drft-cli 0.13.0

A structural integrity checker for linked file systems
Documentation
//! The lockfile: a snapshot of node content hashes and per-node outbound edge
//! target hashes, keyed by path. Lock is infrastructure, not a graph — the graph
//! carries current observations only; `drft check` joins this snapshot to derive
//! staleness.
//!
//! The on-disk form (`drft.lock`) is deterministic TOML with no timestamps and
//! no version field. A parse failure warns and points at `drft lock` rather than
//! failing the command.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;

use crate::model::{Graph, Node};

const LOCK_FILE: &str = "drft.lock";
const HEADER: &str = "# drft.lock — generated by `drft lock`. Do not edit by hand.\n\n";

/// The locked snapshot, keyed by path.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Lock {
    pub nodes: BTreeMap<String, LockedNode>,
}

/// A locked node: its content hash and the target hash of each outbound edge at
/// lock time.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct LockedNode {
    pub hash: Option<String>,
    /// Outbound edge target path → that target's content hash at lock time.
    pub edges: BTreeMap<String, Option<String>>,
}

impl Lock {
    /// Snapshot the composed graph: each node's `fs` hash, and for each outbound
    /// edge, the current hash of its target.
    pub fn from_composed(graph: &Graph) -> Self {
        let mut nodes: BTreeMap<String, LockedNode> = BTreeMap::new();

        for (path, node) in &graph.nodes {
            nodes.insert(
                path.clone(),
                LockedNode {
                    hash: node.fs_hash().map(str::to_string),
                    edges: BTreeMap::new(),
                },
            );
        }

        for edge in &graph.edges {
            let entry = nodes.entry(edge.source.clone()).or_default();
            entry.edges.insert(
                edge.target.clone(),
                graph
                    .nodes
                    .get(&edge.target)
                    .and_then(Node::fs_hash)
                    .map(str::to_string),
            );
        }

        // Lock only what can drift. A node with no hash and no outbound edges —
        // a directory, or an unreferenced escaping symlink — carries nothing to
        // check, so it stays out of the lockfile. A directory that *is* an edge
        // target still appears as a (null) target hash under the linking node.
        nodes.retain(|_, node| node.hash.is_some() || !node.edges.is_empty());

        Lock { nodes }
    }

    /// Serialize to deterministic TOML with a generated header comment.
    pub fn to_toml(&self) -> Result<String> {
        let doc = LockToml {
            node: self
                .nodes
                .iter()
                .map(|(path, node)| NodeToml {
                    path: path.clone(),
                    hash: node.hash.clone(),
                    edge: node
                        .edges
                        .iter()
                        .map(|(target, target_hash)| EdgeToml {
                            target: target.clone(),
                            target_hash: target_hash.clone(),
                        })
                        .collect(),
                })
                .collect(),
        };
        let body = toml::to_string_pretty(&doc).context("failed to serialize lockfile")?;
        Ok(format!("{HEADER}{body}"))
    }

    /// Deserialize from TOML (comments and the header are ignored).
    pub fn from_toml(content: &str) -> Result<Self> {
        let doc: LockToml = toml::from_str(content).context("failed to parse lockfile")?;
        let mut nodes = BTreeMap::new();
        for node in doc.node {
            let edges = node
                .edge
                .into_iter()
                .map(|e| (e.target, e.target_hash))
                .collect();
            nodes.insert(
                node.path,
                LockedNode {
                    hash: node.hash,
                    edges,
                },
            );
        }
        Ok(Lock { nodes })
    }
}

