Skip to main content

pacsea/logic/repos/
pacman_conf.rs

1//! Read-only scan of `pacman.conf` repository sections with shallow `Include` expansion.
2
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5
6/// What: Maximum depth when following `Include` directives.
7///
8/// Inputs:
9/// - N/A (constant).
10///
11/// Output:
12/// - Depth bound to avoid infinite recursion on cyclic includes.
13///
14/// Details:
15/// - Matches common small stacks of `Include`d fragments under `/etc/pacman.d/`.
16const MAX_INCLUDE_DEPTH: usize = 8;
17
18/// What: One occurrence of a repository section header in a parsed file.
19///
20/// Inputs:
21/// - N/A (internal struct).
22///
23/// Output:
24/// - Used to merge active vs commented sections across files.
25///
26/// Details:
27/// - The same repo name may appear multiple times; active headers take precedence over commented.
28struct Occurrence {
29    /// Whether the `[repo]` line was active (not prefixed with `#`).
30    active: bool,
31    /// File path where the header was found.
32    path: PathBuf,
33}
34
35/// What: Presence of a pacman repository section after scanning config trees.
36///
37/// Inputs:
38/// - Produced by [`scan_pacman_conf_path`].
39///
40/// Output:
41/// - Classification for UI and merge logic.
42///
43/// Details:
44/// - `[options]` is never stored here; only repository sections are tracked.
45/// - If both active and commented headers exist anywhere, [`Self::Active`] wins.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum PacmanRepoPresence {
48    /// No `[name]` or `# [name]` header was found.
49    Absent,
50    /// At least one active `[name]` section exists.
51    Active {
52        /// File where an active header was first found (any matching occurrence).
53        source: Option<PathBuf>,
54    },
55    /// Only commented `# [name]` headers exist.
56    Commented {
57        /// File path for reference.
58        source: Option<PathBuf>,
59    },
60}
61
62/// What: Result of scanning `/etc/pacman.conf` and included files.
63///
64/// Inputs:
65/// - Returned by [`scan_pacman_conf_path`].
66///
67/// Output:
68/// - Map keyed by lowercase repository section name.
69///
70/// Details:
71/// - Warnings list I/O or include issues without failing the whole scan.
72#[derive(Debug, Clone)]
73pub struct PacmanConfScan {
74    /// Repository section name (lowercase) mapped to merged [`PacmanRepoPresence`].
75    pub repos: HashMap<String, PacmanRepoPresence>,
76    /// Non-fatal parse, I/O, or include problems collected during the scan.
77    pub warnings: Vec<String>,
78}
79
80impl PacmanConfScan {
81    /// What: Look up merged presence for a repo name from `repos.conf`.
82    ///
83    /// Inputs:
84    /// - `repo_name`: `[[repo]]` `name` value (case-insensitive).
85    ///
86    /// Output:
87    /// - [`PacmanRepoPresence::Absent`] when unknown.
88    #[must_use]
89    pub fn presence_of(&self, repo_name: &str) -> PacmanRepoPresence {
90        let key = repo_name.trim().to_lowercase();
91        self.repos
92            .get(&key)
93            .cloned()
94            .unwrap_or(PacmanRepoPresence::Absent)
95    }
96
97    /// What: Collect lowercase names of repositories that have an active `[name]` section.
98    ///
99    /// Inputs:
100    /// - `self`: Merged scan from [`scan_pacman_conf_path`].
101    ///
102    /// Output:
103    /// - A set of active repository section names (keys are already lowercased).
104    ///
105    /// Details:
106    /// - Skips commented-only headers and omits `[options]`. Callers use this to avoid
107    ///   `pacman -Sl` probes against databases the system does not have configured.
108    #[must_use]
109    pub fn active_repo_names_lower(&self) -> HashSet<String> {
110        self.repos
111            .iter()
112            .filter(|(_, presence)| matches!(presence, PacmanRepoPresence::Active { .. }))
113            .map(|(name, _)| name.clone())
114            .collect()
115    }
116}
117
118/// What: Scan the system pacman configuration for repository section headers.
119///
120/// Inputs:
121/// - `root`: Typically `/etc/pacman.conf`.
122///
123/// Output:
124/// - [`PacmanConfScan`] with merged repo keys and warnings.
125///
126/// Details:
127/// - Follows `Include =` relative to the including file's directory.
128/// - Skips duplicate canonical include targets with a warning.
129/// - Missing files add warnings and continue.
130#[must_use]
131pub fn scan_pacman_conf_path(root: &Path) -> PacmanConfScan {
132    let mut occurrences: HashMap<String, Vec<Occurrence>> = HashMap::new();
133    let mut warnings = Vec::new();
134    let mut visited = HashSet::new();
135    scan_file_recursive(root, 0, &mut visited, &mut occurrences, &mut warnings);
136    let repos = fold_occurrences_map(occurrences);
137    PacmanConfScan { repos, warnings }
138}
139
140/// What: Parse a bracketed section header like `[core]` from a trimmed line.
141///
142/// Inputs:
143/// - `line`: Line without leading `#` (caller strips comments).
144///
145/// Output:
146/// - Inner section name, or `None` if not a header.
147fn parse_bracket_header(line: &str) -> Option<&str> {
148    let s = line.trim();
149    let rest = s.strip_prefix('[')?;
150    let inner = rest.strip_suffix(']')?;
151    let name = inner.trim();
152    if name.is_empty() {
153        return None;
154    }
155    Some(name)
156}
157
158/// What: Parse `Include = path` (case-insensitive key).
159///
160/// Inputs:
161/// - `line`: Non-comment trimmed line.
162///
163/// Output:
164/// - Include path without surrounding quotes.
165fn parse_include_line(line: &str) -> Option<&str> {
166    let mut iter = line.splitn(2, '=');
167    let key = iter.next()?.trim();
168    if !key.eq_ignore_ascii_case("include") {
169        return None;
170    }
171    let val = iter.next()?.trim();
172    let trimmed = val.trim_matches(|c| c == '"' || c == '\'');
173    if trimmed.is_empty() {
174        return None;
175    }
176    Some(trimmed)
177}
178
179/// What: Resolve an include path against the current file's directory.
180///
181/// Inputs:
182/// - `base_dir`: Parent directory of the file containing the `Include` line.
183/// - `raw`: Path string from config.
184///
185/// Output:
186/// - Absolute or joined path.
187fn resolve_include_path(base_dir: &Path, raw: &str) -> PathBuf {
188    let p = Path::new(raw);
189    if p.is_absolute() {
190        p.to_path_buf()
191    } else {
192        base_dir.join(p)
193    }
194}
195
196/// What: Record section headers and queue includes from one file's contents.
197///
198/// Inputs:
199/// - `content`: Full file text.
200/// - `source_path`: Path of this file (for occurrences and include resolution).
201/// - `occurrences`: Running map of section names.
202/// - `pending_includes`: Output paths to recurse into.
203///
204/// Output:
205/// - None (mutates maps).
206///
207/// Details:
208/// - Lines starting with `#` may contain `# [repo]` which counts as commented.
209fn collect_from_content(
210    content: &str,
211    source_path: &Path,
212    occurrences: &mut HashMap<String, Vec<Occurrence>>,
213    pending_includes: &mut Vec<PathBuf>,
214) {
215    let base_dir = source_path.parent().unwrap_or_else(|| Path::new("/"));
216    for line in content.lines() {
217        let trimmed = line.trim();
218        if trimmed.is_empty() {
219            continue;
220        }
221        if let Some(rest) = trimmed.strip_prefix('#') {
222            let inner = rest.trim();
223            if let Some(sec) = parse_bracket_header(inner)
224                && !sec.eq_ignore_ascii_case("options")
225            {
226                let name = sec.trim().to_lowercase();
227                occurrences.entry(name).or_default().push(Occurrence {
228                    active: false,
229                    path: source_path.to_path_buf(),
230                });
231            }
232            continue;
233        }
234        if let Some(sec) = parse_bracket_header(trimmed) {
235            if !sec.eq_ignore_ascii_case("options") {
236                let name = sec.trim().to_lowercase();
237                occurrences.entry(name).or_default().push(Occurrence {
238                    active: true,
239                    path: source_path.to_path_buf(),
240                });
241            }
242            continue;
243        }
244        if let Some(inc) = parse_include_line(trimmed) {
245            pending_includes.push(resolve_include_path(base_dir, inc));
246        }
247    }
248}
249
250/// What: Fold per-repo occurrence lists into a single [`PacmanRepoPresence`].
251///
252/// Inputs:
253/// - `occurrences`: Map built while scanning files.
254///
255/// Output:
256/// - Map suitable for [`PacmanConfScan::repos`].
257///
258/// Details:
259/// - Any active header forces [`PacmanRepoPresence::Active`].
260fn fold_occurrences_map(
261    occurrences: HashMap<String, Vec<Occurrence>>,
262) -> HashMap<String, PacmanRepoPresence> {
263    occurrences
264        .into_iter()
265        .map(|(k, v)| (k, fold_one_repo(&v)))
266        .collect()
267}
268
269/// What: Merge occurrences for one repository name.
270///
271/// Inputs:
272/// - `items`: Non-empty list of occurrences for that name.
273///
274/// Output:
275/// - Merged [`PacmanRepoPresence`].
276///
277/// Details:
278/// - Prefers active over commented; picks a representative source path.
279fn fold_one_repo(items: &[Occurrence]) -> PacmanRepoPresence {
280    if items.is_empty() {
281        return PacmanRepoPresence::Absent;
282    }
283    if items.iter().any(|o| o.active) {
284        PacmanRepoPresence::Active {
285            source: items.iter().find(|o| o.active).map(|o| o.path.clone()),
286        }
287    } else {
288        PacmanRepoPresence::Commented {
289            source: items.first().map(|o| o.path.clone()),
290        }
291    }
292}
293
294/// What: Recursively read a pacman config file and follow includes.
295///
296/// Inputs:
297/// - `path`: File to read.
298/// - `depth`: Current include depth.
299/// - `visited`: Canonical paths already parsed.
300/// - `occurrences`: Aggregated section headers.
301/// - `warnings`: Diagnostic messages.
302///
303/// Output:
304/// - None.
305fn scan_file_recursive(
306    path: &Path,
307    depth: usize,
308    visited: &mut HashSet<PathBuf>,
309    occurrences: &mut HashMap<String, Vec<Occurrence>>,
310    warnings: &mut Vec<String>,
311) {
312    if depth > MAX_INCLUDE_DEPTH {
313        warnings.push(format!(
314            "pacman.conf: max Include depth ({MAX_INCLUDE_DEPTH}) reached at {}",
315            path.display()
316        ));
317        return;
318    }
319
320    let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
321    if visited.contains(&canon) {
322        warnings.push(format!(
323            "pacman.conf: skipping duplicate Include {}",
324            path.display()
325        ));
326        return;
327    }
328
329    let content = match std::fs::read_to_string(path) {
330        Ok(c) => c,
331        Err(e) => {
332            warnings.push(format!(
333                "pacman.conf: could not read {}: {e}",
334                path.display()
335            ));
336            return;
337        }
338    };
339
340    visited.insert(canon);
341
342    let mut pending_includes: Vec<PathBuf> = Vec::new();
343    collect_from_content(&content, path, occurrences, &mut pending_includes);
344
345    for inc in pending_includes {
346        scan_file_recursive(&inc, depth + 1, visited, occurrences, warnings);
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use std::io::Write;
354
355    #[test]
356    fn active_repo_recorded() {
357        let dir = tempfile::tempdir().expect("tempdir");
358        let main = dir.path().join("pacman.conf");
359        std::fs::write(
360            &main,
361            "[options]\n[chaotic-aur]\nServer = https://example.invalid\n",
362        )
363        .expect("write");
364        let scan = scan_pacman_conf_path(&main);
365        assert!(matches!(
366            scan.presence_of("chaotic-aur"),
367            PacmanRepoPresence::Active { .. }
368        ));
369    }
370
371    #[test]
372    fn commented_repo_recorded() {
373        let dir = tempfile::tempdir().expect("tempdir");
374        let main = dir.path().join("pacman.conf");
375        std::fs::write(&main, "# [endeavouros]\n").expect("write");
376        let scan = scan_pacman_conf_path(&main);
377        assert!(matches!(
378            scan.presence_of("endeavouros"),
379            PacmanRepoPresence::Commented { .. }
380        ));
381    }
382
383    #[test]
384    fn include_pulls_in_child_sections() {
385        let dir = tempfile::tempdir().expect("tempdir");
386        let child = dir.path().join("extra.conf");
387        std::fs::write(&child, "[customrepo]\nServer = https://x.test\n").expect("write");
388        let main = dir.path().join("pacman.conf");
389        std::fs::write(
390            &main,
391            format!(
392                "Include = {}\n",
393                child.file_name().expect("name").to_str().expect("utf8")
394            ),
395        )
396        .expect("write");
397        let scan = scan_pacman_conf_path(&main);
398        assert!(matches!(
399            scan.presence_of("customrepo"),
400            PacmanRepoPresence::Active { .. }
401        ));
402    }
403
404    #[test]
405    fn active_beats_commented_across_files() {
406        let dir = tempfile::tempdir().expect("tempdir");
407        let child = dir.path().join("b.conf");
408        std::fs::write(&child, "[same]\n").expect("write");
409        let main = dir.path().join("pacman.conf");
410        let mut f = std::fs::File::create(&main).expect("create");
411        writeln!(f, "# [same]").expect("write");
412        writeln!(
413            f,
414            "Include = {}",
415            child.file_name().expect("n").to_str().expect("utf8")
416        )
417        .expect("write");
418        drop(f);
419        let scan = scan_pacman_conf_path(&main);
420        assert!(matches!(
421            scan.presence_of("same"),
422            PacmanRepoPresence::Active { .. }
423        ));
424    }
425
426    #[test]
427    fn options_section_not_a_repo() {
428        let dir = tempfile::tempdir().expect("tempdir");
429        let main = dir.path().join("pacman.conf");
430        std::fs::write(&main, "[options]\nHoldPkg = pacman glibc\n").expect("write");
431        let scan = scan_pacman_conf_path(&main);
432        assert!(matches!(
433            scan.presence_of("options"),
434            PacmanRepoPresence::Absent
435        ));
436    }
437
438    #[test]
439    fn active_repo_names_lower_includes_only_active_sections() {
440        let dir = tempfile::tempdir().expect("tempdir");
441        let main = dir.path().join("pacman.conf");
442        std::fs::write(
443            &main,
444            "[options]\n# [blackarch]\n[cachyos-core]\nServer = https://example.invalid\n",
445        )
446        .expect("write");
447        let scan = scan_pacman_conf_path(&main);
448        let active = scan.active_repo_names_lower();
449        assert!(active.contains("cachyos-core"));
450        assert!(!active.contains("blackarch"));
451        assert!(!active.contains("options"));
452    }
453}