nd / nd-core/src/sync.rs

raw file 4836 bytes
//! 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();
    }
}