// `deny_unknown_fields` rejects foreign shapes (e.g. an older lockfile format)
// so they surface as a parse failure that points at `drft lock`, rather than
// silently deserializing into an empty lock.
#[derive(Debug, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct LockToml {
    #[serde(rename = "node", default)]
    node: Vec<NodeToml>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct NodeToml {
    path: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    hash: Option<String>,
    #[serde(rename = "edge", default, skip_serializing_if = "Vec::is_empty")]
    edge: Vec<EdgeToml>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct EdgeToml {
    target: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    target_hash: Option<String>,
}

/// Read `drft.lock` from `root`. Returns `Ok(None)` when the file is absent or
/// cannot be parsed (a parse failure warns and points at `drft lock`).
pub fn read(root: &Path) -> Result<Option<Lock>> {
    let path = root.join(LOCK_FILE);
    if !path.exists() {
        return Ok(None);
    }
    let content = std::fs::read_to_string(&path)
        .with_context(|| format!("failed to read {}", path.display()))?;
    match Lock::from_toml(&content) {
        Ok(lock) => Ok(Some(lock)),
        Err(e) => {
            eprintln!("warn: could not parse {LOCK_FILE} ({e}) — run `drft lock` to regenerate");
            Ok(None)
        }
    }
}

/// Write `drft.lock` atomically (temp file + rename).
pub fn write(root: &Path, lock: &Lock) -> Result<()> {
    let content = lock.to_toml()?;
    let lock_path = root.join(LOCK_FILE);
    let tmp_path = root.join("drft.lock.tmp");

    std::fs::write(&tmp_path, &content)
        .with_context(|| format!("failed to write {}", tmp_path.display()))?;
    std::fs::rename(&tmp_path, &lock_path).with_context(|| {
        let _ = std::fs::remove_file(&tmp_path);
        format!(
            "failed to rename {} to {}",
            tmp_path.display(),
            lock_path.display()
        )
    })?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::compose::compose;
    use crate::model::{Edge, GraphSet, Node};
    use serde_json::json;
    use tempfile::TempDir;

    fn fs_fragment(entries: &[(&str, &str)]) -> Graph {
        let mut g = Graph::labeled("fs");
        for (path, hash) in entries {
            g.set_node(
                *path,
                Node::new(
                    json!({ "type": "file", "hash": hash })
                        .as_object()
                        .unwrap()
                        .clone(),
                ),
            );
        }
        g
    }

    #[test]
    fn from_composed_captures_hashes_and_edges() {
        let mut fs = fs_fragment(&[("index.md", "b3:idx"), ("setup.md", "b3:setup")]);
        fs.add_edge(Edge::new("index.md", "setup.md"));
        let composed = compose(&GraphSet::new(vec![fs]));

        let lock = Lock::from_composed(&composed);
        assert_eq!(lock.nodes["index.md"].hash.as_deref(), Some("b3:idx"));
        assert_eq!(
            lock.nodes["index.md"].edges["setup.md"].as_deref(),
            Some("b3:setup")
        );
        assert!(lock.nodes["setup.md"].edges.is_empty());
    }

    #[test]
    fn directory_nodes_are_omitted_but_resolve_edges() {
        // A directory node carries no hash and no outbound edges. A file links to
        // it. The directory must not appear as its own lock entry, but the link
        // must still resolve — recorded as a null target hash under the file.
        let mut fs = fs_fragment(&[("index.md", "b3:idx")]);
        fs.set_node(
            "guides",
            Node::new(json!({ "type": "directory" }).as_object().unwrap().clone()),
        );
        fs.add_edge(Edge::new("index.md", "guides"));
        let lock = Lock::from_composed(&compose(&GraphSet::new(vec![fs])));

        assert!(
            !lock.nodes.contains_key("guides"),
            "hash-less, edge-less directory node must not be locked"
        );
        assert!(
            lock.nodes["index.md"].edges.contains_key("guides"),
            "the link to the directory must still be recorded"
        );
        assert_eq!(lock.nodes["index.md"].edges["guides"], None);
    }

    #[test]
    fn toml_round_trips() {
        let mut fs = fs_fragment(&[("a.md", "b3:a"), ("b.md", "b3:b")]);
        fs.add_edge(Edge::new("a.md", "b.md"));
        let lock = Lock::from_composed(&compose(&GraphSet::new(vec![fs])));

        let toml = lock.to_toml().unwrap();
        assert!(toml.contains("[[node]]"));
        assert!(toml.contains("[[node.edge]]"));
        assert!(!toml.contains("version"));
        let parsed = Lock::from_toml(&toml).unwrap();
        assert_eq!(lock, parsed);
    }

    #[test]
    fn deterministic_output() {
        let fs = fs_fragment(&[("z.md", "b3:z"), ("a.md", "b3:a")]);
        let lock = Lock::from_composed(&compose(&GraphSet::new(vec![fs])));
        assert_eq!(lock.to_toml().unwrap(), lock.to_toml().unwrap());
    }

    #[test]
    fn write_then_read() {
        let dir = TempDir::new().unwrap();
        let fs = fs_fragment(&[("a.md", "b3:a")]);
        let lock = Lock::from_composed(&compose(&GraphSet::new(vec![fs])));
        write(dir.path(), &lock).unwrap();
        assert_eq!(read(dir.path()).unwrap().unwrap(), lock);
    }

    #[test]
    fn read_missing_is_none() {
        let dir = TempDir::new().unwrap();
        assert!(read(dir.path()).unwrap().is_none());
    }

    #[test]
    fn unparseable_lock_warns_and_returns_none() {
        let dir = TempDir::new().unwrap();
        std::fs::write(dir.path().join(LOCK_FILE), "this is not valid toml {{{").unwrap();
        assert!(read(dir.path()).unwrap().is_none());
    }
}