use std::path::PathBuf;
use serde::Serialize;
use crate::fs::Fs;
use crate::paths::Pather;
use crate::Result;
pub const PATH_ATTRIBUTION_MARKER: &str = "# dodot path attribution v1";
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RawPathEntry {
pub pack: String,
pub dirs: Vec<PathBuf>,
}
pub fn parse_path_attribution(content: &str) -> Vec<RawPathEntry> {
content
.lines()
.filter(|line| !line.starts_with('#') && !line.trim().is_empty())
.filter_map(|line| {
let (pack, dirs) = line.split_once('\t')?;
if pack.is_empty() || dirs.is_empty() {
return None;
}
let dirs: Vec<PathBuf> = dirs
.split(':')
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.collect();
if dirs.is_empty() {
return None;
}
Some(RawPathEntry {
pack: pack.to_string(),
dirs,
})
})
.collect()
}
pub fn read_path_attribution(fs: &dyn Fs, paths: &dyn Pather) -> Result<Vec<RawPathEntry>> {
let path = paths.path_attribution_path();
if !fs.exists(&path) {
return Ok(Vec::new());
}
Ok(parse_path_attribution(&fs.read_to_string(&path)?))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum PathOrigin {
Declared,
Raw,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct PathProvenanceEntry {
pub pack: String,
pub dir: PathBuf,
pub origin: PathOrigin,
}
pub fn path_provenance(
fs: &dyn Fs,
paths: &dyn Pather,
homebrew: Option<&super::BrewBlocks>,
) -> Result<Vec<PathProvenanceEntry>> {
let Some(scan) = super::scan_pack_contributions(fs, paths)? else {
return Ok(Vec::new());
};
let declared =
super::compose_path_tier(&scan.path_additions, &super::homebrew_known_dirs(homebrew));
let raw = read_path_attribution(fs, paths)?;
let mut pack_order = scan.pack_order;
pack_order.reverse();
let mut out = Vec::new();
for pack in &pack_order {
for c in declared.iter().filter(|c| &c.pack == pack) {
out.push(PathProvenanceEntry {
pack: pack.clone(),
dir: c.dir.clone(),
origin: PathOrigin::Declared,
});
}
if let Some(entry) = raw.iter().find(|r| &r.pack == pack) {
for dir in &entry.dirs {
out.push(PathProvenanceEntry {
pack: pack.clone(),
dir: dir.clone(),
origin: PathOrigin::Raw,
});
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_header_and_rows() {
let content = "# dodot path attribution v1\nvim\t/home/alice/extra/bin\ngit\t/a:/b\n";
let entries = parse_path_attribution(content);
assert_eq!(
entries,
vec![
RawPathEntry {
pack: "vim".into(),
dirs: vec![PathBuf::from("/home/alice/extra/bin")],
},
RawPathEntry {
pack: "git".into(),
dirs: vec![PathBuf::from("/a"), PathBuf::from("/b")],
},
]
);
}
#[test]
fn header_only_content_parses_to_no_entries() {
assert!(parse_path_attribution("# dodot path attribution v1\n").is_empty());
assert!(parse_path_attribution("").is_empty());
}
#[test]
fn skips_malformed_rows() {
let content = "no-tab-here\n\t/a\nvim\t\n";
assert!(parse_path_attribution(content).is_empty());
}
#[test]
fn tolerates_a_missing_header() {
let content = "vim\t/x\n";
let entries = parse_path_attribution(content);
assert_eq!(entries[0].pack, "vim");
}
}