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(name, &texts, files)),
58            // Parser names are validated at config load (`KNOWN_PARSERS`); an
59            // unknown parser cannot reach here.
60            other => unreachable!("unvalidated parser \"{other}\""),
61        }
62    }
63
64    Ok(GraphSet::new(graphs))
65}
66
67/// drft's job: hash each node's source bytes into its `hash` metadata. Applied
68/// to the `fs` graph, the one v0.8 graph whose nodes carry content.
69fn auto_hash(graph: &mut Graph, files: &[SourceFile]) {
70    for file in files {
71        if let Some(bytes) = &file.bytes
72            && let Some(node) = graph.nodes.get_mut(&file.path)
73        {
74            node.metadata
75                .insert("hash".into(), Value::String(hash_bytes(bytes)));
76        }
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use std::fs;
84    use tempfile::TempDir;
85
86    #[test]
87    fn fs_graph_has_typed_hashed_nodes() {
88        let dir = TempDir::new().unwrap();
89        fs::write(dir.path().join("drft.toml"), "").unwrap();
90        fs::write(dir.path().join("index.md"), "# Index").unwrap();
91        let config = Config::defaults();
92
93        let set = build_set(dir.path(), &config).unwrap();
94        // fs is always the base graph, built first regardless of config.
95        let fs_graph = &set.graphs[0];
96        assert_eq!(fs_graph.label.as_deref(), Some("fs"));
97
98        let node = &fs_graph.nodes["index.md"];
99        assert_eq!(node.metadata["type"], Value::String("file".into()));
100        assert!(
101            node.metadata["hash"].as_str().unwrap().starts_with("b3:"),
102            "node should be auto-hashed"
103        );
104    }
105
106    #[cfg(unix)]
107    #[test]
108    fn escaping_symlink_node_has_no_hash() {
109        let outer = TempDir::new().unwrap();
110        let root = outer.path().join("project");
111        fs::create_dir(&root).unwrap();
112        fs::write(root.join("drft.toml"), "").unwrap();
113        fs::write(outer.path().join("secret.md"), "secret").unwrap();
114        std::os::unix::fs::symlink(outer.path().join("secret.md"), root.join("trap.md")).unwrap();
115
116        let set = build_set(&root, &Config::defaults()).unwrap();
117        let trap = &set.graphs[0].nodes["trap.md"];
118        assert_eq!(trap.metadata["type"], Value::String("symlink".into()));
119        assert!(
120            trap.metadata.get("hash").is_none(),
121            "escaping symlink must not be hashed"
122        );
123    }
124}