//! File-hash sync baseline — lets `nd pull` do git-style three-way //! merging: changed-remotely files fast-forward, locally-changed files //! are never silently overwritten (they conflict instead). //! //! The baseline lives at `/.nd/base.json` and is refreshed by //! download/fork/pull/upload. `.nd/` is in the default ignore list, so //! it never ships in bundles. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; use std::path::Path; pub const BASELINE_FILE: &str = ".nd/base.json"; /// rel path ("src/main.rs") -> sha256 hex pub type Hashes = BTreeMap; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct Baseline { /// which hub ref this dir tracks ("owner8/slug" or "slug") pub nd_ref: String, /// file hashes at the moment of last download/pull/upload pub files: Hashes, } pub fn hex(data: &[u8]) -> String { let mut h = Sha256::new(); h.update(data); let out = h.finalize(); out.iter().map(|b| format!("{b:02x}")).collect() } pub fn hash_file(p: &Path) -> Result { let data = std::fs::read(p).with_context(|| format!("read {}", p.display()))?; Ok(hex(&data)) } pub fn load(dir: &Path) -> Baseline { let p = dir.join(BASELINE_FILE); std::fs::read_to_string(&p) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } pub fn save(dir: &Path, b: &Baseline) -> Result<()> { let dot = dir.join(".nd"); if !dot.exists() { std::fs::create_dir_all(&dot)?; } std::fs::write(dir.join(BASELINE_FILE), serde_json::to_string_pretty(b)?)?; Ok(()) } /// Hash exactly what would ship — same ignore rules as `nd pack`, symlinks /// never followed. Keeping the baseline in step with the bundle means a /// `build/` tree neither bloats the baseline nor (via its symlinks) hangs it. pub fn hash_dir(dir: &Path) -> Result { let plan = crate::pack::plan_dir(dir)?; let mut out = Hashes::new(); for e in &plan.included { if let Ok(h) = hash_file(&dir.join(&e.rel)) { out.insert(e.rel.clone(), h); } } Ok(out) } /// Refresh the baseline to mirror the current state of the dir. pub fn reset(dir: &Path, nd_ref: &str) -> Result { let b = Baseline { nd_ref: nd_ref.into(), files: hash_dir(dir)? }; save(dir, &b)?; Ok(b) } /// Stamp `nd_ref = "..."` into the manifest TEXTUALLY (comments in /// hand-written manifests survive), then reset the baseline to the /// current on-disk state. pub fn stamp(dir: &Path, nd_ref: &str) -> Result { let p = dir.join(crate::manifest::MANIFEST_FILE); let text = std::fs::read_to_string(&p).with_context(|| format!("read {}", p.display()))?; let out = apply_stamp(&text, nd_ref); if out != text { std::fs::write(&p, out)?; } reset(dir, nd_ref) } /// Insert or update the `nd_ref` line before the first table header /// (appended at the end when the manifest has no tables). fn apply_stamp(text: &str, nd_ref: &str) -> String { let line = format!("nd_ref = \"{}\"", nd_ref.replace('"', "\\\"")); let mut replaced = false; let mut out = String::new(); for l in text.lines() { let t = l.trim_start(); if !replaced && !t.starts_with('#') && t.starts_with('[') { out.push_str(&line); out.push('\n'); replaced = true; } if !t.starts_with('#') && t.starts_with("nd_ref") && t.contains('=') { out.push_str(&line); out.push('\n'); replaced = true; continue; } out.push_str(l); out.push('\n'); } if !replaced { out.push_str(&line); out.push('\n'); } out } #[cfg(test)] mod tests { use super::*; use std::fs; #[test] fn stamp_is_comment_safe() { let base = std::env::temp_dir().join(format!("nd-sync2-{}", std::process::id())); let proj = base.join("proj"); fs::create_dir_all(&proj).ok(); fs::create_dir_all(proj.join("src")).ok(); fs::write( proj.join("notdouble.toml"), "# hello comment\nname = \"x\"\n\ndescription = \"d\"\n\n[vibe]\nmodel = \"m\"\n", ) .unwrap(); fs::write(proj.join("src/main.rs"), "fn main(){}").unwrap(); stamp(&proj, "me/pkg").unwrap(); let text = fs::read_to_string(proj.join("notdouble.toml")).unwrap(); assert!(text.contains("# hello comment")); assert!(text.contains("nd_ref = \"me/pkg\"")); // stamped above the [vibe] table, not inside it assert!(text.split("[vibe]").next().unwrap().contains("nd_ref")); fs::remove_dir_all(&base).ok(); } }