use std::path::Path;
use brink_db::{ProjectDb, SourceTree};
use tracing::info;
use crate::discover::DiscoverError;
use crate::source_tree::is_native;
pub fn discover_native(db: &mut ProjectDb, tree: &dyn SourceTree) -> Result<(), DiscoverError> {
let keys = tree.list()?;
if let Some(bad) = keys.iter().find(|key| is_dotdot_polluted(key)) {
return Err(DiscoverError::InvalidKey(bad.clone()));
}
if let Some(bad) = keys.iter().find(|key| !is_native(Path::new(key.as_str()))) {
return Err(DiscoverError::NonNativeKey(bad.clone()));
}
let count = keys.len();
for key in keys {
let source = tree.read(&key)?;
db.set_file(&key, source);
}
info!(files = count, "native discovery complete");
Ok(())
}
fn is_dotdot_polluted(key: &str) -> bool {
key.split('/').any(|segment| segment == "..")
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use brink_db::InMemory;
fn tree(files: &[(&str, &str)]) -> InMemory {
InMemory::new(
files
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect::<BTreeMap<_, _>>(),
)
}
#[test]
fn loads_all_files_root_relative_keyed() {
let mut db = ProjectDb::new();
let t = tree(&[
("z.brink", "flow z() {}"),
("a.brink", "flow a() {}"),
("nested/b.brink", "flow b() {}"),
]);
discover_native(&mut db, &t).expect("discovery succeeds");
let mut paths: Vec<_> = db.file_ids().filter_map(|id| db.file_path(id)).collect();
paths.sort_unstable();
assert_eq!(paths, vec!["a.brink", "nested/b.brink", "z.brink"]);
}
#[test]
fn file_ids_mint_in_sorted_key_order() {
let mut db = ProjectDb::new();
let t = tree(&[
("z.brink", "flow z() {}"),
("a.brink", "flow a() {}"),
("m.brink", "flow m() {}"),
]);
discover_native(&mut db, &t).expect("discovery succeeds");
let a = db.file_id("a.brink").expect("a.brink discovered");
let m = db.file_id("m.brink").expect("m.brink discovered");
let z = db.file_id("z.brink").expect("z.brink discovered");
assert!(
a.0 < m.0 && m.0 < z.0,
"FileIds must mint in sorted-key order: a={}, m={}, z={}",
a.0,
m.0,
z.0
);
}
#[test]
fn hostile_key_order_yields_identical_file_ids_and_module_paths() {
let mut db_a = ProjectDb::new();
let t_a = tree(&[
("z.brink", "flow z() {}"),
("a.brink", "flow a() {}"),
("nested/m.brink", "flow m() {}"),
]);
discover_native(&mut db_a, &t_a).expect("discovery succeeds");
let mut db_b = ProjectDb::new();
let t_b = tree(&[
("nested/m.brink", "flow m() {}"),
("z.brink", "flow z() {}"),
("a.brink", "flow a() {}"),
]);
discover_native(&mut db_b, &t_b).expect("discovery succeeds");
for key in ["a.brink", "nested/m.brink", "z.brink"] {
let id_a = db_a.file_id(key).expect("{key} in db_a");
let id_b = db_b.file_id(key).expect("{key} in db_b");
assert_eq!(id_a, id_b, "FileId for {key} must match across trees");
}
let modules_a = knot_modules(&db_a);
let modules_b = knot_modules(&db_b);
assert_eq!(
modules_a, modules_b,
"derived module paths must match regardless of discovery order"
);
assert_eq!(
modules_a.get("m").map(String::as_str),
Some("story::nested::m"),
"sanity: nested/m.brink's module must be story::nested::m"
);
}
fn knot_modules(db: &ProjectDb) -> BTreeMap<String, String> {
db.analysis()
.index
.symbols
.values()
.filter(|s| s.kind == brink_ir::SymbolKind::Knot)
.filter_map(|s| Some((s.name.clone(), s.module.clone()?)))
.collect()
}
#[test]
fn single_file_discovers() {
let mut db = ProjectDb::new();
let t = tree(&[("main.brink", "flow main() {}")]);
discover_native(&mut db, &t).expect("discovery succeeds");
assert_eq!(db.file_ids().count(), 1);
assert!(db.file_id("main.brink").is_some());
}
#[test]
fn empty_tree_discovers_nothing() {
let mut db = ProjectDb::new();
let t = tree(&[]);
discover_native(&mut db, &t).expect("discovery succeeds");
assert_eq!(db.file_ids().count(), 0);
}
#[test]
fn dotdot_key_is_rejected_before_any_load() {
struct Hostile;
impl SourceTree for Hostile {
fn list(&self) -> std::io::Result<Vec<String>> {
Ok(vec!["a.brink".to_string(), "../escape.brink".to_string()])
}
fn read(&self, key: &str) -> std::io::Result<String> {
Ok(format!("-- {key} --"))
}
}
let mut db = ProjectDb::new();
let err = discover_native(&mut db, &Hostile).expect_err("must be rejected");
assert!(matches!(err, DiscoverError::InvalidKey(k) if k == "../escape.brink"));
assert_eq!(
db.file_ids().count(),
0,
"no partial load: a.brink must not have been set either"
);
}
#[test]
fn non_native_key_is_rejected_before_any_load() {
struct WiderThanNative;
impl SourceTree for WiderThanNative {
fn list(&self) -> std::io::Result<Vec<String>> {
Ok(vec![
"a.brink".to_string(),
"brink.toml".to_string(),
"main.ink".to_string(),
])
}
fn read(&self, key: &str) -> std::io::Result<String> {
Ok(format!("-- {key} --"))
}
}
let mut db = ProjectDb::new();
let err = discover_native(&mut db, &WiderThanNative).expect_err("must be rejected");
assert!(matches!(err, DiscoverError::NonNativeKey(k) if k == "brink.toml"));
assert_eq!(
db.file_ids().count(),
0,
"no partial load: a.brink must not have been set either"
);
}
#[test]
fn absolute_keys_plus_native_root_mint_compile_identical_identity() {
use std::fs;
use crate::source_tree::RealFs;
const FILES: [(&str, &str); 3] = [
("main.brink", "flow start() {\n The market is busy.\n}\n"),
(
"market/barter.brink",
"flow haggle() {\n You haggle over the price.\n}\n",
),
(
"npcs/quests/intro.brink",
"flow intro() {\n A stranger waves.\n}\n",
),
];
let root = temp_dir("native-identity-parity");
for (key, source) in FILES {
let path = root.join(key);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create fixture dir");
}
fs::write(&path, source).expect("write fixture file");
}
let mut compiled = native_db();
discover_native(&mut compiled, &RealFs::new(&root)).expect("discovery succeeds");
let mut editor = native_db();
editor.set_native_root(Some(root.to_string_lossy().into_owned()));
register_absolute(&mut editor, &root, &FILES);
let mut drifted = native_db();
register_absolute(&mut drifted, &root, &FILES);
let compiled_identity = flow_identity(&compiled);
let editor_identity = flow_identity(&editor);
let drifted_identity = flow_identity(&drifted);
fs::remove_dir_all(&root).expect("clean up fixture tree");
assert_eq!(
compiled_identity
.get("haggle")
.map(|(module, _)| module.as_str()),
Some("story::market::barter"),
"compile-side module identity, got {compiled_identity:?}"
);
assert_eq!(
editor_identity, compiled_identity,
"an editor keying by absolute path must mint the SAME native module \
names and `DefinitionId`s a real compile of the same tree mints"
);
assert_ne!(
drifted_identity, compiled_identity,
"guard against a vacuous test: without a declared native root the \
absolute-keyed database must still diverge, got {drifted_identity:?}"
);
}
fn native_db() -> ProjectDb {
let mut db = ProjectDb::new();
db.set_analysis_options(brink_analyzer::AnalysisOptions {
dialect: brink_analyzer::Dialect::Brink,
..brink_analyzer::AnalysisOptions::default()
});
db
}
fn register_absolute(db: &mut ProjectDb, root: &Path, files: &[(&str, &str)]) {
for (key, source) in files {
let path = root.join(key);
db.set_file(&path.to_string_lossy(), (*source).to_string());
}
}
fn flow_identity(db: &ProjectDb) -> BTreeMap<String, (String, brink_format::DefinitionId)> {
let index = db.symbol_index();
index
.symbols
.iter()
.filter(|(_, s)| s.kind == brink_ir::SymbolKind::Knot)
.filter_map(|(id, s)| Some((s.name.clone(), (s.module.clone()?, *id))))
.collect()
}
fn temp_dir(label: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
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-discover-native-test-{label}-{}-{n}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
}