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
98/// What: Scan the system pacman configuration for repository section headers.
99///
100/// Inputs:
101/// - `root`: Typically `/etc/pacman.conf`.
102///
103/// Output:
104/// - [`PacmanConfScan`] with merged repo keys and warnings.
105///
106/// Details:
107/// - Follows `Include =` relative to the including file's directory.
108/// - Skips duplicate canonical include targets with a warning.
109/// - Missing files add warnings and continue.
110#[must_use]
111pub fn scan_pacman_conf_path(root: &Path) -> PacmanConfScan {
112    let mut occurrences: HashMap<String, Vec<Occurrence>> = HashMap::new();
113    let mut warnings = Vec::new();
114    let mut visited = HashSet::new();
115    scan_file_recursive(root, 0, &mut visited, &mut occurrences, &mut warnings);
116    let repos = fold_occurrences_map(occurrences);
117    PacmanConfScan { repos, warnings }
118}
119
120/// What: Parse a bracketed section header like `[core]` from a trimmed line.
121///
122/// Inputs:
123/// - `line`: Line without leading `#` (caller strips comments).
124///
125/// Output:
126/// - Inner section name, or `None` if not a header.
127fn parse_bracket_header(line: &str) -> Option<&str> {
128    let s = line.trim();
129    let rest = s.strip_prefix('[')?;
130    let inner = rest.strip_suffix(']')?;
131    let name = inner.trim();
132    if name.is_empty() {
133        return None;
134    }
135    Some(name)
136}
137
138/// What: Parse `Include = path` (case-insensitive key).
139///
140/// Inputs:
141/// - `line`: Non-comment trimmed line.
142///
143/// Output:
144/// - Include path without surrounding quotes.
145fn parse_include_line(line: &str) -> Option<&str> {
146    let mut iter = line.splitn(2, '=');
147    let key = iter.next()?.trim();
148    if !key.eq_ignore_ascii_case("include") {
149        return None;
150    }
151    let val = iter.next()?.trim();
152    let trimmed = val.trim_matches(|c| c == '"' || c == '\'');
153    if trimmed.is_empty() {
154        return None;
155    }
156    Some(trimmed)
157}
158
159/// What: Resolve an include path against the current file's directory.
160///
161/// Inputs:
162/// - `base_dir`: Parent directory of the file containing the `Include` line.
163/// - `raw`: Path string from config.
164///
165/// Output:
166/// - Absolute or joined path.
167fn resolve_include_path(base_dir: &Path, raw: &str) -> PathBuf {
168    let p = Path::new(raw);
169    if p.is_absolute() {
170        p.to_path_buf()
171    } else {
172        base_dir.join(p)
173    }
174}
175
176/// What: Record section headers and queue includes from one file's contents.
177///
178/// Inputs:
179/// - `content`: Full file text.
180/// - `source_path`: Path of this file (for occurrences and include resolution).
181/// - `occurrences`: Running map of section names.
182/// - `pending_includes`: Output paths to recurse into.
183///
184/// Output:
185/// - None (mutates maps).
186///
187/// Details:
188/// - Lines starting with `#` may contain `# [repo]` which counts as commented.
189fn collect_from_content(
190    content: &str,
191    source_path: &Path,
192    occurrences: &mut HashMap<String, Vec<Occurrence>>,
193    pending_includes: &mut Vec<PathBuf>,
194) {
195    let base_dir = source_path.parent().unwrap_or_else(|| Path::new("/"));
196    for line in content.lines() {
197        let trimmed = line.trim();
198        if trimmed.is_empty() {
199            continue;
200        }
201        if let Some(rest) = trimmed.strip_prefix('#') {
202            let inner = rest.trim();
203            if let Some(sec) = parse_bracket_header(inner)
204                && !sec.eq_ignore_ascii_case("options")
205            {
206                let name = sec.trim().to_lowercase();
207                occurrences.entry(name).or_default().push(Occurrence {
208                    active: false,
209                    path: source_path.to_path_buf(),
210                });
211            }
212            continue;
213        }
214        if let Some(sec) = parse_bracket_header(trimmed) {
215            if !sec.eq_ignore_ascii_case("options") {
216                let name = sec.trim().to_lowercase();
217                occurrences.entry(name).or_default().push(Occurrence {
218                    active: true,
219                    path: source_path.to_path_buf(),
220                });
221            }
222            continue;
223        }
224        if let Some(inc) = parse_include_line(trimmed) {
225            pending_includes.push(resolve_include_path(base_dir, inc));
226        }
227    }
228}
229
230/// What: Fold per-repo occurrence lists into a single [`PacmanRepoPresence`].
231///
232/// Inputs:
233/// - `occurrences`: Map built while scanning files.
234///
235/// Output:
236/// - Map suitable for [`PacmanConfScan::repos`].
237///
238/// Details:
239/// - Any active header forces [`PacmanRepoPresence::Active`].
240fn fold_occurrences_map(
241    occurrences: HashMap<String, Vec<Occurrence>>,
242) -> HashMap<String, PacmanRepoPresence> {
243    occurrences
244        .into_iter()
245        .map(|(k, v)| (k, fold_one_repo(&v)))
246        .collect()
247}
248
249/// What: Merge occurrences for one repository name.
250///
251/// Inputs:
252/// - `items`: Non-empty list of occurrences for that name.
253///
254/// Output:
255/// - Merged [`PacmanRepoPresence`].
256///
257/// Details:
258/// - Prefers active over commented; picks a representative source path.
259fn fold_one_repo(items: &[Occurrence]) -> PacmanRepoPresence {
260    if items.is_empty() {
261        return PacmanRepoPresence::Absent;
262    }
263    if items.iter().any(|o| o.active) {
264        PacmanRepoPresence::Active {
265            source: items.iter().find(|o| o.active).map(|o| o.path.clone()),
266        }
267    } else {
268        PacmanRepoPresence::Commented {
269            source: items.first().map(|o| o.path.clone()),
270        }
271    }
272}
273
274/// What: Recursively read a pacman config file and follow includes.
275///
276/// Inputs:
277/// - `path`: File to read.
278/// - `depth`: Current include depth.
279/// - `visited`: Canonical paths already parsed.
280/// - `occurrences`: Aggregated section headers.
281/// - `warnings`: Diagnostic messages.
282///
283/// Output:
284/// - None.
285fn scan_file_recursive(
286    path: &Path,
287    depth: usize,
288    visited: &mut HashSet<PathBuf>,
289    occurrences: &mut HashMap<String, Vec<Occurrence>>,
290    warnings: &mut Vec<String>,
291) {
292    if depth > MAX_INCLUDE_DEPTH {
293        warnings.push(format!(
294            "pacman.conf: max Include depth ({MAX_INCLUDE_DEPTH}) reached at {}",
295            path.display()
296        ));
297        return;
298    }
299
300    let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
301    if visited.contains(&canon) {
302        warnings.push(format!(
303            "pacman.conf: skipping duplicate Include {}",
304            path.display()
305        ));
306        return;
307    }
308
309    let content = match std::fs::read_to_string(path) {
310        Ok(c) => c,
311        Err(e) => {
312            warnings.push(format!(
313                "pacman.conf: could not read {}: {e}",
314                path.display()
315            ));
316            return;
317        }
318    };
319
320    visited.insert(canon);
321
322    let mut pending_includes: Vec<PathBuf> = Vec::new();
323    collect_from_content(&content, path, occurrences, &mut pending_includes);
324
325    for inc in pending_includes {
326        scan_file_recursive(&inc, depth + 1, visited, occurrences, warnings);
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use std::io::Write;
334
335    #[test]
336    fn active_repo_recorded() {
337        let dir = tempfile::tempdir().expect("tempdir");
338        let main = dir.path().join("pacman.conf");
339        std::fs::write(
340            &main,
341            "[options]\n[chaotic-aur]\nServer = https://example.invalid\n",
342        )
343        .expect("write");
344        let scan = scan_pacman_conf_path(&main);
345        assert!(matches!(
346            scan.presence_of("chaotic-aur"),
347            PacmanRepoPresence::Active { .. }
348        ));
349    }
350
351    #[test]
352    fn commented_repo_recorded() {
353        let dir = tempfile::tempdir().expect("tempdir");
354        let main = dir.path().join("pacman.conf");
355        std::fs::write(&main, "# [endeavouros]\n").expect("write");
356        let scan = scan_pacman_conf_path(&main);
357        assert!(matches!(
358            scan.presence_of("endeavouros"),
359            PacmanRepoPresence::Commented { .. }
360        ));
361    }
362
363    #[test]
364    fn include_pulls_in_child_sections() {
365        let dir = tempfile::tempdir().expect("tempdir");
366        let child = dir.path().join("extra.conf");
367        std::fs::write(&child, "[customrepo]\nServer = https://x.test\n").expect("write");
368        let main = dir.path().join("pacman.conf");
369        std::fs::write(
370            &main,
371            format!(
372                "Include = {}\n",
373                child.file_name().expect("name").to_str().expect("utf8")
374            ),
375        )
376        .expect("write");
377        let scan = scan_pacman_conf_path(&main);
378        assert!(matches!(
379            scan.presence_of("customrepo"),
380            PacmanRepoPresence::Active { .. }
381        ));
382    }
383
384    #[test]
385    fn active_beats_commented_across_files() {
386        let dir = tempfile::tempdir().expect("tempdir");
387        let child = dir.path().join("b.conf");
388        std::fs::write(&child, "[same]\n").expect("write");
389        let main = dir.path().join("pacman.conf");
390        let mut f = std::fs::File::create(&main).expect("create");
391        writeln!(f, "# [same]").expect("write");
392        writeln!(
393            f,
394            "Include = {}",
395            child.file_name().expect("n").to_str().expect("utf8")
396        )
397        .expect("write");
398        drop(f);
399        let scan = scan_pacman_conf_path(&main);
400        assert!(matches!(
401            scan.presence_of("same"),
402            PacmanRepoPresence::Active { .. }
403        ));
404    }
405
406    #[test]
407    fn options_section_not_a_repo() {
408        let dir = tempfile::tempdir().expect("tempdir");
409        let main = dir.path().join("pacman.conf");
410        std::fs::write(&main, "[options]\nHoldPkg = pacman glibc\n").expect("write");
411        let scan = scan_pacman_conf_path(&main);
412        assert!(matches!(
413            scan.presence_of("options"),
414            PacmanRepoPresence::Absent
415        ));
416    }
417}