//! 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 `<project>/.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<String, String>;
#[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<String> {
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<Hashes> {
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<Baseline> {
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<Baseline> {
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();
}
}