1use std::path::{Component, Path};
6
7use serde_json::Value;
8
9use crate::model::{Edge, Graph, Metadata, Node};
10use crate::sources::fs::{NodeKind, SourceFile};
11
12pub 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
40fn 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 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 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}