use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
const MAX_INCLUDE_DEPTH: usize = 8;
struct Occurrence {
active: bool,
path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PacmanRepoPresence {
Absent,
Active {
source: Option<PathBuf>,
},
Commented {
source: Option<PathBuf>,
},
}
#[derive(Debug, Clone)]
pub struct PacmanConfScan {
pub repos: HashMap<String, PacmanRepoPresence>,
pub warnings: Vec<String>,
}
impl PacmanConfScan {
#[must_use]
pub fn presence_of(&self, repo_name: &str) -> PacmanRepoPresence {
let key = repo_name.trim().to_lowercase();
self.repos
.get(&key)
.cloned()
.unwrap_or(PacmanRepoPresence::Absent)
}
}
#[must_use]
pub fn scan_pacman_conf_path(root: &Path) -> PacmanConfScan {
let mut occurrences: HashMap<String, Vec<Occurrence>> = HashMap::new();
let mut warnings = Vec::new();
let mut visited = HashSet::new();
scan_file_recursive(root, 0, &mut visited, &mut occurrences, &mut warnings);
let repos = fold_occurrences_map(occurrences);
PacmanConfScan { repos, warnings }
}
fn parse_bracket_header(line: &str) -> Option<&str> {
let s = line.trim();
let rest = s.strip_prefix('[')?;
let inner = rest.strip_suffix(']')?;
let name = inner.trim();
if name.is_empty() {
return None;
}
Some(name)
}
fn parse_include_line(line: &str) -> Option<&str> {
let mut iter = line.splitn(2, '=');
let key = iter.next()?.trim();
if !key.eq_ignore_ascii_case("include") {
return None;
}
let val = iter.next()?.trim();
let trimmed = val.trim_matches(|c| c == '"' || c == '\'');
if trimmed.is_empty() {
return None;
}
Some(trimmed)
}
fn resolve_include_path(base_dir: &Path, raw: &str) -> PathBuf {
let p = Path::new(raw);
if p.is_absolute() {
p.to_path_buf()
} else {
base_dir.join(p)
}
}
fn collect_from_content(
content: &str,
source_path: &Path,
occurrences: &mut HashMap<String, Vec<Occurrence>>,
pending_includes: &mut Vec<PathBuf>,
) {
let base_dir = source_path.parent().unwrap_or_else(|| Path::new("/"));
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some(rest) = trimmed.strip_prefix('#') {
let inner = rest.trim();
if let Some(sec) = parse_bracket_header(inner)
&& !sec.eq_ignore_ascii_case("options")
{
let name = sec.trim().to_lowercase();
occurrences.entry(name).or_default().push(Occurrence {
active: false,
path: source_path.to_path_buf(),
});
}
continue;
}
if let Some(sec) = parse_bracket_header(trimmed) {
if !sec.eq_ignore_ascii_case("options") {
let name = sec.trim().to_lowercase();
occurrences.entry(name).or_default().push(Occurrence {
active: true,
path: source_path.to_path_buf(),
});
}
continue;
}
if let Some(inc) = parse_include_line(trimmed) {
pending_includes.push(resolve_include_path(base_dir, inc));
}
}
}
fn fold_occurrences_map(
occurrences: HashMap<String, Vec<Occurrence>>,
) -> HashMap<String, PacmanRepoPresence> {
occurrences
.into_iter()
.map(|(k, v)| (k, fold_one_repo(&v)))
.collect()
}
fn fold_one_repo(items: &[Occurrence]) -> PacmanRepoPresence {
if items.is_empty() {
return PacmanRepoPresence::Absent;
}
if items.iter().any(|o| o.active) {
PacmanRepoPresence::Active {
source: items.iter().find(|o| o.active).map(|o| o.path.clone()),
}
} else {
PacmanRepoPresence::Commented {
source: items.first().map(|o| o.path.clone()),
}
}
}
fn scan_file_recursive(
path: &Path,
depth: usize,
visited: &mut HashSet<PathBuf>,
occurrences: &mut HashMap<String, Vec<Occurrence>>,
warnings: &mut Vec<String>,
) {
if depth > MAX_INCLUDE_DEPTH {
warnings.push(format!(
"pacman.conf: max Include depth ({MAX_INCLUDE_DEPTH}) reached at {}",
path.display()
));
return;
}
let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if visited.contains(&canon) {
warnings.push(format!(
"pacman.conf: skipping duplicate Include {}",
path.display()
));
return;
}
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
warnings.push(format!(
"pacman.conf: could not read {}: {e}",
path.display()
));
return;
}
};
visited.insert(canon);
let mut pending_includes: Vec<PathBuf> = Vec::new();
collect_from_content(&content, path, occurrences, &mut pending_includes);
for inc in pending_includes {
scan_file_recursive(&inc, depth + 1, visited, occurrences, warnings);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn active_repo_recorded() {
let dir = tempfile::tempdir().expect("tempdir");
let main = dir.path().join("pacman.conf");
std::fs::write(
&main,
"[options]\n[chaotic-aur]\nServer = https://example.invalid\n",
)
.expect("write");
let scan = scan_pacman_conf_path(&main);
assert!(matches!(
scan.presence_of("chaotic-aur"),
PacmanRepoPresence::Active { .. }
));
}
#[test]
fn commented_repo_recorded() {
let dir = tempfile::tempdir().expect("tempdir");
let main = dir.path().join("pacman.conf");
std::fs::write(&main, "# [endeavouros]\n").expect("write");
let scan = scan_pacman_conf_path(&main);
assert!(matches!(
scan.presence_of("endeavouros"),
PacmanRepoPresence::Commented { .. }
));
}
#[test]
fn include_pulls_in_child_sections() {
let dir = tempfile::tempdir().expect("tempdir");
let child = dir.path().join("extra.conf");
std::fs::write(&child, "[customrepo]\nServer = https://x.test\n").expect("write");
let main = dir.path().join("pacman.conf");
std::fs::write(
&main,
format!(
"Include = {}\n",
child.file_name().expect("name").to_str().expect("utf8")
),
)
.expect("write");
let scan = scan_pacman_conf_path(&main);
assert!(matches!(
scan.presence_of("customrepo"),
PacmanRepoPresence::Active { .. }
));
}
#[test]
fn active_beats_commented_across_files() {
let dir = tempfile::tempdir().expect("tempdir");
let child = dir.path().join("b.conf");
std::fs::write(&child, "[same]\n").expect("write");
let main = dir.path().join("pacman.conf");
let mut f = std::fs::File::create(&main).expect("create");
writeln!(f, "# [same]").expect("write");
writeln!(
f,
"Include = {}",
child.file_name().expect("n").to_str().expect("utf8")
)
.expect("write");
drop(f);
let scan = scan_pacman_conf_path(&main);
assert!(matches!(
scan.presence_of("same"),
PacmanRepoPresence::Active { .. }
));
}
#[test]
fn options_section_not_a_repo() {
let dir = tempfile::tempdir().expect("tempdir");
let main = dir.path().join("pacman.conf");
std::fs::write(&main, "[options]\nHoldPkg = pacman glibc\n").expect("write");
let scan = scan_pacman_conf_path(&main);
assert!(matches!(
scan.presence_of("options"),
PacmanRepoPresence::Absent
));
}
}