use std::path::{Component, Path};
use serde_json::Value;
use crate::model::{Edge, Graph, Metadata, Node};
use crate::sources::fs::{NodeKind, SourceFile};
pub fn build(root: &Path, files: &[SourceFile]) -> Graph {
let mut graph = Graph::labeled("fs");
for file in files {
let node_type = match file.kind {
NodeKind::Symlink => "symlink",
NodeKind::Dir => "directory",
NodeKind::File => "file",
};
let mut meta = Metadata::new();
meta.insert("type".into(), Value::String(node_type.into()));
graph.set_node(file.path.clone(), Node::new(meta));
if file.kind == NodeKind::Symlink
&& let Some(target) = symlink_target(root, &file.path)
{
graph.add_edge(Edge::new(file.path.clone(), target));
}
}
graph
}
fn symlink_target(root: &Path, path: &str) -> Option<String> {
let abs = root.join(path);
let link = std::fs::read_link(&abs).ok()?;
let resolved = if link.is_absolute() {
let canonical_root = root.canonicalize().ok()?;
link.canonicalize()
.ok()?
.strip_prefix(&canonical_root)
.ok()?
.to_string_lossy()
.replace('\\', "/")
} else {
crate::util::resolve_link(path, &link.to_string_lossy())
};
let escapes_root = Path::new(&resolved)
.components()
.next()
.is_some_and(|c| matches!(c, Component::ParentDir));
if escapes_root {
return None;
}
Some(resolved)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn source(path: &str, bytes: &str) -> SourceFile {
SourceFile {
path: path.into(),
kind: NodeKind::File,
bytes: Some(bytes.as_bytes().to_vec()),
}
}
#[test]
fn types_regular_files() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("a.md"), "a").unwrap();
let graph = build(dir.path(), &[source("a.md", "a")]);
assert_eq!(graph.label.as_deref(), Some("fs"));
let node = &graph.nodes["a.md"];
assert_eq!(node.metadata["type"], Value::String("file".into()));
assert!(node.metadata.get("hash").is_none());
assert!(graph.edges.is_empty());
}
#[test]
fn types_directories() {
let dir = TempDir::new().unwrap();
let entry = SourceFile {
path: "guides".into(),
kind: NodeKind::Dir,
bytes: None,
};
let graph = build(dir.path(), &[entry]);
let node = &graph.nodes["guides"];
assert_eq!(node.metadata["type"], Value::String("directory".into()));
assert!(node.metadata.get("hash").is_none());
assert!(graph.edges.is_empty());
}
#[cfg(unix)]
#[test]
fn symlink_within_root_emits_edge() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("real.md"), "r").unwrap();
std::os::unix::fs::symlink(dir.path().join("real.md"), dir.path().join("alias.md"))
.unwrap();
let alias = SourceFile {
kind: NodeKind::Symlink,
..source("alias.md", "r")
};
let files = vec![alias, source("real.md", "r")];
let graph = build(dir.path(), &files);
assert_eq!(
graph.nodes["alias.md"].metadata["type"],
Value::String("symlink".into())
);
assert_eq!(graph.edges.len(), 1);
assert_eq!(graph.edges[0].source, "alias.md");
assert_eq!(graph.edges[0].target, "real.md");
}
}