Skip to main content

drft/builders/
fs.rs

1//! The `fs` builder: the base graph. Emits a node per entry, typed by stat, plus
2//! a symlink edge per symlink. It does not hash — drft auto-hashes at the wiring
3//! seam (see [`crate::graphs`]).
4
5use std::path::{Component, Path};
6
7use serde_json::Value;
8
9use crate::model::{Edge, Graph, Metadata, Node};
10use crate::sources::fs::{NodeKind, SourceFile};
11
12/// Build the `fs` graph fragment from walked source entries. Each entry becomes
13/// a bare-path node carrying its `type` (`file`, `symlink`, or `directory`);
14/// each symlink also contributes an edge to its resolved target within the graph
15/// root. Directory nodes carry no edge — containment is implicit in path
16/// structure.
17pub fn build(root: &Path, files: &[SourceFile]) -> Graph {
18    let mut graph = Graph::labeled("fs");
19
20    for file in files {
21        let node_type = match file.kind {
22            NodeKind::Symlink => "symlink",
23            NodeKind::Dir => "directory",
24            NodeKind::File => "file",
25        };
26        let mut meta = Metadata::new();
27        meta.insert("type".into(), Value::String(node_type.into()));
28        graph.set_node(file.path.clone(), Node::new(meta));
29
30        if file.kind == NodeKind::Symlink
31            && let Some(target) = symlink_target(root, &file.path)
32        {
33            graph.add_edge(Edge::new(file.path.clone(), target));
34        }
35    }
36
37    graph
38}
39
40/// Resolve a symlink at `path` to a graph-relative target within `root`.
41/// Returns `None` for broken links or targets that escape the root.
42fn symlink_target(root: &Path, path: &str) -> Option<String> {
43    let abs = root.join(path);
44    let link = std::fs::read_link(&abs).ok()?;
45
46    let resolved = if link.is_absolute() {
47        let canonical_root = root.canonicalize().ok()?;
48        link.canonicalize()
49            .ok()?
50            .strip_prefix(&canonical_root)
51            .ok()?
52            .to_string_lossy()
53            .replace('\\', "/")
54    } else {
55        crate::util::resolve_link(path, &link.to_string_lossy())
56    };
57
58    let escapes_root = Path::new(&resolved)
59        .components()
60        .next()
61        .is_some_and(|c| matches!(c, Component::ParentDir));
62    if escapes_root {
63        return None;
64    }
65
66    Some(resolved)
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use std::fs;
73    use tempfile::TempDir;
74
75    fn source(path: &str, bytes: &str) -> SourceFile {
76        SourceFile {
77            path: path.into(),
78            kind: NodeKind::File,
79            bytes: Some(bytes.as_bytes().to_vec()),
80        }
81    }
82
83    #[test]
84    fn types_regular_files() {
85        let dir = TempDir::new().unwrap();
86        fs::write(dir.path().join("a.md"), "a").unwrap();
87        let graph = build(dir.path(), &[source("a.md", "a")]);
88        assert_eq!(graph.label.as_deref(), Some("fs"));
89        let node = &graph.nodes["a.md"];
90        assert_eq!(node.metadata["type"], Value::String("file".into()));
91        // The builder does not hash.
92        assert!(node.metadata.get("hash").is_none());
93        assert!(graph.edges.is_empty());
94    }
95
96    #[test]
97    fn types_directories() {
98        let dir = TempDir::new().unwrap();
99        let entry = SourceFile {
100            path: "guides".into(),
101            kind: NodeKind::Dir,
102            bytes: None,
103        };
104        let graph = build(dir.path(), &[entry]);
105        let node = &graph.nodes["guides"];
106        assert_eq!(node.metadata["type"], Value::String("directory".into()));
107        // Directories are never hashed and contribute no edge.
108        assert!(node.metadata.get("hash").is_none());
109        assert!(graph.edges.is_empty());
110    }
111
112    #[cfg(unix)]
113    #[test]
114    fn symlink_within_root_emits_edge() {
115        let dir = TempDir::new().unwrap();
116        fs::write(dir.path().join("real.md"), "r").unwrap();
117        std::os::unix::fs::symlink(dir.path().join("real.md"), dir.path().join("alias.md"))
118            .unwrap();
119
120        let alias = SourceFile {
121            kind: NodeKind::Symlink,
122            ..source("alias.md", "r")
123        };
124        let files = vec![alias, source("real.md", "r")];
125        let graph = build(dir.path(), &files);
126        assert_eq!(
127            graph.nodes["alias.md"].metadata["type"],
128            Value::String("symlink".into())
129        );
130        assert_eq!(graph.edges.len(), 1);
131        assert_eq!(graph.edges[0].source, "alias.md");
132        assert_eq!(graph.edges[0].target, "real.md");
133    }
134}