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}