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 #[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#[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
140fn 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
158fn 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
179fn 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
196fn 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
250fn 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
269fn 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
294fn 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}