Skip to main content

drft/graphs/
mod.rs

1//! Graph wiring: build each configured graph independently into its own
2//! bare-path namespace, producing the raw [`GraphSet`] (the substrate).
3//! Composition into a single graph is a separate projection (see
4//! [`crate::compose`]).
5//!
6//! This layer is also the **adoption seam** where drft auto-hashes: sources and
7//! builders never compute hashes; drft does, once per node, from the source
8//! bytes.
9
10use std::path::Path;
11
12use anyhow::Result;
13use serde_json::Value;
14
15use crate::builders;
16use crate::config::{Config, compile_globs};
17use crate::model::{Graph, GraphSet};
18use crate::sources::{self, fs::SourceFile};
19use crate::util::hash_bytes;
20
21/// drft's own lockfile is never graph content — hashing it would be circular
22/// (its bytes change every time it's written). The wiring layer always excludes
23/// it from the `fs` walk.
24const LOCKFILE_IGNORE: &str = "drft.lock";
25
26/// Build the raw set of per-graph fragments for the graph rooted at `root`.
27///
28/// `fs` is implicit and always builds first — it owns the identity space. Each
29/// configured text graph (`[graphs.*]`) then builds over the same fs walk's
30/// content, scoped by its filter and labeled with the graph's name.
31pub fn build_set(root: &Path, config: &Config) -> Result<GraphSet> {
32    let mut ignore = config.ignore_patterns().to_vec();
33    ignore.push(LOCKFILE_IGNORE.to_string());
34    let files = sources::fs::walk(root, &ignore)?;
35
36    let mut fs_graph = builders::fs::build(root, &files);
37    auto_hash(&mut fs_graph, &files);
38
39    let mut graphs = vec![fs_graph];
40
41    // Decode each file's bytes once for the text builders. Non-UTF-8 files are
42    // skipped (they have no text edges or metadata).
43    let texts: Vec<(String, String)> = files
44        .iter()
45        .filter_map(|f| {
46            f.bytes
47                .as_ref()
48                .and_then(|b| std::str::from_utf8(b).ok())
49                .map(|text| (f.path.clone(), text.to_string()))
50        })
51        .collect();
52
53    for (name, graph) in &config.graphs {
54        let files = compile_globs(&graph.files)?;
55        match graph.parser.as_str() {
56            "markdown" => graphs.push(builders::markdown::build(name, &texts, files)),
57            "frontmatter" => graphs.push(builders::frontmatter::build(
58                name,
59                &texts,
60                files,
61                graph.keys.clone(),
62            )),
63            // Parser names are validated at config load (`KNOWN_PARSERS`); an
64            // unknown parser cannot reach here.
65            other => unreachable!("unvalidated parser \"{other}\""),
66        }
67    }
68
69    Ok(GraphSet::new(graphs))
70}
71
72/// drft's job: hash each node's source bytes into its `hash` metadata. Applied
73/// to the `fs` graph, the one v0.8 graph whose nodes carry content.
74fn auto_hash(graph: &mut Graph, files: &[SourceFile]) {
75    for file in files {
76        if let Some(bytes) = &file.bytes
77            && let Some(node) = graph.nodes.get_mut(&file.path)
78        {
79            node.metadata
80                .insert("hash".into(), Value::String(hash_bytes(bytes)));
81        }
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use std::fs;
89    use tempfile::TempDir;
90
91    #[test]
92    fn fs_graph_has_typed_hashed_nodes() {
93        let dir = TempDir::new().unwrap();
94        fs::write(dir.path().join("drft.toml"), "").unwrap();
95        fs::write(dir.path().join("index.md"), "# Index").unwrap();
96        let config = Config::defaults();
97
98        let set = build_set(dir.path(), &config).unwrap();
99        // fs is always the base graph, built first regardless of config.
100        let fs_graph = &set.graphs[0];
101        assert_eq!(fs_graph.label.as_deref(), Some("fs"));
102
103        let node = &fs_graph.nodes["index.md"];
104        assert_eq!(node.metadata["type"], Value::String("file".into()));
105        assert!(
106            node.metadata["hash"].as_str().unwrap().starts_with("b3:"),
107            "node should be auto-hashed"
108        );
109    }
110
111    #[cfg(unix)]
112    #[test]
113    fn escaping_symlink_node_has_no_hash() {
114        let outer = TempDir::new().unwrap();
115        let root = outer.path().join("project");
116        fs::create_dir(&root).unwrap();
117        fs::write(root.join("drft.toml"), "").unwrap();
118        fs::write(outer.path().join("secret.md"), "secret").unwrap();
119        std::os::unix::fs::symlink(outer.path().join("secret.md"), root.join("trap.md")).unwrap();
120
121        let set = build_set(&root, &Config::defaults()).unwrap();
122        let trap = &set.graphs[0].nodes["trap.md"];
123        assert_eq!(trap.metadata["type"], Value::String("symlink".into()));
124        assert!(
125            trap.metadata.get("hash").is_none(),
126            "escaping symlink must not be hashed"
127        );
128    }
129
130    #[cfg(unix)]
131    #[test]
132    fn inroot_symlink_node_is_not_hashed() {
133        // A symlink is untrackable even when its target is in-graph: it carries no
134        // hash. Staleness reaches it through the edge to the (hashed) target.
135        let dir = TempDir::new().unwrap();
136        fs::write(dir.path().join("drft.toml"), "").unwrap();
137        fs::write(dir.path().join("real.md"), "content").unwrap();
138        std::os::unix::fs::symlink(dir.path().join("real.md"), dir.path().join("alias.md"))
139            .unwrap();
140
141        let set = build_set(dir.path(), &Config::defaults()).unwrap();
142        let nodes = &set.graphs[0].nodes;
143        assert_eq!(
144            nodes["alias.md"].metadata["type"],
145            Value::String("symlink".into())
146        );
147        assert!(
148            nodes["alias.md"].metadata.get("hash").is_none(),
149            "in-root symlink must not be hashed"
150        );
151        assert!(
152            nodes["real.md"].metadata["hash"]
153                .as_str()
154                .unwrap()
155                .starts_with("b3:"),
156            "the real target is still hashed"
157        );
158    }
159}