use crate::core::config::loader::Config;
use crate::core::config::trust::{TrustDecision, TrustStore};
use anyhow::Result;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
pub const REPO_CONFIG_FILE: &str = ".vig.kdl";
pub fn content_hash(text: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
let digest = hasher.finalize();
let mut hex = String::with_capacity(digest.len() * 2);
for b in digest {
use std::fmt::Write;
let _ = write!(hex, "{b:02x}");
}
hex
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tracked {
Yes,
No,
Unknown,
}
pub fn is_tracked(workdir: &Path) -> Tracked {
let status = std::process::Command::new("git")
.arg("-C")
.arg(workdir)
.args(["ls-files", "--error-unmatch", REPO_CONFIG_FILE])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
interpret_ls_files(status)
}
fn interpret_ls_files(status: std::io::Result<std::process::ExitStatus>) -> Tracked {
match status {
Ok(s) if s.success() => Tracked::Yes,
Ok(s) if s.code() == Some(1) => Tracked::No,
_ => Tracked::Unknown,
}
}
#[derive(Debug)]
pub enum RepoLayer {
Absent { path: PathBuf },
Disabled { path: PathBuf },
Load { path: PathBuf, text: String },
Declined { path: PathBuf },
Undecided {
path: PathBuf,
text: String,
hash: String,
},
}
pub fn classify(
workdir: &Path,
repo_config_enabled: bool,
store: &TrustStore,
tracked: impl FnOnce(&Path) -> Tracked,
) -> RepoLayer {
let path = workdir.join(REPO_CONFIG_FILE);
if !repo_config_enabled {
return RepoLayer::Disabled { path };
}
let Ok(text) = std::fs::read_to_string(&path) else {
return RepoLayer::Absent { path };
};
if tracked(workdir) == Tracked::No {
return RepoLayer::Load { path, text };
}
let hash = content_hash(&text);
match store.decision(workdir, &hash) {
Some(TrustDecision::Load) => RepoLayer::Load { path, text },
Some(TrustDecision::Ignore) => RepoLayer::Declined { path },
None => RepoLayer::Undecided { path, text, hash },
}
}
pub fn apply(cfg: &Config, path: &Path, text: &str) -> Result<Config> {
let doc = crate::core::config::source::parse_user_kdl(text, path)?;
cfg.with_repo_layer(&doc, path.to_path_buf())
}
pub fn summarize(err: &anyhow::Error) -> String {
let chain = format!("{err:#}");
chain.lines().next().unwrap_or("error").to_string()
}
pub fn status_text(layer: &RepoLayer, apply_error: Option<&str>) -> String {
match layer {
RepoLayer::Absent { .. } => "not found".to_string(),
RepoLayer::Disabled { .. } => "ignored (repo-config \"off\")".to_string(),
RepoLayer::Declined { .. } => {
"ignored (trust declined; see `vig config trust`)".to_string()
}
RepoLayer::Undecided { .. } => "pending trust decision (start vig to decide)".to_string(),
RepoLayer::Load { .. } => match apply_error {
None => "loaded".to_string(),
Some(e) => format!("ignored ({e})"),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_worktree(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("vig-repo-test-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn content_hash_is_sha256_hex() {
assert_eq!(
content_hash(""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_ne!(content_hash("a"), content_hash("b"));
}
#[test]
fn classify_absent_disabled_untracked() {
let dir = tmp_worktree("classify");
let store = TrustStore::default();
assert!(matches!(
classify(&dir, true, &store, |_| Tracked::No),
RepoLayer::Absent { .. }
));
std::fs::write(dir.join(REPO_CONFIG_FILE), "theme \"InspiredGitHub\"\n").unwrap();
assert!(matches!(
classify(&dir, false, &store, |_| Tracked::Yes),
RepoLayer::Disabled { .. }
));
match classify(&dir, true, &store, |_| Tracked::No) {
RepoLayer::Load { text, .. } => assert!(text.contains("InspiredGitHub")),
other => panic!("expected Load, got {other:?}"),
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn classify_tracked_consults_trust_store() {
let dir = tmp_worktree("tracked");
let text = "theme \"InspiredGitHub\"\n";
std::fs::write(dir.join(REPO_CONFIG_FILE), text).unwrap();
let hash = content_hash(text);
let mut store = TrustStore::default();
assert!(matches!(
classify(&dir, true, &store, |_| Tracked::Yes),
RepoLayer::Undecided { .. }
));
store.remember(&dir, &hash, TrustDecision::Load);
assert!(matches!(
classify(&dir, true, &store, |_| Tracked::Yes),
RepoLayer::Load { .. }
));
store.remember(&dir, &hash, TrustDecision::Ignore);
assert!(matches!(
classify(&dir, true, &store, |_| Tracked::Yes),
RepoLayer::Declined { .. }
));
std::fs::write(dir.join(REPO_CONFIG_FILE), "theme \"Solarized (dark)\"\n").unwrap();
assert!(matches!(
classify(&dir, true, &store, |_| Tracked::Yes),
RepoLayer::Undecided { .. }
));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn undeterminable_tracking_fails_closed() {
let dir = tmp_worktree("unknown");
let text = "theme \"InspiredGitHub\"\n";
std::fs::write(dir.join(REPO_CONFIG_FILE), text).unwrap();
let store = TrustStore::default();
assert!(matches!(
classify(&dir, true, &store, |_| Tracked::Unknown),
RepoLayer::Undecided { .. }
));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn spawn_failure_is_undeterminable() {
assert_eq!(
interpret_ls_files(Err(std::io::Error::other("git missing"))),
Tracked::Unknown
);
}
#[cfg(unix)]
#[test]
fn interpret_ls_files_distinguishes_untracked_from_failure() {
use std::os::unix::process::ExitStatusExt;
let exit = |code: i32| Ok(std::process::ExitStatus::from_raw(code << 8));
assert_eq!(interpret_ls_files(exit(0)), Tracked::Yes);
assert_eq!(interpret_ls_files(exit(1)), Tracked::No);
assert_eq!(interpret_ls_files(exit(128)), Tracked::Unknown);
assert_eq!(
interpret_ls_files(Ok(std::process::ExitStatus::from_raw(9))),
Tracked::Unknown
);
}
#[test]
fn apply_error_degrades_and_names_the_file() {
let cfg = Config::builtin();
let err = apply(&cfg, Path::new("/w/.vig.kdl"), "theme \"no-such-theme\"").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("/w/.vig.kdl"), "{msg}");
assert!(cfg.theme().is_ok());
let summary = summarize(&err);
assert_eq!(summary.lines().count(), 1);
}
#[test]
fn status_text_covers_every_outcome() {
let p = PathBuf::from("/w/.vig.kdl");
let load = RepoLayer::Load {
path: p.clone(),
text: String::new(),
};
assert_eq!(status_text(&load, None), "loaded");
assert_eq!(status_text(&load, Some("boom")), "ignored (boom)");
assert_eq!(
status_text(&RepoLayer::Absent { path: p.clone() }, None),
"not found"
);
assert!(status_text(&RepoLayer::Disabled { path: p.clone() }, None)
.contains("repo-config \"off\""));
assert!(
status_text(&RepoLayer::Declined { path: p.clone() }, None).contains("trust declined")
);
assert!(status_text(
&RepoLayer::Undecided {
path: p,
text: String::new(),
hash: String::new()
},
None
)
.contains("pending"));
}
}