pacsea/logic/files/resolution.rs
1//! File resolution functions for install and remove operations.
2
3use super::backup::get_backup_files;
4use super::lists::{get_installed_file_list, get_remote_file_list};
5use crate::state::modal::{FileChange, FileChangeType, PackageFileInfo};
6use crate::state::types::Source;
7use std::collections::{HashMap, HashSet};
8use std::process::Command;
9
10/// What: Batch fetch remote file lists for multiple official packages using `pacman -Fl`.
11///
12/// Inputs:
13/// - `packages`: Slice of (`package_name`, source) tuples for official packages.
14///
15/// Output:
16/// - `HashMap` mapping package name to its remote file list.
17///
18/// Details:
19/// - Batches queries into chunks of 50 to avoid command-line length limits.
20/// - Parses multi-package `pacman -Fl` output (format: "<pkg> <path>" per line).
21#[must_use]
22pub fn batch_get_remote_file_lists(packages: &[(&str, &Source)]) -> HashMap<String, Vec<String>> {
23 const BATCH_SIZE: usize = 50;
24 let mut result_map = HashMap::new();
25
26 // Group packages by repo to batch them together
27 let mut repo_groups: HashMap<String, Vec<&str>> = HashMap::new();
28 for (name, source) in packages {
29 if let Source::Official { repo, .. } = source {
30 let repo_key = if repo.is_empty() {
31 String::new()
32 } else {
33 repo.clone()
34 };
35 repo_groups.entry(repo_key).or_default().push(name);
36 }
37 }
38
39 for (repo, names) in repo_groups {
40 for chunk in names.chunks(BATCH_SIZE) {
41 let specs: Vec<String> = chunk
42 .iter()
43 .map(|name| {
44 if repo.is_empty() {
45 (*name).to_string()
46 } else {
47 format!("{repo}/{name}")
48 }
49 })
50 .collect();
51
52 let mut args = vec!["-Fl"];
53 args.extend(specs.iter().map(String::as_str));
54
55 match Command::new("pacman")
56 .args(&args)
57 .env("LC_ALL", "C")
58 .env("LANG", "C")
59 .output()
60 {
61 Ok(output) if output.status.success() => {
62 let text = String::from_utf8_lossy(&output.stdout);
63 // Parse pacman -Fl output: format is "<pkg> <path>"
64 // Group by package name
65 let mut pkg_files: HashMap<String, Vec<String>> = HashMap::new();
66 for line in text.lines() {
67 if let Some((pkg, path)) = line.split_once(' ') {
68 // Extract package name (remove repo prefix if present)
69 let pkg_name = if let Some((_, name)) = pkg.split_once('/') {
70 name
71 } else {
72 pkg
73 };
74 pkg_files
75 .entry(pkg_name.to_string())
76 .or_default()
77 .push(path.to_string());
78 }
79 }
80 result_map.extend(pkg_files);
81 }
82 _ => {
83 // If batch fails, fall back to individual queries (but don't do it here to avoid recursion)
84 // The caller will handle individual queries
85 break;
86 }
87 }
88 }
89 }
90 result_map
91}
92
93/// What: Dispatch to the correct file resolution routine based on preflight action.
94///
95/// Inputs:
96/// - `name`: Package name being evaluated.
97/// - `source`: Package source needed for install lookups.
98/// - `action`: Whether the package is being installed or removed.
99///
100/// Output:
101/// - Returns a `PackageFileInfo` on success or an error message.
102///
103/// # Errors
104/// - Returns `Err` when file resolution fails for install or remove operations (see `resolve_install_files` and `resolve_remove_files`)
105///
106/// Details:
107/// - Delegates to either `resolve_install_files` or `resolve_remove_files`.
108pub fn resolve_package_files(
109 name: &str,
110 source: &Source,
111 action: crate::state::modal::PreflightAction,
112) -> Result<PackageFileInfo, String> {
113 match action {
114 crate::state::modal::PreflightAction::Install => resolve_install_files(name, source),
115 crate::state::modal::PreflightAction::Remove => resolve_remove_files(name),
116 crate::state::modal::PreflightAction::Downgrade => resolve_downgrade_files(name, source),
117 }
118}
119
120/// What: Determine new and changed files introduced by installing or upgrading a package.
121///
122/// Inputs:
123/// - `name`: Package name examined.
124/// - `source`: Source repository information for remote lookups.
125///
126/// Output:
127/// - Returns a populated `PackageFileInfo` or an error when file lists cannot be retrieved.
128///
129/// # Errors
130/// - Returns `Err` when remote file list retrieval fails (see `get_remote_file_list`)
131/// - Returns `Err` when installed file list retrieval fails (see `get_installed_file_list`)
132///
133/// Details:
134/// - Compares remote file listings with locally installed files and predicts potential `.pacnew` creations.
135pub fn resolve_install_files(name: &str, source: &Source) -> Result<PackageFileInfo, String> {
136 // Get remote file list
137 let remote_files = get_remote_file_list(name, source)?;
138 resolve_install_files_with_remote_list(name, source, remote_files)
139}
140
141/// What: Determine new and changed files using a pre-fetched remote file list.
142///
143/// Inputs:
144/// - `name`: Package name examined.
145/// - `source`: Source repository information (for backup file lookup).
146/// - `remote_files`: Pre-fetched remote file list.
147///
148/// Output:
149/// - Returns a populated `PackageFileInfo`.
150///
151/// # Errors
152/// - Returns `Err` when installed file list retrieval fails (see `get_installed_file_list`)
153/// - Returns `Err` when backup files retrieval fails (see `get_backup_files`)
154///
155/// Details:
156/// - Compares remote file listings with locally installed files and predicts potential `.pacnew` creations.
157pub fn resolve_install_files_with_remote_list(
158 name: &str,
159 source: &Source,
160 remote_files: Vec<String>,
161) -> Result<PackageFileInfo, String> {
162 // Get installed file list (if package is already installed)
163 let installed_files = get_installed_file_list(name).unwrap_or_default();
164
165 let installed_set: HashSet<&str> = installed_files.iter().map(String::as_str).collect();
166
167 let mut file_changes = Vec::new();
168 let mut new_count = 0;
169 let mut changed_count = 0;
170 let mut config_count = 0;
171 let mut pacnew_candidates = 0;
172
173 // Get backup files for this package (for pacnew/pacsave prediction)
174 let backup_files = get_backup_files(name, source).unwrap_or_default();
175 let backup_set: HashSet<&str> = backup_files.iter().map(String::as_str).collect();
176
177 for path in remote_files {
178 let is_config = path.starts_with("/etc/");
179 let is_dir = path.ends_with('/');
180
181 // Skip directories for now (we can add them later if needed)
182 if is_dir {
183 continue;
184 }
185
186 let change_type = if installed_set.contains(path.as_str()) {
187 changed_count += 1;
188 FileChangeType::Changed
189 } else {
190 new_count += 1;
191 FileChangeType::New
192 };
193
194 if is_config {
195 config_count += 1;
196 }
197
198 // Predict pacnew: file is in backup array and exists (will be changed)
199 let predicted_pacnew = backup_set.contains(path.as_str())
200 && installed_set.contains(path.as_str())
201 && is_config;
202
203 if predicted_pacnew {
204 pacnew_candidates += 1;
205 }
206
207 file_changes.push(FileChange {
208 path,
209 change_type,
210 package: name.to_string(),
211 is_config,
212 predicted_pacnew,
213 predicted_pacsave: false, // Only for remove operations
214 });
215 }
216
217 // Sort files by path for consistent display
218 file_changes.sort_by(|a, b| a.path.cmp(&b.path));
219
220 Ok(PackageFileInfo {
221 name: name.to_string(),
222 files: file_changes,
223 total_count: new_count + changed_count,
224 new_count,
225 changed_count,
226 removed_count: 0,
227 config_count,
228 pacnew_candidates,
229 pacsave_candidates: 0,
230 })
231}
232
233/// What: Enumerate files that would be removed when uninstalling a package.
234///
235/// Inputs:
236/// - `name`: Package scheduled for removal.
237///
238/// Output:
239/// - Returns a `PackageFileInfo` capturing removed files and predicted `.pacsave` candidates.
240///
241/// # Errors
242/// - Returns `Err` when installed file list retrieval fails (see `get_installed_file_list`)
243/// - Returns `Err` when backup files retrieval fails (see `get_backup_files`)
244///
245/// Details:
246/// - Reads installed file lists and backup arrays to flag configuration files requiring user attention.
247pub fn resolve_remove_files(name: &str) -> Result<PackageFileInfo, String> {
248 // Get installed file list
249 let installed_files = get_installed_file_list(name)?;
250
251 let mut file_changes = Vec::new();
252 let mut config_count = 0;
253 let mut pacsave_candidates = 0;
254
255 // Get backup files for this package (for pacsave prediction)
256 let backup_files = get_backup_files(
257 name,
258 &Source::Official {
259 repo: String::new(),
260 arch: String::new(),
261 },
262 )
263 .unwrap_or_default();
264 let backup_set: HashSet<&str> = backup_files.iter().map(String::as_str).collect();
265
266 for path in installed_files {
267 let is_config = path.starts_with("/etc/");
268 let is_dir = path.ends_with('/');
269
270 // Skip directories for now
271 if is_dir {
272 continue;
273 }
274
275 if is_config {
276 config_count += 1;
277 }
278
279 // Predict pacsave: file is in backup array and will be removed
280 let predicted_pacsave = backup_set.contains(path.as_str()) && is_config;
281
282 if predicted_pacsave {
283 pacsave_candidates += 1;
284 }
285
286 file_changes.push(FileChange {
287 path,
288 change_type: FileChangeType::Removed,
289 package: name.to_string(),
290 is_config,
291 predicted_pacnew: false,
292 predicted_pacsave,
293 });
294 }
295
296 // Sort files by path for consistent display
297 file_changes.sort_by(|a, b| a.path.cmp(&b.path));
298
299 let removed_count = file_changes.len();
300
301 Ok(PackageFileInfo {
302 name: name.to_string(),
303 files: file_changes,
304 total_count: removed_count,
305 new_count: 0,
306 changed_count: 0,
307 removed_count,
308 config_count,
309 pacnew_candidates: 0,
310 pacsave_candidates,
311 })
312}
313
314/// What: Enumerate files that would be changed when downgrading a package.
315///
316/// Inputs:
317/// - `name`: Package scheduled for downgrade.
318/// - `source`: Source repository information for remote lookups.
319///
320/// Output:
321/// - Returns a `PackageFileInfo` capturing changed files (downgrade replaces files with older versions).
322///
323/// # Errors
324/// - Returns `Err` when remote file list retrieval fails (see `get_remote_file_list`)
325/// - Returns `Err` when installed file list retrieval fails (see `get_installed_file_list`)
326///
327/// Details:
328/// - For downgrade, files that exist in both current and target versions are marked as "Changed".
329/// - Files are compared between installed and remote (older) versions.
330pub fn resolve_downgrade_files(name: &str, source: &Source) -> Result<PackageFileInfo, String> {
331 // Get remote file list (older version - what we're downgrading TO)
332 let remote_files = get_remote_file_list(name, source)?;
333 // Get installed file list (current version - what we're downgrading FROM)
334 let installed_files = get_installed_file_list(name)?;
335
336 // Normalize paths (remove trailing slashes for comparison)
337 let normalize_path = |p: &str| p.trim_end_matches('/').to_string();
338
339 let installed_set: HashSet<String> =
340 installed_files.iter().map(|p| normalize_path(p)).collect();
341 let remote_set: HashSet<String> = remote_files.iter().map(|p| normalize_path(p)).collect();
342
343 // Get backup files for this package (for pacnew prediction)
344 let backup_files = get_backup_files(name, source).unwrap_or_default();
345 let backup_set: HashSet<String> = backup_files.iter().map(|p| normalize_path(p)).collect();
346
347 let mut file_changes = Vec::new();
348 let mut changed_count = 0;
349 let mut new_count = 0;
350 let mut config_count = 0;
351 let mut pacnew_candidates = 0;
352
353 // Iterate over installed files to find files that will be changed
354 // Files that exist in both versions are "Changed" (being replaced with older version)
355 for path in installed_files {
356 let normalized_path = normalize_path(&path);
357 let is_config = path.starts_with("/etc/");
358 let is_dir = path.ends_with('/');
359
360 // Skip directories for now
361 if is_dir {
362 continue;
363 }
364
365 if is_config {
366 config_count += 1;
367 }
368
369 // If file exists in remote (older) version, it's being changed (downgraded)
370 if remote_set.contains(&normalized_path) {
371 changed_count += 1;
372
373 // Predict pacnew: file is in backup array and exists (will be changed to older version)
374 let predicted_pacnew = backup_set.contains(&normalized_path) && is_config;
375
376 if predicted_pacnew {
377 pacnew_candidates += 1;
378 }
379
380 file_changes.push(FileChange {
381 path,
382 change_type: FileChangeType::Changed,
383 package: name.to_string(),
384 is_config,
385 predicted_pacnew,
386 predicted_pacsave: false,
387 });
388 }
389 // Files that exist only in installed (newer) version but not in remote (older) version are "Removed"
390 else {
391 file_changes.push(FileChange {
392 path,
393 change_type: FileChangeType::Removed,
394 package: name.to_string(),
395 is_config,
396 predicted_pacnew: false,
397 predicted_pacsave: backup_set.contains(&normalized_path) && is_config,
398 });
399 }
400 }
401
402 // Also check for files that exist only in remote (older) version but not installed (newer) version - these are "New"
403 for path in remote_files {
404 let normalized_path = normalize_path(&path);
405 let is_config = path.starts_with("/etc/");
406 let is_dir = path.ends_with('/');
407
408 // Skip directories for now
409 if is_dir {
410 continue;
411 }
412
413 // If file doesn't exist in installed version, it's "New" (will be added back)
414 if !installed_set.contains(&normalized_path) {
415 new_count += 1;
416 file_changes.push(FileChange {
417 path,
418 change_type: FileChangeType::New,
419 package: name.to_string(),
420 is_config,
421 predicted_pacnew: false,
422 predicted_pacsave: false,
423 });
424 }
425 }
426
427 // Sort files by path for consistent display
428 file_changes.sort_by(|a, b| a.path.cmp(&b.path));
429
430 let removed_count = file_changes
431 .iter()
432 .filter(|f| matches!(f.change_type, FileChangeType::Removed))
433 .count();
434
435 Ok(PackageFileInfo {
436 name: name.to_string(),
437 files: file_changes,
438 total_count: changed_count + new_count + removed_count,
439 new_count,
440 changed_count,
441 removed_count,
442 config_count,
443 pacnew_candidates,
444 pacsave_candidates: 0,
445 })
446}