use anyhow::Result;
use globset::GlobSet;
use ignore::WalkBuilder;
use std::path::Path;
use crate::config::compile_globs;
const VCS_DIRS: [&str; 4] = [".git", ".hg", ".svn", ".jj"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeKind {
File,
Symlink,
Dir,
}
pub struct SourceFile {
pub path: String,
pub kind: NodeKind,
pub bytes: Option<Vec<u8>>,
}
pub fn walk(root: &Path, ignore: &[String]) -> Result<Vec<SourceFile>> {
let ignore_set = compile_globs(ignore)?;
let mut files = Vec::new();
let walker = WalkBuilder::new(root)
.follow_links(false)
.hidden(false)
.parents(false)
.git_global(false)
.git_exclude(false)
.filter_entry(|entry| {
entry
.file_name()
.to_str()
.is_none_or(|name| !VCS_DIRS.contains(&name))
})
.build();
for entry in walker {
let entry = entry?;
let ft = entry.file_type();
if !ft.is_some_and(|t| t.is_file() || t.is_dir() || t.is_symlink()) {
continue;
}
let relative = entry
.path()
.strip_prefix(root)
.expect("path should be under root")
.to_string_lossy()
.replace('\\', "/");
if relative.is_empty() {
continue;
}
let kind = match abs_lstat(root, &relative) {
Some(m) if m.file_type().is_symlink() => NodeKind::Symlink,
Some(m) if m.is_dir() => NodeKind::Dir,
_ => NodeKind::File,
};
if is_ignored(&ignore_set, &relative, kind) {
continue;
}
let bytes = match kind {
NodeKind::File => std::fs::read(root.join(&relative)).ok(),
NodeKind::Symlink | NodeKind::Dir => None,
};
files.push(SourceFile {
path: relative,
kind,
bytes,
});
}
files.sort_by(|a, b| a.path.cmp(&b.path));
Ok(files)
}
fn abs_lstat(root: &Path, relative: &str) -> Option<std::fs::Metadata> {
root.join(relative).symlink_metadata().ok()
}
fn is_ignored(ignore_set: &Option<GlobSet>, relative: &str, kind: NodeKind) -> bool {
let Some(set) = ignore_set else {
return false;
};
set.is_match(relative) || (kind == NodeKind::Dir && set.is_match(format!("{relative}/")))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn walks_all_files_sorted() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("b.md"), "b").unwrap();
fs::write(dir.path().join("a.md"), "a").unwrap();
fs::write(dir.path().join("notes.txt"), "n").unwrap();
let files = walk(dir.path(), &[]).unwrap();
let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
assert_eq!(paths, vec!["a.md", "b.md", "notes.txt"]);
assert_eq!(files[0].bytes.as_deref(), Some(&b"a"[..]));
}
#[test]
fn respects_ignore_globs() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("keep.md"), "k").unwrap();
let sub = dir.path().join("target");
fs::create_dir(&sub).unwrap();
fs::write(sub.join("build.md"), "b").unwrap();
let files = walk(dir.path(), &["target/**".to_string()]).unwrap();
let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
assert_eq!(paths, vec!["keep.md"]);
}
#[test]
fn yields_directory_nodes() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("guides")).unwrap();
fs::write(dir.path().join("guides/intro.md"), "i").unwrap();
let files = walk(dir.path(), &[]).unwrap();
let guides = files.iter().find(|f| f.path == "guides").unwrap();
assert_eq!(guides.kind, NodeKind::Dir);
assert!(guides.bytes.is_none(), "directories carry no content");
let intro = files.iter().find(|f| f.path == "guides/intro.md").unwrap();
assert_eq!(intro.kind, NodeKind::File);
}
#[test]
fn includes_empty_directories() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("empty")).unwrap();
let files = walk(dir.path(), &[]).unwrap();
assert!(
files
.iter()
.any(|f| f.path == "empty" && f.kind == NodeKind::Dir)
);
}
#[test]
fn root_is_not_a_node() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("a.md"), "a").unwrap();
let files = walk(dir.path(), &[]).unwrap();
assert!(
!files.iter().any(|f| f.path.is_empty()),
"the graph root must not appear as a node"
);
}
#[test]
fn respects_gitignore() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".gitignore"), "vendor/\n").unwrap();
fs::write(dir.path().join("index.md"), "i").unwrap();
let vendor = dir.path().join("vendor");
fs::create_dir(&vendor).unwrap();
fs::write(vendor.join("lib.md"), "v").unwrap();
let files = walk(dir.path(), &[]).unwrap();
let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
assert!(paths.contains(&"index.md"));
assert!(!paths.iter().any(|p| p.contains("vendor")));
}
#[test]
fn ignore_files_above_root_do_not_prune_the_walk() {
let outer = TempDir::new().unwrap();
fs::create_dir(outer.path().join(".git")).unwrap();
fs::write(outer.path().join(".gitignore"), "keep.md\n").unwrap();
let root = outer.path().join("project");
fs::create_dir(&root).unwrap();
fs::write(root.join("keep.md"), "k").unwrap();
let files = walk(&root, &[]).unwrap();
assert!(
files.iter().any(|f| f.path == "keep.md"),
"an ignore rule above the root must not prune the walk"
);
}
#[test]
fn dot_dirs_are_walked_but_vcs_dirs_are_pruned() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".git").join("HEAD"), "ref: x").unwrap();
fs::create_dir(dir.path().join(".github")).unwrap();
fs::write(dir.path().join(".github").join("ci.yml"), "y").unwrap();
let files = walk(dir.path(), &[]).unwrap();
let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
assert!(
!paths.iter().any(|p| p.starts_with(".git/") || *p == ".git"),
"VCS metadata must be pruned, got: {paths:?}"
);
assert!(
paths.contains(&".github") && paths.contains(&".github/ci.yml"),
"ordinary dot-directories must be walked, got: {paths:?}"
);
}
#[cfg(unix)]
#[test]
fn symlink_escaping_root_has_no_bytes() {
let outer = TempDir::new().unwrap();
let root = outer.path().join("project");
fs::create_dir(&root).unwrap();
fs::write(root.join("index.md"), "i").unwrap();
fs::write(outer.path().join("secret.md"), "secret").unwrap();
std::os::unix::fs::symlink(outer.path().join("secret.md"), root.join("trap.md")).unwrap();
let files = walk(&root, &[]).unwrap();
let trap = files.iter().find(|f| f.path == "trap.md").unwrap();
assert!(
trap.bytes.is_none(),
"escaping symlink should carry no bytes"
);
}
#[cfg(unix)]
#[test]
fn symlink_to_directory_is_typed_symlink() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("real")).unwrap();
std::os::unix::fs::symlink(dir.path().join("real"), dir.path().join("alias")).unwrap();
let files = walk(dir.path(), &[]).unwrap();
let alias = files.iter().find(|f| f.path == "alias").unwrap();
assert_eq!(alias.kind, NodeKind::Symlink);
let real = files.iter().find(|f| f.path == "real").unwrap();
assert_eq!(real.kind, NodeKind::Dir);
}
#[cfg(unix)]
#[test]
fn symlink_to_file_carries_no_bytes() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("real.md"), "content").unwrap();
std::os::unix::fs::symlink(dir.path().join("real.md"), dir.path().join("alias.md"))
.unwrap();
let files = walk(dir.path(), &[]).unwrap();
let alias = files.iter().find(|f| f.path == "alias.md").unwrap();
assert_eq!(alias.kind, NodeKind::Symlink);
assert!(alias.bytes.is_none(), "symlink node must carry no bytes");
let real = files.iter().find(|f| f.path == "real.md").unwrap();
assert_eq!(real.bytes.as_deref(), Some(&b"content"[..]));
}
#[cfg(unix)]
#[test]
fn does_not_descend_into_symlinked_directory() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("real")).unwrap();
fs::write(dir.path().join("real/child.md"), "c").unwrap();
std::os::unix::fs::symlink(dir.path().join("real"), dir.path().join("alias")).unwrap();
let files = walk(dir.path(), &[]).unwrap();
let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
assert!(paths.contains(&"alias"), "the symlink itself is a node");
assert!(
paths.contains(&"real/child.md"),
"real content appears once"
);
assert!(
!paths.contains(&"alias/child.md"),
"must not re-walk the target subtree through the symlink, got: {paths:?}"
);
}
#[cfg(unix)]
#[test]
fn does_not_leak_content_through_escaping_directory_symlink() {
let outer = TempDir::new().unwrap();
let root = outer.path().join("project");
fs::create_dir(&root).unwrap();
fs::create_dir(outer.path().join("secrets")).unwrap();
fs::write(outer.path().join("secrets/passwd.md"), "TOP SECRET").unwrap();
std::os::unix::fs::symlink(outer.path().join("secrets"), root.join("alias")).unwrap();
let files = walk(&root, &[]).unwrap();
assert!(
!files.iter().any(|f| f.path.contains("passwd")),
"outside content must not be walked through a symlink"
);
}
}