Skip to main content

drft/builders/
fs.rs

1//! The `fs` builder: the base graph. Emits a node per file, 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::SourceFile;
11
12/// Build the `fs` graph fragment from walked source files. Each file becomes a
13/// bare-path node carrying its `type`; each symlink also contributes an edge to
14/// its resolved target within the graph root.
15pub fn build(root: &Path, files: &[SourceFile]) -> Graph {
16    let mut graph = Graph::labeled("fs");
17
18    for file in files {
19        let node_type = if file.is_symlink { "symlink" } else { "file" };
20        let mut meta = Metadata::new();
21        meta.insert("type".into(), Value::String(node_type.into()));
22        graph.set_node(file.path.clone(), Node::new(meta));
23
24        if file.is_symlink
25            && let Some(target) = symlink_target(root, &file.path)
26        {
27            graph.add_edge(Edge::new(file.path.clone(), target));
28        }
29    }
30
31    graph
32}
33
34/// Resolve a symlink at `path` to a graph-relative target within `root`.
35/// Returns `None` for broken links or targets that escape the root.
36fn symlink_target(root: &Path, path: &str) -> Option<String> {
37    let abs = root.join(path);
38    let link = std::fs::read_link(&abs).ok()?;
39
40    let resolved = if link.is_absolute() {
41        let canonical_root = root.canonicalize().ok()?;
42        link.canonicalize()
43            .ok()?
44            .strip_prefix(&canonical_root)
45            .ok()?
46            .to_string_lossy()
47            .replace('\\', "/")
48    } else {
49        crate::util::resolve_link(path, &link.to_string_lossy())
50    };
51
52    let escapes_root = Path::new(&resolved)
53        .components()
54        .next()
55        .is_some_and(|c| matches!(c, Component::ParentDir));
56    if escapes_root {
57        return None;
58    }
59
60    Some(resolved)
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use std::fs;
67    use tempfile::TempDir;
68
69    fn source(path: &str, bytes: &str) -> SourceFile {
70        SourceFile {
71            path: path.into(),
72            is_symlink: false,
73            bytes: Some(bytes.as_bytes().to_vec()),
74        }
75    }
76
77    #[test]
78    fn types_regular_files() {
79        let dir = TempDir::new().unwrap();
80        fs::write(dir.path().join("a.md"), "a").unwrap();
81        let graph = build(dir.path(), &[source("a.md", "a")]);
82        assert_eq!(graph.label.as_deref(), Some("fs"));
83        let node = &graph.nodes["a.md"];
84        assert_eq!(node.metadata["type"], Value::String("file".into()));
85        // The builder does not hash.
86        assert!(node.metadata.get("hash").is_none());
87        assert!(graph.edges.is_empty());
88    }
89
90    #[cfg(unix)]
91    #[test]
92    fn symlink_within_root_emits_edge() {
93        let dir = TempDir::new().unwrap();
94        fs::write(dir.path().join("real.md"), "r").unwrap();
95        std::os::unix::fs::symlink(dir.path().join("real.md"), dir.path().join("alias.md"))
96            .unwrap();
97
98        let alias = SourceFile {
99            is_symlink: true,
100            ..source("alias.md", "r")
101        };
102        let files = vec![alias, source("real.md", "r")];
103        let graph = build(dir.path(), &files);
104        assert_eq!(
105            graph.nodes["alias.md"].metadata["type"],
106            Value::String("symlink".into())
107        );
108        assert_eq!(graph.edges.len(), 1);
109        assert_eq!(graph.edges[0].source, "alias.md");
110        assert_eq!(graph.edges[0].target, "real.md");
111    }
112}