pacsea/logic/repos/
pacman_conf.rs1use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5
6const MAX_INCLUDE_DEPTH: usize = 8;
17
18struct Occurrence {
29 active: bool,
31 path: PathBuf,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum PacmanRepoPresence {
48 Absent,
50 Active {
52 source: Option<PathBuf>,
54 },
55 Commented {
57 source: Option<PathBuf>,
59 },
60}
61
62#[derive(Debug, Clone)]
73pub struct PacmanConfScan {
74 pub repos: HashMap<String, PacmanRepoPresence>,
76 pub warnings: Vec<String>,
78}
79
80impl PacmanConfScan {
81 #[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#[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
120fn 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
138fn 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
159fn 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
176fn 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
230fn 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
249fn 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
274fn 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}