use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use brink_db::SourceTree;
use brink_source_tree::Walk;
const NATIVE_EXTENSION: &str = "brink";
#[derive(Debug, Clone)]
pub struct RealFs {
root: PathBuf,
}
impl RealFs {
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
}
impl SourceTree for RealFs {
fn list(&self) -> io::Result<Vec<String>> {
let mut keys = Vec::new();
let mut walk = Walk::new(&self.root)
.allow(unprune_dirs(&self.root))
.warn_on_pruned_sources([NATIVE_EXTENSION]);
for entry in walk.by_ref() {
let entry = entry?;
if !entry.is_file() || !is_native(entry.path()) {
continue;
}
let rel = entry
.path()
.strip_prefix(&self.root)
.map_err(|e| io::Error::other(e.to_string()))?;
keys.push(to_key(rel));
}
keys.sort();
for pruned in walk.pruned_with_sources() {
let name = pruned.file_name().unwrap_or_default().to_string_lossy();
tracing::warn!(
"discovery pruned {} — it contains .{NATIVE_EXTENSION} file(s) that were not \
loaded. If this is intentional source, add `unprune-dirs = [\"{name}\"]` under \
`[project]` in {CONFIG_FILE_NAME}.",
pruned.display(),
);
}
Ok(keys)
}
fn read(&self, key: &str) -> io::Result<String> {
fs::read_to_string(self.root.join(key))
}
}
const CONFIG_FILE_NAME: &str = brink_project_config::CONFIG_FILE_NAME;
fn unprune_dirs(root: &Path) -> Vec<OsString> {
let Ok(text) = fs::read_to_string(root.join(CONFIG_FILE_NAME)) else {
return Vec::new();
};
let Ok((config, _warnings)) = brink_project_config::parse_str_at(CONFIG_FILE_NAME, &text)
else {
return Vec::new();
};
config.unprune_dirs.into_iter().map(Into::into).collect()
}
fn to_key(rel: &Path) -> String {
rel.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
#[must_use]
pub fn is_native(path: &Path) -> bool {
path.extension().is_some_and(|ext| ext == NATIVE_EXTENSION)
}
#[must_use]
pub fn native_source_root(entry: &Path) -> PathBuf {
native_source_root_inner(entry, false).0
}
#[must_use]
pub fn native_source_root_with_warnings(
entry: &Path,
) -> (PathBuf, Vec<brink_project_config::ConfigWarning>) {
native_source_root_inner(entry, true)
}
fn native_source_root_inner(
entry: &Path,
want_warnings: bool,
) -> (PathBuf, Vec<brink_project_config::ConfigWarning>) {
let entry_dir = entry
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let (root, mut warnings) = source_root_from_config(entry_dir, want_warnings);
if let Some(root) = root {
return (root, warnings);
}
let entry_dir_abs = std::path::absolute(entry_dir).unwrap_or_else(|_| entry_dir.to_path_buf());
if entry_dir_abs != entry_dir {
let (root, abs_warnings) = source_root_from_config(&entry_dir_abs, want_warnings);
warnings.extend(abs_warnings);
if let Some(root) = root {
return (root, warnings);
}
}
(entry_dir.to_path_buf(), warnings)
}
fn source_root_from_config(
entry_dir: &Path,
want_warnings: bool,
) -> (Option<PathBuf>, Vec<brink_project_config::ConfigWarning>) {
let (config_path, warnings) = if want_warnings {
brink_project_config::find_config_with_warnings(entry_dir)
} else {
(brink_project_config::find_config(entry_dir), Vec::new())
};
let root = config_path.map(|path| {
let parent = path.parent().unwrap_or_else(|| Path::new(""));
if parent.as_os_str().is_empty() {
PathBuf::from(".")
} else {
parent.to_path_buf()
}
});
(root, warnings)
}
#[must_use]
pub fn relative_key(root: &Path, path: &Path) -> String {
let root_abs = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf());
let path_abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
let rel = path_abs.strip_prefix(&root_abs).unwrap_or(&path_abs);
to_key(rel)
}
#[derive(Debug, Clone)]
pub struct GitRev {
repo_dir: PathBuf,
rev: String,
root: PathBuf,
}
impl GitRev {
#[must_use]
pub fn new(
repo_dir: impl Into<PathBuf>,
rev: impl Into<String>,
root: impl Into<PathBuf>,
) -> Self {
Self {
repo_dir: repo_dir.into(),
rev: rev.into(),
root: root.into(),
}
}
fn repo_relative(&self, key: &str) -> String {
if self.root == Path::new(".") {
key.to_string()
} else {
format!("{}/{key}", to_key(&self.root))
}
}
}
impl SourceTree for GitRev {
fn list(&self) -> io::Result<Vec<String>> {
let pathspec = to_key(&self.root);
let output = Command::new("git")
.current_dir(&self.repo_dir)
.args([
"ls-tree",
"-r",
"--name-only",
"--full-name",
&self.rev,
"--",
])
.arg(&pathspec)
.output()?;
if !output.status.success() {
return Err(io::Error::other(format!(
"git ls-tree {} -- {pathspec} failed: {}",
self.rev,
String::from_utf8_lossy(&output.stderr)
)));
}
let text = String::from_utf8(output.stdout)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
let prefix = if pathspec == "." {
String::new()
} else {
format!("{pathspec}/")
};
let mut keys: Vec<String> = text
.lines()
.filter(|line| line.ends_with(&format!(".{NATIVE_EXTENSION}")))
.map(|line| line.strip_prefix(&prefix).unwrap_or(line).to_string())
.collect();
keys.sort();
Ok(keys)
}
fn read(&self, key: &str) -> io::Result<String> {
let spec = format!("{}:{}", self.rev, self.repo_relative(key));
let output = Command::new("git")
.current_dir(&self.repo_dir)
.args(["show", &spec])
.output()?;
if output.status.success() {
String::from_utf8(output.stdout)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
} else {
Err(io::Error::new(
io::ErrorKind::NotFound,
format!("{key} not in {}", self.rev),
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command as StdCommand;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
let dir = std::env::temp_dir().join(format!(
"brink-source-tree-test-{label}-{}-{n}-{nanos}",
std::process::id()
));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn real_fs_list_enumerates_only_brink_files_in_sorted_order() {
let root = temp_dir("realfs-list");
fs::write(root.join("z.brink"), "-- z --").expect("write z.brink");
fs::write(root.join("z.ink"), "not brink").expect("write z.ink");
fs::write(root.join("README.md"), "not brink").expect("write README.md");
fs::create_dir_all(root.join("nested")).expect("mkdir nested");
fs::write(root.join("nested/a.brink"), "-- nested/a --").expect("write nested/a.brink");
fs::write(root.join("a.brink"), "-- a --").expect("write a.brink");
let tree = RealFs::new(&root);
let keys = tree.list().expect("list succeeds");
assert_eq!(keys, vec!["a.brink", "nested/a.brink", "z.brink"]);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn real_fs_read_round_trips() {
let root = temp_dir("realfs-read");
fs::write(root.join("main.brink"), "flow main() {}").expect("write main.brink");
let tree = RealFs::new(&root);
let keys = tree.list().expect("list succeeds");
assert_eq!(keys, vec!["main.brink"]);
let source = tree.read(&keys[0]).expect("read succeeds");
assert_eq!(source, "flow main() {}");
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn real_fs_list_empty_dir_is_ok_empty() {
let root = temp_dir("realfs-empty");
let tree = RealFs::new(&root);
let keys = tree.list().expect("list succeeds");
assert_eq!(keys, Vec::<String>::new());
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn real_fs_list_skips_ignored_dirs() {
let root = temp_dir("realfs-ignored-dirs");
fs::write(root.join("main.brink"), "flow main() {}").expect("write main.brink");
fs::create_dir_all(root.join("target/debug")).expect("mkdir target/debug");
fs::write(root.join("target/stray.brink"), "-- stray --").expect("write target/stray");
fs::write(root.join("target/debug/build.brink"), "-- build --")
.expect("write target/debug/build");
fs::create_dir_all(root.join(".git/objects")).expect("mkdir .git/objects");
fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write .git/HEAD");
fs::write(root.join(".git/objects/pack.brink"), "-- pack --")
.expect("write .git/objects/pack.brink");
fs::create_dir_all(root.join("node_modules/some-pkg")).expect("mkdir node_modules");
fs::write(root.join("node_modules/some-pkg/index.brink"), "-- pkg --")
.expect("write node_modules/some-pkg/index.brink");
let tree = RealFs::new(&root);
let keys = tree.list().expect("list succeeds");
assert_eq!(
keys,
vec!["main.brink"],
"target/, .git/, and node_modules/ must be pruned entirely"
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn real_fs_list_unprune_dirs_admits_a_named_ignored_dir_only() {
let root = temp_dir("realfs-unprune-dirs");
fs::write(
root.join("brink.toml"),
"[project]\nunprune-dirs = [\"node_modules\"]\n",
)
.expect("write brink.toml");
fs::write(root.join("main.brink"), "flow main() {}").expect("write main.brink");
fs::create_dir_all(root.join("node_modules/vendor-ink")).expect("mkdir node_modules");
fs::write(
root.join("node_modules/vendor-ink/lib.brink"),
"flow lib() {}",
)
.expect("write node_modules/vendor-ink/lib.brink");
fs::create_dir_all(root.join("target")).expect("mkdir target");
fs::write(root.join("target/stray.brink"), "-- stray --")
.expect("write target/stray.brink");
let tree = RealFs::new(&root);
let keys = tree.list().expect("list succeeds");
assert_eq!(
keys,
vec!["main.brink", "node_modules/vendor-ink/lib.brink"],
"unprune-dirs must admit node_modules/ specifically, target/ must stay pruned"
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn real_fs_list_with_no_brink_toml_still_prunes_normally() {
let root = temp_dir("realfs-unprune-dirs-no-config");
fs::write(root.join("main.brink"), "flow main() {}").expect("write main.brink");
fs::create_dir_all(root.join("node_modules")).expect("mkdir node_modules");
fs::write(root.join("node_modules/stray.brink"), "-- stray --")
.expect("write node_modules/stray.brink");
let tree = RealFs::new(&root);
let keys = tree.list().expect("list succeeds");
assert_eq!(keys, vec!["main.brink"]);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn real_fs_list_tolerates_a_malformed_brink_toml() {
let root = temp_dir("realfs-unprune-dirs-malformed-config");
fs::write(
root.join("brink.toml"),
"[project]\ndialect = \"sideways\"\n",
)
.expect("write malformed brink.toml");
fs::write(root.join("main.brink"), "flow main() {}").expect("write main.brink");
fs::create_dir_all(root.join("node_modules")).expect("mkdir node_modules");
fs::write(root.join("node_modules/stray.brink"), "-- stray --")
.expect("write node_modules/stray.brink");
let tree = RealFs::new(&root);
let keys = tree
.list()
.expect("list must succeed despite a malformed brink.toml");
assert_eq!(
keys,
vec!["main.brink"],
"no unprune-dirs could be read from the malformed file, so node_modules/ \
stays pruned, same as no config at all"
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn real_fs_native_list_still_excludes_ink_and_config() {
let root = temp_dir("realfs-native-scope");
fs::write(root.join("a.brink"), "-- a --").expect("write a.brink");
fs::write(root.join("main.ink"), "-> END\n").expect("write main.ink");
fs::write(root.join("brink.toml"), "[project]\n").expect("write brink.toml");
let tree = RealFs::new(&root);
let keys = tree.list().expect("list succeeds");
assert_eq!(keys, vec!["a.brink"]);
assert_eq!(
tree.read("brink.toml").expect("read is not list-scoped"),
"[project]\n"
);
assert_eq!(
tree.read("main.ink").expect("read is not list-scoped"),
"-> END\n"
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn real_fs_list_uses_only_its_constructor_root() {
let root = temp_dir("realfs-list-constructor-root");
fs::write(root.join("main.brink"), "flow main() {}").expect("write main.brink");
let tree = RealFs::new(&root);
let keys = tree.list().expect("list succeeds");
assert_eq!(keys, vec!["main.brink"]);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn real_fs_read_is_lazy_so_an_unrelated_malformed_file_does_not_fail_other_reads() {
let root = temp_dir("realfs-lazy-read");
fs::write(root.join("good.brink"), "flow main() {}").expect("write good.brink");
fs::write(root.join("bad.brink"), [0xFF, 0xFE, 0xFD]).expect("write bad.brink");
let tree = RealFs::new(&root);
let keys = tree.list().expect("list succeeds without reading contents");
assert_eq!(keys, vec!["bad.brink", "good.brink"]);
let source = tree
.read("good.brink")
.expect("reading an unrelated, well-formed key must not be affected");
assert_eq!(source, "flow main() {}");
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn real_fs_read_resolves_a_key_that_escapes_the_root() {
let wrapper = temp_dir("realfs-escape-root");
let root = wrapper.join("proj");
fs::create_dir_all(&root).expect("mkdir proj");
fs::write(wrapper.join("shared.ink"), "Shared content.\n").expect("write shared.ink");
let tree = RealFs::new(&root);
let source = tree
.read("../shared.ink")
.expect("read resolves an above-root key relative to the constructed root");
assert_eq!(source, "Shared content.\n");
fs::remove_dir_all(&wrapper).expect("cleanup temp dir");
}
#[cfg(unix)]
#[test]
fn real_fs_read_and_find_config_in_tree_follow_a_symlinked_brink_toml() {
use std::os::unix::fs::symlink;
let root = temp_dir("realfs-symlink-config");
fs::write(
root.join("real-brink.toml"),
"[project]\ndialect = \"brink\"\n",
)
.expect("write real-brink.toml");
symlink(root.join("real-brink.toml"), root.join("brink.toml"))
.expect("symlink brink.toml -> real-brink.toml");
fs::write(root.join("main.brink"), "flow main() {}").expect("write main.brink");
let tree = RealFs::new(&root);
assert_eq!(
tree.read("brink.toml").expect("read follows the symlink"),
"[project]\ndialect = \"brink\"\n"
);
let found = brink_project_config::discover_from_entry_in_tree(&tree, "main.brink")
.expect("probe read succeeds")
.expect("the symlinked brink.toml is discovered");
assert_eq!(found, "brink.toml");
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn real_fs_read_and_find_config_in_tree_report_a_directory_shaped_brink_toml_as_found() {
let root = temp_dir("realfs-dir-config");
fs::create_dir_all(root.join("brink.toml")).expect("mkdir brink.toml (directory)");
fs::write(root.join("main.brink"), "flow main() {}").expect("write main.brink");
let tree = RealFs::new(&root);
tree.read("brink.toml")
.expect_err("reading a directory as a config file must error, not panic");
let found = brink_project_config::discover_from_entry_in_tree(&tree, "main.brink")
.expect("the directory's read error is not propagated as an Err")
.expect("a directory-shaped brink.toml is still reported as found");
assert_eq!(found, "brink.toml");
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
fn git_repo_with_commit(label: &str, files: &[(&str, &str)]) -> (PathBuf, String) {
let dir = temp_dir(label);
let git = |args: &[&str]| {
let output = StdCommand::new("git")
.current_dir(&dir)
.args(args)
.output()
.expect("spawn git");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
};
git(&["init", "--quiet"]);
git(&["config", "user.email", "sourcetree-test@example.invalid"]);
git(&["config", "user.name", "SourceTree Test"]);
for (path, content) in files {
let full = dir.join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).expect("mkdir parent");
}
fs::write(&full, content).expect("write fixture file");
}
git(&["add", "."]);
git(&["commit", "--quiet", "-m", "sourcetree test fixture"]);
let output = StdCommand::new("git")
.current_dir(&dir)
.args(["rev-parse", "HEAD"])
.output()
.expect("spawn git rev-parse");
assert!(output.status.success(), "git rev-parse HEAD failed");
let sha = String::from_utf8(output.stdout)
.expect("HEAD sha is utf8")
.trim()
.to_string();
(dir, sha)
}
#[test]
fn git_rev_list_and_read_round_trip_a_real_commit() {
let (repo_dir, sha) = git_repo_with_commit(
"gitrev",
&[
("z.brink", "-- z --"),
("a.brink", "-- a --"),
("nested/b.brink", "-- nested/b --"),
("README.md", "not brink"),
],
);
let tree = GitRev::new(&repo_dir, sha.clone(), ".");
let keys = tree.list().expect("list succeeds");
assert_eq!(keys, vec!["a.brink", "nested/b.brink", "z.brink"]);
assert_eq!(tree.read("a.brink").expect("read succeeds"), "-- a --");
assert_eq!(
tree.read("nested/b.brink").expect("read succeeds"),
"-- nested/b --"
);
fs::remove_dir_all(&repo_dir).expect("cleanup temp dir");
}
#[test]
fn git_rev_read_missing_key_is_not_found() {
let (repo_dir, sha) = git_repo_with_commit("gitrev-missing", &[("a.brink", "-- a --")]);
let tree = GitRev::new(&repo_dir, sha, ".");
let err = tree.read("missing.brink").expect_err("key absent at rev");
assert_eq!(err.kind(), io::ErrorKind::NotFound);
fs::remove_dir_all(&repo_dir).expect("cleanup temp dir");
}
#[test]
fn git_rev_list_uses_only_its_constructor_root_not_a_call_site_argument() {
let (repo_dir, sha) = git_repo_with_commit(
"gitrev-constructor-root",
&[("sub/a.brink", "-- sub/a --"), ("top.brink", "-- top --")],
);
let tree = GitRev::new(&repo_dir, sha, "sub");
let keys = tree.list().expect("list succeeds");
assert_eq!(
keys,
vec!["a.brink"],
"must scope to the constructor-held root (sub/), never see top.brink"
);
assert_eq!(tree.read("a.brink").expect("read succeeds"), "-- sub/a --");
fs::remove_dir_all(&repo_dir).expect("cleanup temp dir");
}
#[test]
fn is_native_matches_brink_extension_only() {
assert!(is_native(Path::new("foo.brink")));
assert!(is_native(Path::new("nested/foo.brink")));
assert!(!is_native(Path::new("foo.ink")));
assert!(!is_native(Path::new("foo")));
}
#[test]
fn native_source_root_walks_up_to_brink_toml() {
let dir = temp_dir("root-walkup");
fs::create_dir_all(dir.join("sub")).expect("mkdir sub");
fs::write(dir.join("brink.toml"), "[project]\n").expect("write brink.toml");
let entry = dir.join("sub").join("main.brink");
let root = native_source_root(&entry);
assert_eq!(
root, dir,
"root must be brink.toml's directory, not entry's"
);
fs::remove_dir_all(&dir).expect("cleanup temp dir");
}
#[test]
fn native_source_root_falls_back_to_entry_dir_without_brink_toml() {
let dir = temp_dir("root-fallback");
let entry = dir.join("main.brink");
let root = native_source_root(&entry);
assert_eq!(root, dir);
fs::remove_dir_all(&dir).expect("cleanup temp dir");
}
#[test]
fn relative_key_strips_root_prefix() {
let dir = temp_dir("relative-key");
let path = dir.join("story").join("main.brink");
assert_eq!(relative_key(&dir, &path), "story/main.brink");
fs::remove_dir_all(&dir).expect("cleanup temp dir");
}
#[test]
fn relative_key_root_dot_keys_a_relative_path_as_is() {
assert_eq!(
relative_key(Path::new("."), Path::new("main.brink")),
"main.brink"
);
}
#[test]
fn native_source_root_with_warnings_reports_discovery_warnings() {
let base = temp_dir("native-source-root-warnings");
fs::write(base.join("brink.toml"), "[project]\n").expect("write above brink.toml");
let root = base.join("proj");
fs::create_dir_all(root.join(".git")).expect("mkdir .git");
fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write .git/HEAD");
fs::create_dir_all(root.join("sub/deep")).expect("mkdir sub/deep");
fs::write(root.join("sub/deep/entry.brink"), "flow main() {}\n")
.expect("write entry.brink");
let entry = root.join("sub/deep/entry.brink");
let (resolved_root, warnings) = native_source_root_with_warnings(&entry);
assert_eq!(resolved_root, root.join("sub/deep"));
assert_eq!(warnings.len(), 1, "expected exactly one discovery warning");
let warning_text = warnings[0].to_string();
let stepped_over = base.join("brink.toml");
assert!(
warning_text.contains(&stepped_over.display().to_string()),
"warning must name the stepped-over brink.toml path {}: got {warning_text}",
stepped_over.display(),
);
fs::remove_dir_all(&base).expect("cleanup temp dir");
}
}