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