diff --git a/README.md b/README.md index 92a972e..822ae05 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,13 @@ Remove-Item -Recurse -Force (Join-Path $env:APPDATA "bt") -ErrorAction SilentlyC | `bt sync` | Synchronize project logs between Braintrust and local NDJSON files | | `bt self update` | Update bt in-place | +## `bt init` + +- Creates local project config at `.bt/config.json`. +- Ensures `.bt/.gitignore` is present with managed rules: + - tracked by default: `.bt/config.json`, `.bt/skills/**` + - ignored by default: other `.bt/*` runtime artifacts (for example runner caches) + ## `bt eval` **File selection:** diff --git a/src/bt_dir.rs b/src/bt_dir.rs new file mode 100644 index 0000000..d730ee1 --- /dev/null +++ b/src/bt_dir.rs @@ -0,0 +1,196 @@ +use std::path::{Path, PathBuf}; + +use anyhow::bail; +use anyhow::Result; + +use crate::utils::write_text_atomic; + +pub const BT_DIR: &str = ".bt"; +pub const CONFIG_FILE: &str = "config.json"; +pub const GITIGNORE_FILE: &str = ".gitignore"; + +const MANAGED_START: &str = "# BEGIN bt-managed"; +const MANAGED_END: &str = "# END bt-managed"; +const MANAGED_BODY: &str = "*\n!config.json\n!.gitignore\n!skills/\n!skills/**\n"; + +pub fn bt_dir(root: &Path) -> PathBuf { + root.join(BT_DIR) +} + +pub fn config_path(root: &Path) -> PathBuf { + bt_dir(root).join(CONFIG_FILE) +} + +pub fn gitignore_path(root: &Path) -> PathBuf { + bt_dir(root).join(GITIGNORE_FILE) +} + +pub fn cache_dir(root: &Path) -> PathBuf { + bt_dir(root).join("cache") +} + +pub fn runners_cache_dir(root: &Path) -> PathBuf { + cache_dir(root) + .join("runners") + .join(env!("CARGO_PKG_VERSION")) +} + +pub fn state_dir(root: &Path) -> PathBuf { + bt_dir(root).join("state") +} + +pub fn skills_dir(root: &Path) -> PathBuf { + bt_dir(root).join("skills") +} + +pub fn skills_docs_dir(root: &Path) -> PathBuf { + skills_dir(root).join("docs") +} + +pub fn ensure_repo_layout(root: &Path) -> Result<()> { + let dir = bt_dir(root); + ensure_not_symlink(&dir)?; + std::fs::create_dir_all(&dir)?; + ensure_not_symlink(&dir)?; + ensure_bt_gitignore(root) +} + +pub fn ensure_bt_gitignore(root: &Path) -> Result<()> { + let dir = bt_dir(root); + ensure_not_symlink(&dir)?; + let path = gitignore_path(root); + ensure_not_symlink(&path)?; + let existing = match std::fs::read_to_string(&path) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(err) => return Err(err.into()), + }; + let updated = upsert_managed_block(&existing); + if updated != existing { + write_text_atomic(&path, &updated)?; + } + Ok(()) +} + +fn managed_block() -> String { + format!("{MANAGED_START}\n{MANAGED_BODY}{MANAGED_END}\n") +} + +fn upsert_managed_block(existing: &str) -> String { + let managed = managed_block(); + let user_tail = strip_managed_block(existing); + + if user_tail.is_empty() { + managed + } else { + let mut out = String::with_capacity(managed.len() + user_tail.len() + 2); + out.push_str(&managed); + if !user_tail.starts_with('\n') { + out.push('\n'); + } + out.push_str(&user_tail); + if !out.ends_with('\n') { + out.push('\n'); + } + out + } +} + +fn ensure_not_symlink(path: &Path) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + bail!("refusing to use symlink path {}", path.display()); + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + Ok(()) +} + +fn strip_managed_block(existing: &str) -> String { + let Some(start) = existing.find(MANAGED_START) else { + return existing.to_string(); + }; + let Some(end_marker) = existing[start..].find(MANAGED_END) else { + return existing.to_string(); + }; + + let mut end = start + end_marker + MANAGED_END.len(); + while end < existing.len() && existing.as_bytes()[end] == b'\n' { + end += 1; + } + + let mut out = String::new(); + out.push_str(&existing[..start]); + out.push_str(&existing[end..]); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upsert_adds_managed_block_for_empty_file() { + let updated = upsert_managed_block(""); + assert!(updated.starts_with(MANAGED_START)); + assert!(updated.contains("!config.json")); + assert!(updated.contains("!skills/**")); + } + + #[test] + fn upsert_places_managed_block_first_and_preserves_custom_rules() { + let existing = + "custom-before\n\n# BEGIN bt-managed\nold\n# END bt-managed\n\ncustom-after\n"; + let updated = upsert_managed_block(existing); + assert!(updated.starts_with("# BEGIN bt-managed\n")); + assert!(updated.contains("custom-before")); + assert!(updated.contains("custom-after")); + assert_eq!(updated.matches(MANAGED_START).count(), 1); + assert_eq!(updated.matches(MANAGED_END).count(), 1); + let end_pos = updated.find(MANAGED_END).unwrap(); + let before_pos = updated.find("custom-before").unwrap(); + let after_pos = updated.find("custom-after").unwrap(); + assert!(before_pos > end_pos); + assert!(after_pos > end_pos); + } + + #[test] + fn strip_managed_block_returns_input_when_markers_incomplete() { + let existing = "# BEGIN bt-managed\nno-end"; + assert_eq!(strip_managed_block(existing), existing); + } + + #[test] + fn upsert_is_idempotent_with_custom_rules() { + let existing = + "custom-before\n\n# BEGIN bt-managed\nold\n# END bt-managed\n\ncustom-after\n\n"; + let once = upsert_managed_block(existing); + let twice = upsert_managed_block(&once); + assert_eq!(once, twice); + } + + #[cfg(unix)] + #[test] + fn ensure_repo_layout_rejects_symlinked_bt_dir() { + use std::os::unix::fs::symlink; + + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!("bt-layout-symlink-{unique}")); + let target = std::env::temp_dir().join(format!("bt-layout-symlink-target-{unique}")); + std::fs::create_dir_all(&root).expect("create root"); + std::fs::create_dir_all(&target).expect("create target"); + symlink(&target, root.join(BT_DIR)).expect("create symlinked .bt"); + + let err = ensure_repo_layout(&root).expect_err("must reject symlinked .bt"); + assert!(err.to_string().contains("symlink")); + + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&target); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index a26cd33..98f6155 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -169,12 +169,18 @@ pub fn save_global(config: &Config) -> Result<()> { } pub fn find_local_config_dir() -> Option { + let current_dir = std::env::current_dir().ok()?; + find_local_config_dir_from(¤t_dir) +} + +fn find_local_config_dir_from(start: &Path) -> Option { let home = dirs::home_dir(); - let mut current_dir = std::env::current_dir().ok()?; + let mut current_dir = start.to_path_buf(); loop { - if current_dir.join(".bt").is_dir() { - return Some(current_dir.join(".bt")); + let bt_dir = current_dir.join(crate::bt_dir::BT_DIR); + if bt_dir.join(crate::bt_dir::CONFIG_FILE).is_file() { + return Some(bt_dir); } if current_dir.join(".git").exists() { return None; @@ -189,7 +195,7 @@ pub fn find_local_config_dir() -> Option { } pub fn local_path() -> Option { - find_local_config_dir().map(|dir| dir.join("config.json")) + find_local_config_dir().map(|dir| dir.join(crate::bt_dir::CONFIG_FILE)) } pub enum WriteTarget { @@ -222,12 +228,10 @@ pub fn resolve_write_path(global: bool, local: bool) -> Result { } } -pub fn save_local(config: &Config, create_dir: bool) -> Result<()> { - let dir = std::env::current_dir()?.join(".bt"); - if create_dir && !dir.exists() { - fs::create_dir_all(&dir)?; - } - save_file(&dir.join("config.json"), config) +pub fn save_local(config: &Config) -> Result<()> { + let cwd = std::env::current_dir()?; + crate::bt_dir::ensure_repo_layout(&cwd)?; + save_file(&crate::bt_dir::config_path(&cwd), config) } // --- CLI commands --- @@ -460,4 +464,29 @@ mod tests { save_file(&path, &config).unwrap(); assert!(path.exists()); } + + #[test] + fn find_local_config_dir_requires_config_file() { + let tmp = TempDir::new().unwrap(); + let repo_root = tmp.path(); + std::fs::create_dir_all(repo_root.join(".git")).unwrap(); + std::fs::create_dir_all(repo_root.join(".bt").join("cache")).unwrap(); + std::fs::create_dir_all(repo_root.join("nested")).unwrap(); + + let discovered = find_local_config_dir_from(&repo_root.join("nested")); + assert_eq!(discovered, None); + } + + #[test] + fn find_local_config_dir_detects_config_file_in_parent_repo() { + let tmp = TempDir::new().unwrap(); + let repo_root = tmp.path(); + std::fs::create_dir_all(repo_root.join(".git")).unwrap(); + std::fs::create_dir_all(repo_root.join(".bt")).unwrap(); + std::fs::write(repo_root.join(".bt").join("config.json"), "{}\n").unwrap(); + std::fs::create_dir_all(repo_root.join("nested").join("pkg")).unwrap(); + + let discovered = find_local_config_dir_from(&repo_root.join("nested").join("pkg")); + assert_eq!(discovered, Some(repo_root.join(".bt"))); + } } diff --git a/src/eval.rs b/src/eval.rs index 128a51e..bad65b9 100644 --- a/src/eval.rs +++ b/src/eval.rs @@ -2134,7 +2134,7 @@ fn build_js_plan( if let Some(explicit) = runner_override { let resolved_runner = resolve_js_runner_command(explicit, files); if is_deno_runner(explicit) || is_deno_runner_path(resolved_runner.as_ref()) { - let runner_script = prepare_js_runner_in_cwd()?; + let runner_script = prepare_js_runner_in_repo_cache()?; return Ok(JsRunnerPlan { cmd: build_deno_js_command(resolved_runner.as_os_str(), &runner_script, files), kind: RunnerKind::Deno, @@ -2149,7 +2149,7 @@ fn build_js_plan( if let Some(auto_runner) = find_js_runner_binary(files) { if is_deno_runner_path(&auto_runner) { - let runner_script = prepare_js_runner_in_cwd()?; + let runner_script = prepare_js_runner_in_repo_cache()?; return Ok(JsRunnerPlan { cmd: build_deno_js_command(auto_runner.as_os_str(), &runner_script, files), kind: RunnerKind::Deno, @@ -2313,24 +2313,14 @@ fn is_deno_runner_path(runner: &Path) -> bool { fn select_js_runner_entrypoint(default_runner: &Path, runner_command: &Path) -> Result { if is_ts_node_runner(runner_command) { - return prepare_js_runner_in_cwd(); + return prepare_js_runner_in_repo_cache(); } Ok(default_runner.to_path_buf()) } -fn prepare_js_runner_in_cwd() -> Result { - let cwd = std::env::current_dir().context("failed to resolve current working directory")?; - let cache_dir = cwd - .join(".bt") - .join("eval-runners") - .join(env!("CARGO_PKG_VERSION")); - std::fs::create_dir_all(&cache_dir).with_context(|| { - format!( - "failed to create eval runner cache dir {}", - cache_dir.display() - ) - })?; - materialize_runner_script(&cache_dir, JS_RUNNER_FILE, JS_RUNNER_SOURCE) +fn prepare_js_runner_in_repo_cache() -> Result { + let root = resolve_runner_cache_root()?; + crate::runner::materialize_runner_script_in_root(&root, JS_RUNNER_FILE, JS_RUNNER_SOURCE) } fn runner_bin_name(runner_command: &Path) -> Option { @@ -2461,42 +2451,31 @@ fn bind_sse_listener() -> Result<(UnixListener, PathBuf, SocketCleanupGuard)> { )) } -fn eval_runner_cache_dir() -> PathBuf { - let root = std::env::var_os("XDG_CACHE_HOME") - .map(PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cache"))) - .unwrap_or_else(std::env::temp_dir); - - root.join("bt") - .join("eval-runners") - .join(env!("CARGO_PKG_VERSION")) -} - fn prepare_eval_runners() -> Result<(PathBuf, PathBuf)> { - prepare_eval_runners_in_dir(&eval_runner_cache_dir()) + let root = resolve_runner_cache_root()?; + prepare_eval_runners_in_root(&root) } -fn prepare_eval_runners_in_dir(cache_dir: &Path) -> Result<(PathBuf, PathBuf)> { - std::fs::create_dir_all(cache_dir).with_context(|| { - format!( - "failed to create eval runner cache dir {}", - cache_dir.display() - ) - })?; - - let js_runner = materialize_runner_script(cache_dir, JS_RUNNER_FILE, JS_RUNNER_SOURCE)?; - let py_runner = materialize_runner_script(cache_dir, PY_RUNNER_FILE, PY_RUNNER_SOURCE)?; +fn prepare_eval_runners_in_root(root: &Path) -> Result<(PathBuf, PathBuf)> { + crate::bt_dir::ensure_repo_layout(root)?; + let js_runner = + crate::runner::materialize_runner_script_in_root(root, JS_RUNNER_FILE, JS_RUNNER_SOURCE)?; + let py_runner = + crate::runner::materialize_runner_script_in_root(root, PY_RUNNER_FILE, PY_RUNNER_SOURCE)?; Ok((js_runner, py_runner)) } -fn materialize_runner_script(cache_dir: &Path, file_name: &str, source: &str) -> Result { - let path = cache_dir.join(file_name); - let current = std::fs::read_to_string(&path).ok(); - if current.as_deref() != Some(source) { - std::fs::write(&path, source) - .with_context(|| format!("failed to write eval runner script {}", path.display()))?; +fn resolve_runner_cache_root() -> Result { + if let Some(local_bt_dir) = crate::config::find_local_config_dir() { + if let Some(parent) = local_bt_dir.parent() { + return Ok(parent.to_path_buf()); + } + } + let cwd = std::env::current_dir().context("failed to resolve current working directory")?; + if let Some(repo) = crate::utils::GitRepo::discover_from(&cwd) { + return Ok(repo.root().to_path_buf()); } - Ok(path) + Ok(cwd) } #[derive(Debug)] @@ -3533,44 +3512,174 @@ mod tests { #[test] fn materialize_runner_script_writes_file() { - let dir = make_temp_dir("write"); + let root = make_temp_dir("write"); - let path = materialize_runner_script(&dir, "runner.ts", "console.log('ok');") - .expect("runner script should be materialized"); + let path = crate::runner::materialize_runner_script_in_root( + &root, + "runner.ts", + "console.log('ok');", + ) + .expect("runner script should be materialized"); let contents = fs::read_to_string(path).expect("runner script should be readable"); assert_eq!(contents, "console.log('ok');"); - let _ = fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&root); } #[test] fn materialize_runner_script_overwrites_stale_content() { - let dir = make_temp_dir("overwrite"); - let path = dir.join("runner.py"); + let root = make_temp_dir("overwrite"); + let cache_dir = crate::bt_dir::runners_cache_dir(&root); + fs::create_dir_all(&cache_dir).expect("cache dir should be created"); + let path = cache_dir.join("runner.py"); fs::write(&path, "stale").expect("stale file should be written"); - materialize_runner_script(&dir, "runner.py", "fresh") + crate::runner::materialize_runner_script_in_root(&root, "runner.py", "fresh") .expect("runner script should be updated"); let contents = fs::read_to_string(path).expect("runner script should be readable"); assert_eq!(contents, "fresh"); - let _ = fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&root); } #[test] fn prepare_eval_runners_writes_embedded_scripts() { - let dir = make_temp_dir("embedded"); + let root = make_temp_dir("embedded"); let (js_runner, py_runner) = - prepare_eval_runners_in_dir(&dir).expect("embedded runners should be materialized"); + prepare_eval_runners_in_root(&root).expect("embedded runners should be materialized"); let js = fs::read_to_string(js_runner).expect("js runner should be readable"); let py = fs::read_to_string(py_runner).expect("python runner should be readable"); assert_eq!(js, JS_RUNNER_SOURCE); assert_eq!(py, PY_RUNNER_SOURCE); + let gitignore = fs::read_to_string(root.join(".bt").join(".gitignore")) + .expect("managed .bt/.gitignore should be written"); + assert!(gitignore.contains("# BEGIN bt-managed")); + assert!(gitignore.contains("!config.json")); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn prepare_eval_runners_uses_repo_local_cache() { + let _guard = env_test_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let dir = make_temp_dir("repo-local-eval-runners"); + let original_cwd = std::env::current_dir().expect("read current directory"); + std::env::set_current_dir(&dir).expect("set cwd"); + + let (js_runner, py_runner) = prepare_eval_runners().expect("prepare eval runners"); + + std::env::set_current_dir(&original_cwd).expect("restore cwd"); + + let expected_root = dir + .join(".bt") + .join("cache") + .join("runners") + .join(env!("CARGO_PKG_VERSION")) + .canonicalize() + .expect("canonicalize expected root"); + let js_root = js_runner + .parent() + .expect("js runner parent") + .canonicalize() + .expect("canonicalize js runner parent"); + let py_root = py_runner + .parent() + .expect("py runner parent") + .canonicalize() + .expect("canonicalize py runner parent"); + + assert_eq!(js_root, expected_root); + assert_eq!(py_root, expected_root); let _ = fs::remove_dir_all(&dir); } + #[test] + fn resolve_runner_cache_root_uses_git_repo_root() { + let _guard = env_test_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = make_temp_dir("runner-root-git"); + let nested = root.join("a").join("b"); + fs::create_dir_all(&nested).expect("create nested directories"); + fs::create_dir_all(root.join(".git")).expect("create git marker"); + + let original_cwd = std::env::current_dir().expect("read current directory"); + std::env::set_current_dir(&nested).expect("set cwd"); + let resolved = resolve_runner_cache_root().expect("resolve runner cache root"); + std::env::set_current_dir(&original_cwd).expect("restore cwd"); + + assert_eq!( + resolved.canonicalize().expect("canonicalize resolved root"), + root.canonicalize().expect("canonicalize root"), + ); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn prepare_eval_runners_uses_git_repo_root_when_nested() { + let _guard = env_test_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = make_temp_dir("runner-cache-git-root"); + let nested = root.join("pkg").join("evals"); + fs::create_dir_all(&nested).expect("create nested directories"); + fs::create_dir_all(root.join(".git")).expect("create git marker"); + + let original_cwd = std::env::current_dir().expect("read current directory"); + std::env::set_current_dir(&nested).expect("set cwd"); + let (js_runner, py_runner) = prepare_eval_runners().expect("prepare eval runners"); + std::env::set_current_dir(&original_cwd).expect("restore cwd"); + + let expected_root = root + .join(".bt") + .join("cache") + .join("runners") + .join(env!("CARGO_PKG_VERSION")) + .canonicalize() + .expect("canonicalize expected root"); + + assert_eq!( + js_runner + .parent() + .expect("js runner parent") + .canonicalize() + .expect("canonicalize js runner parent"), + expected_root + ); + assert_eq!( + py_runner + .parent() + .expect("py runner parent") + .canonicalize() + .expect("canonicalize py runner parent"), + expected_root + ); + assert!(!nested.join(".bt").exists()); + + let _ = fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn prepare_eval_runners_rejects_symlinked_bt_dir() { + use std::os::unix::fs::symlink; + + let root = make_temp_dir("runner-symlink"); + let target = make_temp_dir("runner-symlink-target"); + symlink(&target, root.join(".bt")).expect("create symlinked .bt"); + + let err = prepare_eval_runners_in_root(&root).expect_err("must reject symlinked path"); + assert!(err.to_string().contains("symlink")); + + let _ = fs::remove_dir_all(&root); + let _ = fs::remove_dir_all(&target); + } + #[test] fn resolve_js_runner_command_finds_local_node_module_bin() { let dir = make_temp_dir("resolve-runner"); @@ -3607,6 +3716,62 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn select_js_runner_entrypoint_materializes_ts_node_at_repo_cache_root() { + let _guard = env_test_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = make_temp_dir("ts-node-entrypoint"); + let cwd = root.join("package"); + fs::create_dir_all(&cwd).expect("create package directory"); + fs::create_dir_all(root.join(".git")).expect("create git marker"); + + let original_cwd = std::env::current_dir().expect("read current directory"); + std::env::set_current_dir(&cwd).expect("set cwd"); + + for runner in ["ts-node", "ts-node-esm"] { + let entrypoint = + select_js_runner_entrypoint(Path::new("repo-runner.ts"), Path::new(runner)) + .expect("select runner entrypoint"); + let expected_root = root + .join(".bt") + .join("cache") + .join("runners") + .join(env!("CARGO_PKG_VERSION")) + .canonicalize() + .expect("canonicalize expected root"); + assert_eq!( + entrypoint + .parent() + .expect("entrypoint parent") + .canonicalize() + .expect("canonicalize entrypoint parent"), + expected_root + ); + assert_eq!( + entrypoint.file_name().and_then(|value| value.to_str()), + Some(JS_RUNNER_FILE) + ); + let contents = fs::read_to_string(&entrypoint).expect("read materialized entrypoint"); + assert_eq!(contents, JS_RUNNER_SOURCE); + } + assert!( + !cwd.join(".bt").exists(), + "ts-node runner cache should be written at repo root, not cwd" + ); + + std::env::set_current_dir(&original_cwd).expect("restore cwd"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn select_js_runner_entrypoint_keeps_default_for_non_ts_node() { + let default = PathBuf::from("repo-root-runner.ts"); + let entrypoint = select_js_runner_entrypoint(&default, Path::new("tsx")) + .expect("select runner entrypoint"); + assert_eq!(entrypoint, default); + } + #[test] fn expand_eval_file_globs_expands_recursive_glob() { let dir = make_temp_dir("glob-recursive"); diff --git a/src/functions/push.rs b/src/functions/push.rs index 12862a3..a0546a8 100644 --- a/src/functions/push.rs +++ b/src/functions/push.rs @@ -23,9 +23,8 @@ use crate::functions::report::{ CommandStatus, FileStatus, HardFailureReason, PushFileReport, PushSummary, ReportError, SoftSkipReason, }; -use crate::js_runner; use crate::projects::api::{create_project, get_project_by_name, list_projects}; -use crate::python_runner; +use crate::runner::{self, js, py}; use crate::source_language::{classify_runtime_extension, SourceLanguage}; use crate::ui::{animations_enabled, is_interactive, is_quiet}; @@ -980,8 +979,7 @@ fn build_js_bundle( })?; let output_bundle = build_dir.path.join("bundle.js"); - let bundler_script = js_runner::materialize_runner_script_in_cwd( - "functions-runners", + let bundler_script = runner::materialize_runner_script_in_cwd( FUNCTIONS_JS_BUNDLER_FILE, FUNCTIONS_JS_BUNDLER_SOURCE, ) @@ -990,7 +988,7 @@ fn build_js_bundle( message: format!("failed to materialize JS bundler script: {err}"), })?; - let mut command = js_runner::build_js_runner_command( + let mut command = js::build_js_runner_command( args.runner.as_deref(), &bundler_script, &[source_path.to_path_buf(), output_bundle.clone()], @@ -1045,17 +1043,13 @@ fn run_functions_runner( ) -> std::result::Result { let mut command = match language { SourceLanguage::JsLike => { - let _common = js_runner::materialize_runner_script_in_cwd( - "functions-runners", - RUNNER_COMMON_FILE, - RUNNER_COMMON_SOURCE, - ) - .map_err(|err| FileFailure { - reason: HardFailureReason::RunnerSpawnFailed, - message: format!("failed to materialize shared runner helper: {err}"), - })?; - let runner_script = js_runner::materialize_runner_script_in_cwd( - "functions-runners", + let _common = + runner::materialize_runner_script_in_cwd(RUNNER_COMMON_FILE, RUNNER_COMMON_SOURCE) + .map_err(|err| FileFailure { + reason: HardFailureReason::RunnerSpawnFailed, + message: format!("failed to materialize shared runner helper: {err}"), + })?; + let runner_script = runner::materialize_runner_script_in_cwd( FUNCTIONS_JS_RUNNER_FILE, FUNCTIONS_JS_RUNNER_SOURCE, ) @@ -1063,11 +1057,10 @@ fn run_functions_runner( reason: HardFailureReason::RunnerSpawnFailed, message: format!("failed to materialize functions runner: {err}"), })?; - js_runner::build_js_runner_command(args.runner.as_deref(), &runner_script, files) + js::build_js_runner_command(args.runner.as_deref(), &runner_script, files) } SourceLanguage::Python => { - let _common = js_runner::materialize_runner_script_in_cwd( - "functions-runners", + let _common = runner::materialize_runner_script_in_cwd( PYTHON_RUNNER_COMMON_FILE, PYTHON_RUNNER_COMMON_SOURCE, ) @@ -1075,8 +1068,7 @@ fn run_functions_runner( reason: HardFailureReason::RunnerSpawnFailed, message: format!("failed to materialize shared Python runner helper: {err}"), })?; - let runner_script = js_runner::materialize_runner_script_in_cwd( - "functions-runners", + let runner_script = runner::materialize_runner_script_in_cwd( FUNCTIONS_PY_RUNNER_FILE, FUNCTIONS_PY_RUNNER_SOURCE, ) @@ -1084,7 +1076,7 @@ fn run_functions_runner( reason: HardFailureReason::RunnerSpawnFailed, message: format!("failed to materialize Python functions runner: {err}"), })?; - let Some(python) = python_runner::resolve_python_interpreter( + let Some(python) = py::resolve_python_interpreter( args.runner.as_deref(), PYTHON_INTERPRETER_ENV_OVERRIDES, ) else { @@ -1620,8 +1612,7 @@ fn build_python_bundle_archive( baseline_dep_versions: &[String], python_version: &str, ) -> Result> { - let Some(python) = - python_runner::resolve_python_interpreter(runner, PYTHON_INTERPRETER_ENV_OVERRIDES) + let Some(python) = py::resolve_python_interpreter(runner, PYTHON_INTERPRETER_ENV_OVERRIDES) else { bail!("No Python interpreter found. Install python or pass --runner.") }; @@ -1833,7 +1824,7 @@ fn install_python_dependencies( baseline_dep_versions: &[String], python_version: &str, ) -> Result<()> { - let uv = python_runner::find_binary_in_path(&["uv"]).ok_or_else(|| { + let uv = py::find_binary_in_path(&["uv"]).ok_or_else(|| { anyhow!("`uv` is required to build Python code bundles; please install uv") })?; diff --git a/src/init.rs b/src/init.rs index 0a032f0..aa92f3f 100644 --- a/src/init.rs +++ b/src/init.rs @@ -18,8 +18,11 @@ Examples: pub struct InitArgs {} pub async fn run(base: BaseArgs, _args: InitArgs) -> Result<()> { - let bt_dir = std::env::current_dir()?.join(".bt"); - if bt_dir.join("config.json").exists() { + let cwd = std::env::current_dir()?; + let config_path = crate::bt_dir::config_path(&cwd); + + if config_path.exists() { + crate::bt_dir::ensure_repo_layout(&cwd)?; print_command_status(CommandStatus::Warning, "Already Initialized"); return Ok(()); } @@ -52,7 +55,7 @@ pub async fn run(base: BaseArgs, _args: InitArgs) -> Result<()> { ..Default::default() }; - config::save_local(&cfg, true)?; + config::save_local(&cfg)?; print_command_status( CommandStatus::Success, diff --git a/src/js_runner.rs b/src/js_runner.rs deleted file mode 100644 index cd4c94d..0000000 --- a/src/js_runner.rs +++ /dev/null @@ -1,283 +0,0 @@ -use std::ffi::OsStr; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use anyhow::{bail, Context, Result}; - -pub fn materialize_runner_script( - cache_dir: &Path, - file_name: &str, - source: &str, -) -> Result { - std::fs::create_dir_all(cache_dir).with_context(|| { - format!( - "failed to create runner cache directory {}", - cache_dir.display() - ) - })?; - ensure_not_symlink(cache_dir)?; - - let path = cache_dir.join(file_name); - ensure_not_symlink(&path)?; - let current = std::fs::read_to_string(&path).ok(); - if current.as_deref() != Some(source) { - crate::utils::write_text_atomic(&path, source) - .with_context(|| format!("failed to write runner script {}", path.display()))?; - } - Ok(path) -} - -pub fn materialize_runner_script_in_cwd( - cache_subdir: &str, - file_name: &str, - source: &str, -) -> Result { - let cwd = std::env::current_dir().context("failed to resolve current working directory")?; - let cache_dir = cwd - .join(".bt") - .join(cache_subdir) - .join(env!("CARGO_PKG_VERSION")); - ensure_descendant_components_not_symlinks(&cwd, &cache_dir)?; - materialize_runner_script(&cache_dir, file_name, source) -} - -pub fn build_js_runner_command( - runner_override: Option<&str>, - runner_script: &Path, - files: &[PathBuf], -) -> Command { - if let Some(explicit) = runner_override { - let resolved = resolve_js_runner_command(explicit, files); - if is_deno_runner_path(&resolved) { - return build_deno_command(resolved.as_os_str(), runner_script, files); - } - - let mut command = Command::new(&resolved); - command.arg(runner_script); - for file in files { - command.arg(file); - } - return command; - } - - if let Some(auto_runner) = find_js_runner_binary(files) { - if is_deno_runner_path(&auto_runner) { - return build_deno_command(auto_runner.as_os_str(), runner_script, files); - } - - let mut command = Command::new(&auto_runner); - command.arg(runner_script); - for file in files { - command.arg(file); - } - return command; - } - - let mut command = Command::new("npx"); - command.arg("--yes").arg("tsx").arg(runner_script); - for file in files { - command.arg(file); - } - command -} - -pub fn find_js_runner_binary(files: &[PathBuf]) -> Option { - const CANDIDATES: &[&str] = &["tsx", "vite-node", "ts-node", "ts-node-esm", "deno"]; - - for candidate in CANDIDATES { - if let Some(path) = find_node_module_bin_for_files(candidate, files) { - return Some(path); - } - } - - find_binary_in_path(CANDIDATES) -} - -pub fn resolve_js_runner_command(runner: &str, files: &[PathBuf]) -> PathBuf { - if is_path_like_runner(runner) { - return PathBuf::from(runner); - } - - find_node_module_bin_for_files(runner, files) - .or_else(|| find_binary_in_path(&[runner])) - .unwrap_or_else(|| PathBuf::from(runner)) -} - -fn build_deno_command(deno_runner: &OsStr, runner_script: &Path, files: &[PathBuf]) -> Command { - let mut command = Command::new(deno_runner); - command - .arg("run") - .arg("-A") - .arg("--node-modules-dir=auto") - .arg("--unstable-detect-cjs") - .arg(runner_script); - for file in files { - command.arg(file); - } - command -} - -fn is_path_like_runner(runner: &str) -> bool { - let path = Path::new(runner); - path.is_absolute() || runner.contains('/') || runner.contains('\\') || runner.starts_with('.') -} - -fn is_deno_runner_path(runner: &Path) -> bool { - runner - .file_name() - .and_then(|value| value.to_str()) - .map(|name| name.eq_ignore_ascii_case("deno") || name.eq_ignore_ascii_case("deno.exe")) - .unwrap_or(false) -} - -fn find_node_module_bin_for_files(binary: &str, files: &[PathBuf]) -> Option { - for root in js_runner_search_roots(files) { - if let Some(path) = find_node_module_bin(binary, &root) { - return Some(path); - } - } - None -} - -fn js_runner_search_roots(files: &[PathBuf]) -> Vec { - let mut roots = Vec::new(); - if let Ok(cwd) = std::env::current_dir() { - roots.push(cwd.clone()); - for file in files { - let absolute = if file.is_absolute() { - file.clone() - } else { - cwd.join(file) - }; - if let Some(parent) = absolute.parent() { - roots.push(parent.to_path_buf()); - } - } - } - roots -} - -fn find_node_module_bin(binary: &str, start: &Path) -> Option { - let mut current = Some(start); - while let Some(dir) = current { - let base = dir.join("node_modules").join(".bin").join(binary); - if base.is_file() { - return Some(base); - } - if cfg!(windows) { - for candidate in with_windows_extensions(&base) { - if candidate.is_file() { - return Some(candidate); - } - } - } - current = dir.parent(); - } - None -} - -fn find_binary_in_path(candidates: &[&str]) -> Option { - let paths = std::env::var_os("PATH")?; - for dir in std::env::split_paths(&paths) { - for candidate in candidates { - let path = dir.join(candidate); - if path.is_file() { - return Some(path); - } - if cfg!(windows) { - for candidate_path in with_windows_extensions(&path) { - if candidate_path.is_file() { - return Some(candidate_path); - } - } - } - } - } - None -} - -#[cfg(windows)] -fn with_windows_extensions(path: &Path) -> [PathBuf; 2] { - [path.with_extension("exe"), path.with_extension("cmd")] -} - -#[cfg(not(windows))] -fn with_windows_extensions(_path: &Path) -> [PathBuf; 0] { - [] -} - -fn ensure_descendant_components_not_symlinks(base: &Path, descendant: &Path) -> Result<()> { - let Ok(relative) = descendant.strip_prefix(base) else { - return Ok(()); - }; - - let mut current = base.to_path_buf(); - for component in relative.components() { - current.push(component.as_os_str()); - let metadata = match std::fs::symlink_metadata(¤t) { - Ok(metadata) => metadata, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => break, - Err(err) => { - return Err(err).with_context(|| { - format!("failed to inspect path component {}", current.display()) - }) - } - }; - if metadata.file_type().is_symlink() { - bail!( - "refusing to write runner script through symlink path component {}", - current.display() - ); - } - } - Ok(()) -} - -fn ensure_not_symlink(path: &Path) -> Result<()> { - match std::fs::symlink_metadata(path) { - Ok(metadata) => { - if metadata.file_type().is_symlink() { - bail!( - "refusing to write runner script via symlink {}", - path.display() - ); - } - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} - Err(err) => { - return Err(err) - .with_context(|| format!("failed to inspect runner path {}", path.display())) - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn path_like_runner_detection() { - assert!(is_path_like_runner("./tsx")); - assert!(is_path_like_runner("bin/tsx")); - assert!(!is_path_like_runner("tsx")); - } - - #[cfg(unix)] - #[test] - fn descendant_symlink_check_rejects_symlinked_component() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().expect("tempdir"); - let base = dir.path().join("base"); - let real = dir.path().join("real"); - std::fs::create_dir_all(&base).expect("create base directory"); - std::fs::create_dir_all(&real).expect("create real directory"); - let link = base.join("link"); - symlink(&real, &link).expect("create symlink"); - - let err = ensure_descendant_components_not_symlinks(&base, &link.join("cache")) - .expect_err("must reject symlink path"); - assert!(err.to_string().contains("symlink")); - } -} diff --git a/src/main.rs b/src/main.rs index db7760b..19dcf56 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use std::ffi::{OsStr, OsString}; mod args; mod auth; +mod bt_dir; #[allow(dead_code)] mod config; mod env; @@ -13,10 +14,9 @@ mod experiments; mod functions; mod http; mod init; -mod js_runner; mod projects; mod prompts; -mod python_runner; +mod runner; mod scorers; mod self_update; mod setup; diff --git a/src/runner/js.rs b/src/runner/js.rs new file mode 100644 index 0000000..58d454a --- /dev/null +++ b/src/runner/js.rs @@ -0,0 +1,150 @@ +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub fn build_js_runner_command( + runner_override: Option<&str>, + runner_script: &Path, + files: &[PathBuf], +) -> Command { + if let Some(explicit) = runner_override { + let resolved = resolve_js_runner_command(explicit, files); + if is_deno_runner_path(&resolved) { + return build_deno_command(resolved.as_os_str(), runner_script, files); + } + + let mut command = Command::new(&resolved); + command.arg(runner_script); + for file in files { + command.arg(file); + } + return command; + } + + if let Some(auto_runner) = find_js_runner_binary(files) { + if is_deno_runner_path(&auto_runner) { + return build_deno_command(auto_runner.as_os_str(), runner_script, files); + } + + let mut command = Command::new(&auto_runner); + command.arg(runner_script); + for file in files { + command.arg(file); + } + return command; + } + + let mut command = Command::new("npx"); + command.arg("--yes").arg("tsx").arg(runner_script); + for file in files { + command.arg(file); + } + command +} + +pub fn find_js_runner_binary(files: &[PathBuf]) -> Option { + const CANDIDATES: &[&str] = &["tsx", "vite-node", "ts-node", "ts-node-esm", "deno"]; + + for candidate in CANDIDATES { + if let Some(path) = find_node_module_bin_for_files(candidate, files) { + return Some(path); + } + } + + super::find_binary_in_path(CANDIDATES) +} + +pub fn resolve_js_runner_command(runner: &str, files: &[PathBuf]) -> PathBuf { + if is_path_like_runner(runner) { + return PathBuf::from(runner); + } + + find_node_module_bin_for_files(runner, files) + .or_else(|| super::find_binary_in_path(&[runner])) + .unwrap_or_else(|| PathBuf::from(runner)) +} + +fn build_deno_command(deno_runner: &OsStr, runner_script: &Path, files: &[PathBuf]) -> Command { + let mut command = Command::new(deno_runner); + command + .arg("run") + .arg("-A") + .arg("--node-modules-dir=auto") + .arg("--unstable-detect-cjs") + .arg(runner_script); + for file in files { + command.arg(file); + } + command +} + +fn is_path_like_runner(runner: &str) -> bool { + let path = Path::new(runner); + path.is_absolute() || runner.contains('/') || runner.contains('\\') || runner.starts_with('.') +} + +fn is_deno_runner_path(runner: &Path) -> bool { + runner + .file_name() + .and_then(|value| value.to_str()) + .map(|name| name.eq_ignore_ascii_case("deno") || name.eq_ignore_ascii_case("deno.exe")) + .unwrap_or(false) +} + +fn find_node_module_bin_for_files(binary: &str, files: &[PathBuf]) -> Option { + for root in js_runner_search_roots(files) { + if let Some(path) = find_node_module_bin(binary, &root) { + return Some(path); + } + } + None +} + +fn js_runner_search_roots(files: &[PathBuf]) -> Vec { + let mut roots = Vec::new(); + if let Ok(cwd) = std::env::current_dir() { + roots.push(cwd.clone()); + for file in files { + let absolute = if file.is_absolute() { + file.clone() + } else { + cwd.join(file) + }; + if let Some(parent) = absolute.parent() { + roots.push(parent.to_path_buf()); + } + } + } + roots +} + +fn find_node_module_bin(binary: &str, start: &Path) -> Option { + let mut current = Some(start); + while let Some(dir) = current { + let base = dir.join("node_modules").join(".bin").join(binary); + if base.is_file() { + return Some(base); + } + if cfg!(windows) { + for candidate in super::with_windows_extensions(&base) { + if candidate.is_file() { + return Some(candidate); + } + } + } + current = dir.parent(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_like_runner_detection() { + assert!(is_path_like_runner("./tsx")); + assert!(is_path_like_runner("bin/tsx")); + assert!(!is_path_like_runner("tsx")); + } +} diff --git a/src/runner/mod.rs b/src/runner/mod.rs new file mode 100644 index 0000000..a46850c --- /dev/null +++ b/src/runner/mod.rs @@ -0,0 +1,139 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; + +pub mod js; +pub mod py; + +pub(crate) fn materialize_runner_script_in_cwd(file_name: &str, source: &str) -> Result { + let cwd = std::env::current_dir().context("failed to resolve current working directory")?; + materialize_runner_script_in_root(&cwd, file_name, source) +} + +pub(crate) fn materialize_runner_script_in_root( + root: &Path, + file_name: &str, + source: &str, +) -> Result { + let cache_dir = crate::bt_dir::runners_cache_dir(root); + ensure_descendant_components_not_symlinks(root, &cache_dir)?; + materialize_runner_script(&cache_dir, file_name, source) +} + +fn materialize_runner_script(cache_dir: &Path, file_name: &str, source: &str) -> Result { + std::fs::create_dir_all(cache_dir).with_context(|| { + format!( + "failed to create runner cache directory {}", + cache_dir.display() + ) + })?; + ensure_not_symlink(cache_dir)?; + + let path = cache_dir.join(file_name); + ensure_not_symlink(&path)?; + let current = std::fs::read_to_string(&path).ok(); + if current.as_deref() != Some(source) { + crate::utils::write_text_atomic(&path, source) + .with_context(|| format!("failed to write runner script {}", path.display()))?; + } + Ok(path) +} + +pub(crate) fn find_binary_in_path(candidates: &[&str]) -> Option { + let paths = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&paths) { + for candidate in candidates { + let path = dir.join(candidate); + if path.is_file() { + return Some(path); + } + if cfg!(windows) { + for candidate_path in with_windows_extensions(&path) { + if candidate_path.is_file() { + return Some(candidate_path); + } + } + } + } + } + None +} + +fn ensure_descendant_components_not_symlinks(base: &Path, descendant: &Path) -> Result<()> { + let Ok(relative) = descendant.strip_prefix(base) else { + return Ok(()); + }; + + let mut current = base.to_path_buf(); + for component in relative.components() { + current.push(component.as_os_str()); + let metadata = match std::fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => break, + Err(err) => { + return Err(err).with_context(|| { + format!("failed to inspect path component {}", current.display()) + }) + } + }; + if metadata.file_type().is_symlink() { + bail!( + "refusing to write runner script through symlink path component {}", + current.display() + ); + } + } + Ok(()) +} + +fn ensure_not_symlink(path: &Path) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + bail!( + "refusing to write runner script via symlink {}", + path.display() + ); + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(err) + .with_context(|| format!("failed to inspect runner path {}", path.display())) + } + } + Ok(()) +} + +#[cfg(windows)] +pub(crate) fn with_windows_extensions(path: &Path) -> [PathBuf; 2] { + [path.with_extension("exe"), path.with_extension("cmd")] +} + +#[cfg(not(windows))] +pub(crate) fn with_windows_extensions(_path: &Path) -> [PathBuf; 0] { + [] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn descendant_symlink_check_rejects_symlinked_component() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().expect("tempdir"); + let base = dir.path().join("base"); + let real = dir.path().join("real"); + std::fs::create_dir_all(&base).expect("create base directory"); + std::fs::create_dir_all(&real).expect("create real directory"); + let link = base.join("link"); + symlink(&real, &link).expect("create symlink"); + + let err = ensure_descendant_components_not_symlinks(&base, &link.join("cache")) + .expect_err("must reject symlink path"); + assert!(err.to_string().contains("symlink")); + } +} diff --git a/src/python_runner.rs b/src/runner/py.rs similarity index 68% rename from src/python_runner.rs rename to src/runner/py.rs index 3f6c92b..38d52d9 100644 --- a/src/python_runner.rs +++ b/src/runner/py.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::PathBuf; pub fn resolve_python_interpreter( explicit: Option<&str>, @@ -42,34 +42,7 @@ fn find_virtual_env_python() -> Option { } pub fn find_binary_in_path(candidates: &[&str]) -> Option { - let paths = std::env::var_os("PATH")?; - for dir in std::env::split_paths(&paths) { - for candidate in candidates { - let path = dir.join(candidate); - if path.is_file() { - return Some(path); - } - if cfg!(windows) { - let exe = with_windows_extensions(&path); - for candidate_path in exe { - if candidate_path.is_file() { - return Some(candidate_path); - } - } - } - } - } - None -} - -#[cfg(windows)] -fn with_windows_extensions(path: &Path) -> [PathBuf; 2] { - [path.with_extension("exe"), path.with_extension("cmd")] -} - -#[cfg(not(windows))] -fn with_windows_extensions(_path: &Path) -> [PathBuf; 0] { - [] + super::find_binary_in_path(candidates) } #[cfg(test)] diff --git a/src/setup/agent_stream.rs b/src/setup/agent_stream.rs index 886942d..1290dc0 100644 --- a/src/setup/agent_stream.rs +++ b/src/setup/agent_stream.rs @@ -407,8 +407,10 @@ pub async fn stream_agent_output( display.finish(); if let Some(result) = result_json { - let result_path = repo_root.join(".bt").join("last_instrument.json"); + let state_dir = crate::bt_dir::state_dir(repo_root); + let result_path = state_dir.join("last_instrument.json"); if let Ok(json) = serde_json::to_string_pretty(&result) { + let _ = std::fs::create_dir_all(&state_dir); let _ = std::fs::write(&result_path, json); } } diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 37f4ed9..6c375b1 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -757,7 +757,8 @@ async fn select_project_with_skip( /// Returns `true` if config was written or already matched, `false` if user declined. fn maybe_init(org: &str, project: &crate::projects::api::Project) -> Result { - let config_path = std::env::current_dir()?.join(".bt").join("config.json"); + let cwd = std::env::current_dir()?; + let config_path = crate::bt_dir::config_path(&cwd); if config_path.exists() { let mut existing = config::load_file(&config_path); @@ -770,7 +771,7 @@ fn maybe_init(org: &str, project: &crate::projects::api::Project) -> Result Result { let root = scope_root(scope, local_root, home)?; - Ok(root.join(".bt").join("skills").join("docs")) + Ok(crate::bt_dir::skills_docs_dir(root)) } InstallScope::Global => Ok(global_bt_config_dir(home).join("skills").join("docs")), } diff --git a/tests/eval_fixtures.rs b/tests/eval_fixtures.rs index 84c63a6..54f20ba 100644 --- a/tests/eval_fixtures.rs +++ b/tests/eval_fixtures.rs @@ -447,7 +447,7 @@ fn collect_deno_eval_diagnostics(dir: &Path, files: &[String]) -> Option let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let runner_script = root.join("scripts").join("eval-runner.ts"); - let local_runner_dir = dir.join(".bt").join("eval-runners"); + let local_runner_dir = dir.join(".bt").join("cache").join("runners"); fs::create_dir_all(&local_runner_dir).ok()?; let local_runner = local_runner_dir.join("diag-eval-runner.ts"); fs::copy(&runner_script, &local_runner).ok()?; diff --git a/tests/init.rs b/tests/init.rs new file mode 100644 index 0000000..34a9da4 --- /dev/null +++ b/tests/init.rs @@ -0,0 +1,204 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use tempfile::tempdir; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn bt_binary_path() -> PathBuf { + if let Ok(path) = std::env::var("CARGO_BIN_EXE_bt") { + return PathBuf::from(path); + } + + let root = repo_root(); + let candidate = root.join("target").join("debug").join("bt"); + if !candidate.is_file() { + let status = Command::new("cargo") + .args(["build", "--bin", "bt"]) + .current_dir(&root) + .status() + .expect("cargo build --bin bt"); + assert!(status.success(), "cargo build --bin bt failed"); + } + candidate +} + +fn run_git(cwd: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .expect("run git"); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + panic!( + "git command failed in {}: git {}\n{}", + cwd.display(), + args.join(" "), + stderr.trim() + ); + } +} + +fn git_check_ignore(cwd: &Path, path: &str) -> bool { + let status = Command::new("git") + .args(["check-ignore", "-q", path]) + .current_dir(cwd) + .status() + .expect("git check-ignore"); + status.success() +} + +#[test] +fn init_creates_config_and_bt_gitignore_with_expected_tracking() { + let tmp = tempdir().expect("tempdir"); + run_git(tmp.path(), &["init"]); + + let output = Command::new(bt_binary_path()) + .args(["init", "--no-input", "--org", "acme", "--project", "my-app"]) + .current_dir(tmp.path()) + .output() + .expect("run bt init"); + assert!( + output.status.success(), + "bt init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let config = tmp.path().join(".bt").join("config.json"); + assert!(config.is_file(), "expected {}", config.display()); + + let gitignore = tmp.path().join(".bt").join(".gitignore"); + let gitignore_contents = std::fs::read_to_string(&gitignore).expect("read .bt/.gitignore"); + assert!( + gitignore_contents.starts_with( + "# BEGIN bt-managed\n*\n!config.json\n!.gitignore\n!skills/\n!skills/**\n# END bt-managed\n" + ), + "unexpected managed block:\n{}", + gitignore_contents + ); + + let skills_doc = tmp.path().join(".bt/skills/docs/reference/sql.md"); + std::fs::create_dir_all(skills_doc.parent().unwrap()).expect("create skills docs dir"); + std::fs::write(&skills_doc, "docs").expect("write skills doc"); + + let cache_file = tmp.path().join(".bt/cache/runners/v1/functions-runner.ts"); + std::fs::create_dir_all(cache_file.parent().unwrap()).expect("create cache dir"); + std::fs::write(&cache_file, "runner").expect("write cache file"); + + assert!(!git_check_ignore(tmp.path(), ".bt/config.json")); + assert!(!git_check_ignore( + tmp.path(), + ".bt/skills/docs/reference/sql.md" + )); + assert!(git_check_ignore( + tmp.path(), + ".bt/cache/runners/v1/functions-runner.ts" + )); +} + +#[test] +fn init_repairs_missing_bt_gitignore_when_config_exists() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join(".bt")).expect("create .bt"); + std::fs::write( + tmp.path().join(".bt").join("config.json"), + "{ \"org\": \"acme\", \"project\": \"my-app\" }\n", + ) + .expect("write config.json"); + + let output = Command::new(bt_binary_path()) + .args(["init", "--no-input"]) + .current_dir(tmp.path()) + .output() + .expect("run bt init"); + assert!( + output.status.success(), + "bt init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + tmp.path().join(".bt").join(".gitignore").is_file(), + "expected .bt/.gitignore to be created" + ); +} + +#[test] +fn init_preserves_custom_bt_gitignore_rules_and_moves_managed_block_to_top() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join(".bt")).expect("create .bt"); + std::fs::write( + tmp.path().join(".bt").join("config.json"), + "{ \"org\": \"legacy-org\", \"project\": \"legacy-project\" }\n", + ) + .expect("write config"); + + let existing = + "custom-before\n\n# BEGIN bt-managed\nold-rule\n# END bt-managed\n\ncustom-after\n"; + std::fs::write(tmp.path().join(".bt").join(".gitignore"), existing).expect("write .gitignore"); + + let output = Command::new(bt_binary_path()) + .args(["init", "--no-input"]) + .current_dir(tmp.path()) + .output() + .expect("run bt init"); + assert!( + output.status.success(), + "bt init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let gitignore = std::fs::read_to_string(tmp.path().join(".bt").join(".gitignore")) + .expect("read .gitignore"); + assert!( + gitignore.starts_with("# BEGIN bt-managed\n"), + "managed block should be first:\n{gitignore}" + ); + assert_eq!(gitignore.matches("# BEGIN bt-managed").count(), 1); + assert_eq!(gitignore.matches("# END bt-managed").count(), 1); + assert!(gitignore.contains("custom-before")); + assert!(gitignore.contains("custom-after")); + + let end_pos = gitignore + .find("# END bt-managed") + .expect("managed block end"); + assert!( + gitignore.find("custom-before").unwrap() > end_pos, + "custom-before should be after managed block:\n{gitignore}" + ); + assert!( + gitignore.find("custom-after").unwrap() > end_pos, + "custom-after should be after managed block:\n{gitignore}" + ); +} + +#[test] +fn status_reads_local_config() { + let tmp = tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join(".bt")).expect("create .bt"); + std::fs::write( + tmp.path().join(".bt").join("config.json"), + "{ \"org\": \"test-org\", \"project\": \"test-project\" }\n", + ) + .expect("write config"); + + let output = Command::new(bt_binary_path()) + .args(["status", "--json"]) + .current_dir(tmp.path()) + .output() + .expect("run bt status --json"); + assert!( + output.status.success(), + "bt status failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("parse status json"); + assert_eq!(json.get("org").and_then(|v| v.as_str()), Some("test-org")); + assert_eq!( + json.get("project").and_then(|v| v.as_str()), + Some("test-project") + ); +}