use std::path::Path;
use std::process::Command;
use super::entry::Entry;
use super::store::Store;
pub(crate) enum Report {
NotARepo,
Clean,
Tampered(Vec<String>),
}
enum Change {
Modified,
Deleted,
Renamed,
}
pub(crate) fn check(store: &Store) -> Report {
let root = store.root();
if !inside_repo(root) {
return Report::NotARepo;
}
let porcelain = match git_status(root) {
Some(s) => s,
None => return Report::NotARepo,
};
let mut tampered = Vec::new();
for (change, rel) in parse_porcelain(&porcelain) {
match change {
Change::Deleted => {
tampered.push(format!("{rel} (deleted — entries are never removed)"))
}
Change::Renamed => {
tampered.push(format!("{rel} (renamed — the filename is the entry id)"))
}
Change::Modified => {
if !is_redaction(root, &rel) {
tampered.push(format!("{rel} (modified outside a redaction)"));
}
}
}
}
if tampered.is_empty() {
Report::Clean
} else {
Report::Tampered(tampered)
}
}
fn inside_repo(root: &Path) -> bool {
Command::new("git")
.arg("-C")
.arg(root)
.args(["rev-parse", "--is-inside-work-tree"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn git_status(root: &Path) -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(root)
.args(["status", "--porcelain", "--", ".rustio/memory/entries"])
.output()
.ok()?;
if out.status.success() {
Some(String::from_utf8_lossy(&out.stdout).into_owned())
} else {
None
}
}
fn is_redaction(root: &Path, rel: &str) -> bool {
let stem = match Path::new(rel).file_stem().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => return false,
};
std::fs::read_to_string(root.join(rel))
.ok()
.and_then(|raw| Entry::parse(&stem, &raw).ok())
.map(|e| e.redacted)
.unwrap_or(false)
}
fn parse_porcelain(out: &str) -> Vec<(Change, String)> {
let mut changes = Vec::new();
for line in out.lines() {
if line.len() < 4 {
continue;
}
let xy = &line[..2];
let rest = line[3..].trim();
let path = rest.rsplit(" -> ").next().unwrap_or(rest).trim_matches('"');
if !(path.starts_with(".rustio/memory/entries/") && path.ends_with(".md")) {
continue;
}
if xy.contains('D') {
changes.push((Change::Deleted, path.to_string()));
} else if xy.contains('R') {
changes.push((Change::Renamed, path.to_string()));
} else if xy.contains('M') {
changes.push((Change::Modified, path.to_string()));
}
}
changes
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(out: &str) -> Vec<(&'static str, String)> {
parse_porcelain(out)
.into_iter()
.map(|(c, p)| {
let k = match c {
Change::Modified => "M",
Change::Deleted => "D",
Change::Renamed => "R",
};
(k, p)
})
.collect()
}
#[test]
fn flags_modified_deleted_renamed_entries() {
let out = " M .rustio/memory/entries/aaa.md\n\
D .rustio/memory/entries/bbb.md\n\
R .rustio/memory/entries/old.md -> .rustio/memory/entries/new.md\n";
let got = kinds(out);
assert_eq!(got[0], ("M", ".rustio/memory/entries/aaa.md".into()));
assert_eq!(got[1], ("D", ".rustio/memory/entries/bbb.md".into()));
assert_eq!(got[2], ("R", ".rustio/memory/entries/new.md".into()));
}
#[test]
fn ignores_new_and_nonentry_changes() {
let out = "?? .rustio/memory/entries/ccc.md\n\
A .rustio/memory/entries/ddd.md\n\
M CLOUD.md\n";
assert!(kinds(out).is_empty(), "got {:?}", kinds(out));
}
#[test]
fn not_a_repo_when_git_absent() {
let dir = std::env::temp_dir().join("rustio-integrity-norepo-xyz123");
std::fs::create_dir_all(&dir).unwrap();
let store = Store::new(&dir);
assert!(matches!(check(&store), Report::NotARepo));
}
}