From 809d5239e6a77e5674825896e4719f67b535be59 Mon Sep 17 00:00:00 2001 From: Sarvesh Date: Wed, 20 May 2026 11:10:23 +0530 Subject: [PATCH 1/4] feat: add configurable discovery cache directory --- crates/tryke/src/cli.rs | 40 ++++++++++ crates/tryke/src/discovery.rs | 39 +++++++++- crates/tryke/src/graph.rs | 25 ++++++- crates/tryke/src/main.rs | 61 ++++++++++++--- crates/tryke/src/watch.rs | 3 +- crates/tryke_config/src/lib.rs | 94 ++++++++++++++++++++++++ crates/tryke_discovery/src/cache.rs | 89 +++++++++++++++++++--- crates/tryke_discovery/src/discoverer.rs | 83 +++++++++++++++++++-- crates/tryke_server/src/server.rs | 17 ++++- docs/guides/configuration.md | 24 +++++- docs/reference/cli.md | 24 ++++++ 11 files changed, 465 insertions(+), 34 deletions(-) diff --git a/crates/tryke/src/cli.rs b/crates/tryke/src/cli.rs index 37ad4f8..c939884 100644 --- a/crates/tryke/src/cli.rs +++ b/crates/tryke/src/cli.rs @@ -51,6 +51,13 @@ pub struct Cli { /// this flag in CI or in terminals that mis-render the sequence. #[arg(long = "no-progress", global = true)] pub no_progress: bool, + + /// Directory for tryke's persistent discovery cache. + /// + /// Overrides `[tool.tryke] cache_dir` in `pyproject.toml`. Defaults to + /// `/.tryke/cache`. + #[arg(long = "cache-dir", global = true)] + pub cache_dir: Option, } /// Reporter format used to render test results. @@ -367,3 +374,36 @@ impl Commands { } } } + +#[cfg(test)] +mod tests { + use std::path::Path; + + use clap::Parser; + + use super::*; + + #[test] + fn parses_global_cache_dir_before_subcommand() { + let cli = Cli::parse_from(["tryke", "--cache-dir", "/tmp/tryke-cache", "test"]); + assert_eq!( + cli.cache_dir.as_deref(), + Some(Path::new("/tmp/tryke-cache")) + ); + } + + #[test] + fn parses_global_cache_dir_after_subcommand() { + let cli = Cli::parse_from(["tryke", "test", "--cache-dir", "/tmp/tryke-cache"]); + assert_eq!( + cli.cache_dir.as_deref(), + Some(Path::new("/tmp/tryke-cache")) + ); + } + + #[test] + fn cache_dir_defaults_to_none() { + let cli = Cli::parse_from(["tryke", "test"]); + assert_eq!(cli.cache_dir, None); + } +} diff --git a/crates/tryke/src/discovery.rs b/crates/tryke/src/discovery.rs index 3cdd8db..e8bac0a 100644 --- a/crates/tryke/src/discovery.rs +++ b/crates/tryke/src/discovery.rs @@ -88,7 +88,18 @@ pub fn discover_tests( base_branch: Option<&str>, excludes: &[String], ) -> DiscoverySelection { - let mut discoverer = Discoverer::new_with_excludes(root, excludes); + discover_tests_with_cache_dir(root, changed, base_branch, excludes, None) +} + +/// Discover tests using an optional cache directory override. +pub fn discover_tests_with_cache_dir( + root: &Path, + changed: bool, + base_branch: Option<&str>, + excludes: &[String], + cache_dir: Option<&Path>, +) -> DiscoverySelection { + let mut discoverer = Discoverer::new_with_excludes_and_cache_dir(root, excludes, cache_dir); discoverer.rediscover(); let warnings = all_discovery_warnings(&discoverer); let hooks = discoverer.hooks(); @@ -147,16 +158,26 @@ pub fn discover_tests_for_paths( root: &Path, path_specs: &[PathSpec], excludes: &[String], +) -> DiscoverySelection { + discover_tests_for_paths_with_cache_dir(root, path_specs, excludes, None) +} + +/// Discover tests for explicit paths using an optional cache directory override. +pub fn discover_tests_for_paths_with_cache_dir( + root: &Path, + path_specs: &[PathSpec], + excludes: &[String], + cache_dir: Option<&Path>, ) -> DiscoverySelection { let walk_roots = match resolve_walk_roots(root, path_specs) { Some(roots) => roots, None => { debug!("discover_tests_for_paths: falling back to full discovery"); - return discover_tests(root, false, None, excludes); + return discover_tests_with_cache_dir(root, false, None, excludes, cache_dir); } }; - let mut discoverer = Discoverer::new_with_excludes(root, excludes); + let mut discoverer = Discoverer::new_with_excludes_and_cache_dir(root, excludes, cache_dir); let tests = discoverer.rediscover_restricted(&walk_roots); let warnings = all_discovery_warnings(&discoverer); let hooks = discoverer.hooks(); @@ -223,7 +244,17 @@ pub fn discover_tests_changed_first( base_branch: Option<&str>, excludes: &[String], ) -> DiscoverySelection { - let mut discoverer = Discoverer::new_with_excludes(root, excludes); + discover_tests_changed_first_with_cache_dir(root, base_branch, excludes, None) +} + +/// Discover all tests with changed tests first using an optional cache directory override. +pub fn discover_tests_changed_first_with_cache_dir( + root: &Path, + base_branch: Option<&str>, + excludes: &[String], + cache_dir: Option<&Path>, +) -> DiscoverySelection { + let mut discoverer = Discoverer::new_with_excludes_and_cache_dir(root, excludes, cache_dir); discoverer.rediscover(); let warnings = all_discovery_warnings(&discoverer); let hooks = discoverer.hooks(); diff --git a/crates/tryke/src/graph.rs b/crates/tryke/src/graph.rs index 9a0d605..83ac2c1 100644 --- a/crates/tryke/src/graph.rs +++ b/crates/tryke/src/graph.rs @@ -13,10 +13,22 @@ pub fn run_graph( connected_only: bool, changed: bool, base_branch: Option<&str>, +) -> Result<()> { + run_graph_with_cache_dir(root, excludes, connected_only, changed, base_branch, None) +} + +pub fn run_graph_with_cache_dir( + root: Option<&Path>, + excludes: &[String], + connected_only: bool, + changed: bool, + base_branch: Option<&str>, + cache_dir: Option<&Path>, ) -> Result<()> { let cwd = std::env::current_dir()?; let root_path = root.unwrap_or(&cwd); - let mut discoverer = Discoverer::new_with_excludes(root_path, excludes); + let mut discoverer = + Discoverer::new_with_excludes_and_cache_dir(root_path, excludes, cache_dir); discoverer.rediscover(); let changed_files = if changed { @@ -102,9 +114,18 @@ pub fn run_graph( /// discovered module) are printed with a `?` suffix so users can spot /// typos or missing fixtures without reading through test output. pub fn run_fixture_graph(root: Option<&Path>, excludes: &[String]) -> Result<()> { + run_fixture_graph_with_cache_dir(root, excludes, None) +} + +pub fn run_fixture_graph_with_cache_dir( + root: Option<&Path>, + excludes: &[String], + cache_dir: Option<&Path>, +) -> Result<()> { let cwd = std::env::current_dir()?; let root_path = root.unwrap_or(&cwd); - let mut discoverer = Discoverer::new_with_excludes(root_path, excludes); + let mut discoverer = + Discoverer::new_with_excludes_and_cache_dir(root_path, excludes, cache_dir); discoverer.rediscover(); let hooks = discoverer.hooks(); diff --git a/crates/tryke/src/main.rs b/crates/tryke/src/main.rs index 11deacb..5140bc8 100644 --- a/crates/tryke/src/main.rs +++ b/crates/tryke/src/main.rs @@ -5,10 +5,11 @@ use clap::Parser; use log::debug; use tryke::cli::{Cli, Commands, ReporterFormat}; use tryke::discovery::{ - discover_tests, discover_tests_changed_first, discover_tests_for_paths, resolved_excludes, + discover_tests_changed_first_with_cache_dir, discover_tests_for_paths_with_cache_dir, + discover_tests_with_cache_dir, resolved_excludes, }; use tryke::execution::run_tests; -use tryke::graph::{run_fixture_graph, run_graph}; +use tryke::graph::{run_fixture_graph_with_cache_dir, run_graph_with_cache_dir}; use tryke::watch::run_watch; use tryke_reporter::{ DotReporter, JSONReporter, JUnitReporter, LlmReporter, NextReporter, ProgressReporter, @@ -103,6 +104,7 @@ fn main() -> Result<()> { let verbosity = Verbosity::from_level_filter(rust_default); let rt = tokio::runtime::Runtime::new()?; + let cache_dir = cli.cache_dir.clone(); let effective = effective_command(cli.command); let command = effective.as_command(); let bare_watch = effective.is_bare_watch(); @@ -150,6 +152,8 @@ fn main() -> Result<()> { .map_err(|e| anyhow::anyhow!(e))?; let config = tryke_config::load_effective_config(root_path); let resolved_python = tryke_config::resolve_python(python.as_deref(), &config); + let resolved_cache_dir = + tryke_config::resolve_cache_dir(cache_dir.as_deref(), &config); return rt.block_on(run_watch( &mut *rep, Some(root_path), @@ -162,9 +166,15 @@ fn main() -> Result<()> { (*dist).into(), *all, *now, + resolved_cache_dir.as_deref(), )); } if let Some(p) = port { + if cache_dir.is_some() { + return Err(anyhow::anyhow!( + "--cache-dir is not supported with --port; start the server with --cache-dir instead" + )); + } if !exclude.is_empty() { return Err(anyhow::anyhow!( "--exclude is not supported with --port; start the server with --exclude instead" @@ -185,6 +195,8 @@ fn main() -> Result<()> { let excludes = resolved_excludes(root_path, exclude, include); let test_filter = TestFilter::from_args(paths, filter.as_deref(), markers.as_deref()) .map_err(|e| anyhow::anyhow!(e))?; + let config = tryke_config::load_effective_config(root_path); + let resolved_cache_dir = tryke_config::resolve_cache_dir(cache_dir.as_deref(), &config); let discovery_start = Instant::now(); // Fast path: explicit paths without change-based selection @@ -192,11 +204,27 @@ fn main() -> Result<()> { // post-filter (`test_filter.apply` below) still runs to // honor `:line` specs, `--filter`, and `--markers`. let discovered = if !paths.is_empty() && !*changed && !*changed_first { - discover_tests_for_paths(root_path, &test_filter.path_specs, &excludes) + discover_tests_for_paths_with_cache_dir( + root_path, + &test_filter.path_specs, + &excludes, + resolved_cache_dir.as_deref(), + ) } else if *changed_first { - discover_tests_changed_first(root_path, base_branch.as_deref(), &excludes) + discover_tests_changed_first_with_cache_dir( + root_path, + base_branch.as_deref(), + &excludes, + resolved_cache_dir.as_deref(), + ) } else { - discover_tests(root_path, *changed, base_branch.as_deref(), &excludes) + discover_tests_with_cache_dir( + root_path, + *changed, + base_branch.as_deref(), + &excludes, + resolved_cache_dir.as_deref(), + ) }; for warning in &discovered.warnings { rep.on_discovery_warning(warning); @@ -215,7 +243,6 @@ fn main() -> Result<()> { rep.on_collect_complete(&tests); Ok(()) } else { - let config = tryke_config::load_effective_config(root_path); let resolved_python = tryke_config::resolve_python(python.as_deref(), &config); let summary = rt.block_on(run_tests( &mut *rep, @@ -247,8 +274,15 @@ fn main() -> Result<()> { let excludes = resolved_excludes(&root_path, exclude, include); let config = tryke_config::load_effective_config(&root_path); let resolved_python = tryke_config::resolve_python(python.as_deref(), &config); - let server = - tryke_server::Server::new(*port, root_path, excludes, resolved_python, worker_log); + let resolved_cache_dir = tryke_config::resolve_cache_dir(cache_dir.as_deref(), &config); + let server = tryke_server::Server::new_with_cache_dir( + *port, + root_path, + excludes, + resolved_python, + worker_log, + resolved_cache_dir, + ); rt.block_on(server.run()) } Commands::Graph { @@ -266,15 +300,22 @@ fn main() -> Result<()> { let cwd = env::current_dir()?; let root_path = root.as_deref().unwrap_or(&cwd); let excludes = resolved_excludes(root_path, exclude, include); + let config = tryke_config::load_effective_config(root_path); + let resolved_cache_dir = tryke_config::resolve_cache_dir(cache_dir.as_deref(), &config); if *fixtures { - run_fixture_graph(Some(root_path), &excludes) + run_fixture_graph_with_cache_dir( + Some(root_path), + &excludes, + resolved_cache_dir.as_deref(), + ) } else { - run_graph( + run_graph_with_cache_dir( Some(root_path), &excludes, *connected_only, *changed, base_branch.as_deref(), + resolved_cache_dir.as_deref(), ) } } diff --git a/crates/tryke/src/watch.rs b/crates/tryke/src/watch.rs index 886de8a..104cc2f 100644 --- a/crates/tryke/src/watch.rs +++ b/crates/tryke/src/watch.rs @@ -199,10 +199,11 @@ pub async fn run_watch( dist: DistMode, all_tests: bool, run_now: bool, + cache_dir: Option<&Path>, ) -> Result<()> { let cwd = std::env::current_dir()?; let root = root.unwrap_or(&cwd); - let mut discoverer = Discoverer::new_with_excludes(root, excludes); + let mut discoverer = Discoverer::new_with_excludes_and_cache_dir(root, excludes, cache_dir); let pool_size = workers.unwrap_or_else(worker_pool_size); let pool = WorkerPool::new(pool_size, python, root, log_level); diff --git a/crates/tryke_config/src/lib.rs b/crates/tryke_config/src/lib.rs index 0f59e55..de41233 100644 --- a/crates/tryke_config/src/lib.rs +++ b/crates/tryke_config/src/lib.rs @@ -31,6 +31,10 @@ pub struct TrykeConfig { /// `None` means fall back to `python` on Windows / `python3` on Unix /// (per `default_python()`). pub python: Option, + /// Directory for tryke's persistent discovery cache. + /// + /// `None` means use the default `/.tryke/cache` path. + pub cache_dir: Option, } impl TrykeConfig { @@ -44,6 +48,7 @@ impl TrykeConfig { src: config.src.unwrap_or_else(|| vec![".".into()]), }, python: config.python, + cache_dir: config.cache_dir, }) }) } @@ -91,6 +96,12 @@ pub fn load_effective_config(start: &Path) -> TrykeConfig { config.python = Some(root.join(path).to_string_lossy().into_owned()); } } + if let Some(cache_dir) = config.cache_dir.as_deref() { + let has_prefix = matches!(cache_dir.components().next(), Some(Component::Prefix(_))); + if !cache_dir.is_absolute() && !cache_dir.has_root() && !has_prefix { + config.cache_dir = Some(root.join(cache_dir)); + } + } config } @@ -116,6 +127,17 @@ pub fn resolve_python(cli_override: Option<&str>, config: &TrykeConfig) -> Strin .unwrap_or_else(|| default_python().to_owned()) } +/// Resolve the discovery cache directory. +/// +/// Precedence: CLI override > `[tool.tryke] cache_dir` in `pyproject.toml` > +/// `None`, which tells discovery to use its default `/.tryke/cache`. +#[must_use] +pub fn resolve_cache_dir(cli_override: Option<&Path>, config: &TrykeConfig) -> Option { + cli_override + .map(Path::to_path_buf) + .or_else(|| config.cache_dir.clone()) +} + /// Default `RUST_LOG`-style filter directive when `RUST_LOG` is unset. /// /// `env_logger` honors `RUST_LOG` natively for fine-grained per-module @@ -174,6 +196,7 @@ struct RawTrykeConfig { exclude: Option>, src: Option>, python: Option, + cache_dir: Option, } #[cfg(test)] @@ -201,6 +224,7 @@ mod tests { src: vec![".".into()], }, python: None, + cache_dir: None, }) ); } @@ -216,6 +240,7 @@ mod tests { src: vec![".".into()], }, python: None, + cache_dir: None, }) ); } @@ -246,6 +271,19 @@ mod tests { assert_eq!(config.python, None); } + #[test] + fn parses_cache_dir_path() { + let config = TrykeConfig::from_toml_str("[tool.tryke]\ncache_dir = \".cache/tryke\"\n") + .expect("some"); + assert_eq!(config.cache_dir.as_deref(), Some(Path::new(".cache/tryke"))); + } + + #[test] + fn cache_dir_defaults_to_none() { + let config = TrykeConfig::from_toml_str("[tool.tryke]\n").expect("some"); + assert_eq!(config.cache_dir, None); + } + #[test] fn returns_none_when_no_tryke_section_exists() { let config = TrykeConfig::from_toml_str("[project]\nname = \"app\"\n"); @@ -338,6 +376,32 @@ mod tests { assert_eq!(config.python.as_deref(), Some("/usr/bin/python3.13")); } + #[test] + fn load_effective_config_resolves_relative_cache_dir_against_config_root() { + let dir = tempdir(); + fs::write( + dir.path().join("pyproject.toml"), + "[tool.tryke]\ncache_dir = \".cache/tryke\"\n", + ) + .expect("write pyproject"); + let nested = dir.path().join("subdir"); + fs::create_dir_all(&nested).expect("create nested"); + let config = load_effective_config(&nested); + assert_eq!(config.cache_dir, Some(dir.path().join(".cache/tryke"))); + } + + #[test] + fn load_effective_config_leaves_absolute_cache_dir_unchanged() { + let dir = tempdir(); + fs::write( + dir.path().join("pyproject.toml"), + "[tool.tryke]\ncache_dir = \"/tmp/tryke-cache\"\n", + ) + .expect("write pyproject"); + let config = load_effective_config(dir.path()); + assert_eq!(config.cache_dir, Some(PathBuf::from("/tmp/tryke-cache"))); + } + #[test] fn resolve_python_prefers_cli_override() { let config = TrykeConfig { @@ -363,6 +427,36 @@ mod tests { assert_eq!(resolve_python(None, &config), expected); } + #[test] + fn resolve_cache_dir_prefers_cli_override() { + let config = TrykeConfig { + cache_dir: Some(PathBuf::from("/from/config")), + ..TrykeConfig::default() + }; + assert_eq!( + resolve_cache_dir(Some(Path::new("/from/cli")), &config), + Some(PathBuf::from("/from/cli")) + ); + } + + #[test] + fn resolve_cache_dir_falls_back_to_config() { + let config = TrykeConfig { + cache_dir: Some(PathBuf::from("/from/config")), + ..TrykeConfig::default() + }; + assert_eq!( + resolve_cache_dir(None, &config), + Some(PathBuf::from("/from/config")) + ); + } + + #[test] + fn resolve_cache_dir_defaults_to_none() { + let config = TrykeConfig::default(); + assert_eq!(resolve_cache_dir(None, &config), None); + } + #[test] fn load_effective_config_leaves_bare_executable_name_unchanged() { // `python = "python3"` should resolve via PATH, not be rewritten diff --git a/crates/tryke_discovery/src/cache.rs b/crates/tryke_discovery/src/cache.rs index 38c0ee6..c76c964 100644 --- a/crates/tryke_discovery/src/cache.rs +++ b/crates/tryke_discovery/src/cache.rs @@ -77,12 +77,48 @@ pub struct DiskCache { /// The path we loaded from / will save to. `None` disables I/O /// (used by tests that don't want a filesystem footprint). path: Option, + /// Best-effort `.gitignore` to create if one does not exist. + gitignore: Option, +} + +#[derive(Debug)] +struct GitignoreConfig { + dir: PathBuf, + contents: &'static str, } impl DiskCache { /// Load a cache from `path`. Returns an empty cache if the file is /// missing, corrupted, or has a mismatched `CACHE_VERSION`. pub fn load(path: PathBuf) -> Self { + let gitignore = path + .parent() + .and_then(Path::parent) + .map(|dir| GitignoreConfig { + dir: dir.to_path_buf(), + contents: "# created by tryke\n*\n", + }); + Self::load_with_gitignore(path, gitignore) + } + + /// Load the standard discovery cache file inside `cache_dir`. + /// + /// `cache_dir` is also the directory that receives the best-effort + /// `.gitignore`, matching user intent for a custom cache location. Unlike + /// the default `.tryke` state directory, this writes narrow patterns only: + /// a user-provided cache directory may be an existing project directory. + pub fn load_in_dir(cache_dir: PathBuf) -> Self { + let path = cache_dir.join("discovery-v1.bin"); + Self::load_with_gitignore( + path, + Some(GitignoreConfig { + dir: cache_dir, + contents: "# created by tryke\n/discovery-v1.bin\n/discovery-v1.tmp\n", + }), + ) + } + + fn load_with_gitignore(path: PathBuf, gitignore: Option) -> Self { let entries = match Self::try_load(&path) { Ok(entries) => entries, Err(err) => { @@ -98,6 +134,7 @@ impl DiskCache { Self { entries, path: Some(path), + gitignore, } } @@ -147,16 +184,15 @@ impl DiskCache { }; if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; - // Drop a `.gitignore` at the top of the tryke state dir - // (parent-of-parent of the cache file, since the layout is - // `.tryke/cache/discovery-v1.bin`) so users don't have to - // remember to gitignore tryke's internal cache. Best-effort - // — a write failure here shouldn't abort the save. - if let Some(state_dir) = parent.parent() { - let gitignore = state_dir.join(".gitignore"); - if !gitignore.exists() { - let _ = fs::write(&gitignore, "# created by tryke\n*\n"); - } + } + // Drop a `.gitignore` at the tryke state/cache directory so users + // don't have to remember to ignore tryke's internal cache. Best-effort + // — a write failure here shouldn't abort the save. + if let Some(config) = self.gitignore.as_ref() { + let _ = fs::create_dir_all(&config.dir); + let gitignore = config.dir.join(".gitignore"); + if !gitignore.exists() { + let _ = fs::write(&gitignore, config.contents); } } let file = CacheFile { @@ -196,6 +232,39 @@ mod tests { assert_eq!(reloaded.entries.len(), 0); } + #[test] + fn default_cache_writes_broad_gitignore_in_state_dir() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir + .path() + .join(".tryke") + .join("cache") + .join("discovery-v1.bin"); + let cache = DiskCache::load(path); + + cache.save().expect("save"); + + let gitignore = + fs::read_to_string(dir.path().join(".tryke/.gitignore")).expect("read gitignore"); + assert_eq!(gitignore, "# created by tryke\n*\n"); + } + + #[test] + fn custom_cache_dir_writes_narrow_gitignore() { + let dir = tempfile::tempdir().expect("tempdir"); + let cache_dir = dir.path().join("custom-cache"); + let cache = DiskCache::load_in_dir(cache_dir.clone()); + + cache.save().expect("save"); + + let gitignore = fs::read_to_string(cache_dir.join(".gitignore")).expect("read gitignore"); + assert_eq!( + gitignore, + "# created by tryke\n/discovery-v1.bin\n/discovery-v1.tmp\n" + ); + assert!(!gitignore.lines().any(|line| line.trim() == "*")); + } + #[test] fn roundtrip_with_entry() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/tryke_discovery/src/discoverer.rs b/crates/tryke_discovery/src/discoverer.rs index 6dc1527..bb973ec 100644 --- a/crates/tryke_discovery/src/discoverer.rs +++ b/crates/tryke_discovery/src/discoverer.rs @@ -66,17 +66,53 @@ impl Discoverer { #[must_use] pub fn new(start: &Path) -> Self { let config = tryke_config::load_effective_config(start); - Self::new_with_options(start, &config.discovery.exclude, &config.discovery.src) + Self::new_with_options_and_cache_dir( + start, + &config.discovery.exclude, + &config.discovery.src, + config.cache_dir.as_deref(), + ) } #[must_use] pub fn new_with_excludes(start: &Path, excludes: &[String]) -> Self { - let src = tryke_config::load_effective_config(start).discovery.src; - Self::new_with_options(start, excludes, &src) + let config = tryke_config::load_effective_config(start); + Self::new_with_options_and_cache_dir( + start, + excludes, + &config.discovery.src, + config.cache_dir.as_deref(), + ) + } + + #[must_use] + pub fn new_with_excludes_and_cache_dir( + start: &Path, + excludes: &[String], + cache_dir: Option<&Path>, + ) -> Self { + let config = tryke_config::load_effective_config(start); + let resolved_cache_dir = tryke_config::resolve_cache_dir(cache_dir, &config); + Self::new_with_options_and_cache_dir( + start, + excludes, + &config.discovery.src, + resolved_cache_dir.as_deref(), + ) } #[must_use] pub fn new_with_options(start: &Path, excludes: &[String], src: &[String]) -> Self { + Self::new_with_options_and_cache_dir(start, excludes, src, None) + } + + #[must_use] + pub fn new_with_options_and_cache_dir( + start: &Path, + excludes: &[String], + src: &[String], + cache_dir: Option<&Path>, + ) -> Self { let root = crate::find_project_root(start).unwrap_or_else(|| start.to_path_buf()); let root = root.canonicalize().unwrap_or(root); let src_roots = if src.is_empty() { @@ -84,8 +120,10 @@ impl Discoverer { } else { crate::resolve_src_roots(&root, src) }; - let cache_path = root.join(".tryke").join("cache").join("discovery-v1.bin"); - let cache = DiskCache::load(cache_path); + let cache = cache_dir.map_or_else( + || DiskCache::load(root.join(".tryke").join("cache").join("discovery-v1.bin")), + |dir| DiskCache::load_in_dir(dir.to_path_buf()), + ); Self { db: Database::default(), inputs: HashMap::new(), @@ -718,6 +756,41 @@ mod tests { assert_eq!(second.len(), 2); } + #[test] + fn discoverer_saves_cache_under_custom_cache_dir() { + let source = "@test\ndef test_hello():\n pass\n"; + let dir = make_project(&[("test_example.py", source)]); + let cache_dir = dir.path().join("custom-cache"); + let mut discoverer = + Discoverer::new_with_excludes_and_cache_dir(dir.path(), &[], Some(&cache_dir)); + + discoverer.rediscover(); + + assert!(cache_dir.join("discovery-v1.bin").exists()); + assert!(!dir.path().join(".tryke/cache/discovery-v1.bin").exists()); + } + + #[test] + fn discoverer_saves_cache_under_configured_cache_dir() { + let source = "@test\ndef test_hello():\n pass\n"; + let dir = make_project(&[("test_example.py", source)]); + fs::write( + dir.path().join("pyproject.toml"), + "[tool.tryke]\ncache_dir = \"configured-cache\"\n", + ) + .expect("write pyproject"); + let mut discoverer = Discoverer::new(dir.path()); + + discoverer.rediscover(); + + assert!( + dir.path() + .join("configured-cache/discovery-v1.bin") + .exists() + ); + assert!(!dir.path().join(".tryke/cache/discovery-v1.bin").exists()); + } + #[test] fn discoverer_removes_deleted_file() { let source_a = "@test\ndef test_a():\n pass\n"; diff --git a/crates/tryke_server/src/server.rs b/crates/tryke_server/src/server.rs index 547d1df..3bf9231 100644 --- a/crates/tryke_server/src/server.rs +++ b/crates/tryke_server/src/server.rs @@ -18,6 +18,7 @@ pub struct Server { port: u16, root: PathBuf, excludes: Vec, + cache_dir: Option, python: String, log_level: LevelFilter, } @@ -30,11 +31,24 @@ impl Server { excludes: Vec, python: String, log_level: LevelFilter, + ) -> Self { + Self::new_with_cache_dir(port, root, excludes, python, log_level, None) + } + + #[must_use] + pub fn new_with_cache_dir( + port: u16, + root: PathBuf, + excludes: Vec, + python: String, + log_level: LevelFilter, + cache_dir: Option, ) -> Self { Self { port, root, excludes, + cache_dir, python, log_level, } @@ -72,9 +86,10 @@ impl Server { let dirty = Arc::new(AtomicBool::new(false)); let (bcast_tx, _) = broadcast::channel::(256); - let disc = Arc::new(Mutex::new(Discoverer::new_with_excludes( + let disc = Arc::new(Mutex::new(Discoverer::new_with_excludes_and_cache_dir( &self.root, &self.excludes, + self.cache_dir.as_deref(), ))); disc.lock().await.rediscover(); diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index cebe703..a637cb0 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -51,7 +51,18 @@ python = ".venv/bin/python3" Defaults to `python` on Windows and `python3` on Unix from `PATH`. -**Path resolution.** A value with a path separator (e.g., `.venv/bin/python3`) is treated as a filesystem path; bare names (e.g., `python3`, `pypy`) are looked up via `PATH` exactly like `execvp` / `CreateProcess`. Relative paths are anchored to the directory containing `pyproject.toml`, not the cwd, so `python = ".venv/bin/python3"` keeps working when tryke is invoked from a sibling directory or a script. Absolute paths and Windows drive-relative values (e.g., `C:foo\python.exe`) are passed through unchanged. +**Path resolution.** A value with a path separator (e.g., `.venv/bin/python3`) is treated as a filesystem path; bare names (e.g., `python3`, `pypy`) are looked up via `PATH` exactly like `execvp` / `CreateProcess`. Relative paths are anchored to the directory containing `pyproject.toml`, not the cwd, so `python = ".venv/bin/python3"` keeps working when tryke is invoked from a sibling directory or a script. Absolute paths and Windows drive-relative values (e.g., `C:foo\\python.exe`) are passed through unchanged. + +### `cache_dir` + +Directory for tryke's persistent discovery cache. By default, tryke stores discovery results under `/.tryke/cache`; set `cache_dir` when that location is not suitable (for example, a read-only project checkout or a shared CI cache directory). + +```toml +[tool.tryke] +cache_dir = ".cache/tryke" +``` + +Relative paths are anchored to the directory containing `pyproject.toml`, not the cwd. The command-line `--cache-dir` flag takes precedence for one-off runs. ## CLI overrides @@ -83,6 +94,17 @@ Override the project root (where Tryke looks for `pyproject.toml` and test files tryke test --root /path/to/project ``` +### `--cache-dir` + +Override the discovery cache directory from the command line: + +```bash +tryke --cache-dir /tmp/tryke-cache test +tryke server --cache-dir .cache/tryke +``` + +`--cache-dir` is a global flag and overrides `[tool.tryke] cache_dir`. Relative CLI paths are resolved by the shell/process cwd. + ## Logging Tryke has a single user-facing verbosity knob with a precedence chain spanning CLI flags, environment variables, and cross-language propagation to the python workers it spawns. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 5027616..1bcc264 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -24,6 +24,12 @@ tryke [OPTIONS] [COMMAND] **Options:** +- `--cache-dir` `` + + Directory for tryke's persistent discovery cache. + + Overrides `[tool.tryke] cache_dir` in `pyproject.toml`. Defaults to `/.tryke/cache`. + - `--no-progress` Disable the terminal's native graphical progress bar. @@ -56,6 +62,12 @@ tryke graph [OPTIONS] Base branch for `--changed`. Uses `git merge-base` diff +- `--cache-dir` `` + + Directory for tryke's persistent discovery cache. + + Overrides `[tool.tryke] cache_dir` in `pyproject.toml`. Defaults to `/.tryke/cache`. + - `--changed` Show only the slice affected by changes since `HEAD`. @@ -112,6 +124,12 @@ tryke server [OPTIONS] **Options:** +- `--cache-dir` `` + + Directory for tryke's persistent discovery cache. + + Overrides `[tool.tryke] cache_dir` in `pyproject.toml`. Defaults to `/.tryke/cache`. + - `-e`, `--exclude` `` Exclude files or directories from discovery @@ -199,6 +217,12 @@ tryke test [OPTIONS] [PATHS]... Compares against `git merge-base HEAD` instead of the working tree. Typical CI usage: `--changed --base-branch origin/main`. +- `--cache-dir` `` + + Directory for tryke's persistent discovery cache. + + Overrides `[tool.tryke] cache_dir` in `pyproject.toml`. Defaults to `/.tryke/cache`. + - `--changed` Run only tests affected by uncommitted changes. From 431d1edfce16ee04e21afcf7fc41aec658119f78 Mon Sep 17 00:00:00 2001 From: Justin Chapman Date: Wed, 20 May 2026 23:32:51 -0400 Subject: [PATCH 2/4] remove some redundant methods --- .gitignore | 2 + crates/tryke/src/discovery.rs | 61 ++++++++++--------------------- crates/tryke/src/execution.rs | 10 ++--- crates/tryke/src/graph.rs | 24 +++--------- crates/tryke/src/main.rs | 21 ++++------- crates/tryke/src/watch.rs | 2 +- crates/tryke_server/src/server.rs | 13 +------ 7 files changed, 42 insertions(+), 91 deletions(-) diff --git a/.gitignore b/.gitignore index a54ca67..aba2e69 100644 --- a/.gitignore +++ b/.gitignore @@ -225,3 +225,5 @@ __marimo__/ demo/*.gif demo/*.webm demo/*.mp4 + +.claude/worktrees diff --git a/crates/tryke/src/discovery.rs b/crates/tryke/src/discovery.rs index e8bac0a..242d3d6 100644 --- a/crates/tryke/src/discovery.rs +++ b/crates/tryke/src/discovery.rs @@ -82,21 +82,13 @@ pub fn resolved_excludes( } /// Discover tests, optionally restricting to changed files. +/// +/// `cache_dir` optionally overrides the discovery cache directory. pub fn discover_tests( root: &Path, changed: bool, base_branch: Option<&str>, excludes: &[String], -) -> DiscoverySelection { - discover_tests_with_cache_dir(root, changed, base_branch, excludes, None) -} - -/// Discover tests using an optional cache directory override. -pub fn discover_tests_with_cache_dir( - root: &Path, - changed: bool, - base_branch: Option<&str>, - excludes: &[String], cache_dir: Option<&Path>, ) -> DiscoverySelection { let mut discoverer = Discoverer::new_with_excludes_and_cache_dir(root, excludes, cache_dir); @@ -154,26 +146,19 @@ pub fn discover_tests_with_cache_dir( /// if any spec resolves to a nonexistent file or escapes the project /// root — the existing post-filter (`TestFilter::apply`) still runs in /// `main` and handles suffix-match semantics in that case. +/// +/// `cache_dir` optionally overrides the discovery cache directory. pub fn discover_tests_for_paths( root: &Path, path_specs: &[PathSpec], excludes: &[String], -) -> DiscoverySelection { - discover_tests_for_paths_with_cache_dir(root, path_specs, excludes, None) -} - -/// Discover tests for explicit paths using an optional cache directory override. -pub fn discover_tests_for_paths_with_cache_dir( - root: &Path, - path_specs: &[PathSpec], - excludes: &[String], cache_dir: Option<&Path>, ) -> DiscoverySelection { let walk_roots = match resolve_walk_roots(root, path_specs) { Some(roots) => roots, None => { debug!("discover_tests_for_paths: falling back to full discovery"); - return discover_tests_with_cache_dir(root, false, None, excludes, cache_dir); + return discover_tests(root, false, None, excludes, cache_dir); } }; @@ -239,19 +224,12 @@ fn resolve_walk_roots(root: &Path, path_specs: &[PathSpec]) -> Option, excludes: &[String], -) -> DiscoverySelection { - discover_tests_changed_first_with_cache_dir(root, base_branch, excludes, None) -} - -/// Discover all tests with changed tests first using an optional cache directory override. -pub fn discover_tests_changed_first_with_cache_dir( - root: &Path, - base_branch: Option<&str>, - excludes: &[String], cache_dir: Option<&Path>, ) -> DiscoverySelection { let mut discoverer = Discoverer::new_with_excludes_and_cache_dir(root, excludes, cache_dir); @@ -370,7 +348,7 @@ mod tests { git_run(dir.path(), &["add", "test_feature.py"]); git_run(dir.path(), &["commit", "-m", "add feature test"]); - let discovered = discover_tests(dir.path(), true, Some("main"), &[]); + let discovered = discover_tests(dir.path(), true, Some("main"), &[], None); assert!( discovered.tests.iter().any(|t| t.name == "test_feature"), "should find the branch's test: {:?}", @@ -404,7 +382,7 @@ mod tests { ) .expect("write"); - let discovered = discover_tests_changed_first(dir.path(), None, &[]); + let discovered = discover_tests_changed_first(dir.path(), None, &[], None); let names: Vec<&str> = discovered.tests.iter().map(|t| t.name.as_str()).collect(); assert!( @@ -437,7 +415,7 @@ mod tests { )], ); - let discovered = discover_tests_changed_first(dir.path(), None, &[]); + let discovered = discover_tests_changed_first(dir.path(), None, &[], None); assert!( discovered.changed_prefix_len.is_none(), "changed_prefix_len should be None when no changes" @@ -474,7 +452,7 @@ mod tests { git_run(dir.path(), &["add", "test_c.py"]); git_run(dir.path(), &["commit", "-m", "add test_c"]); - let discovered = discover_tests_changed_first(dir.path(), Some("main"), &[]); + let discovered = discover_tests_changed_first(dir.path(), Some("main"), &[], None); let names: Vec<&str> = discovered.tests.iter().map(|t| t.name.as_str()).collect(); assert!( @@ -502,7 +480,7 @@ mod tests { ) .expect("write test_dyn.py"); - let discovered = discover_tests(dir.path(), false, None, &[]); + let discovered = discover_tests(dir.path(), false, None, &[], None); assert!( !discovered.warnings.is_empty(), "should have at least one dynamic import warning" @@ -551,7 +529,7 @@ mod tests { ), ]); let specs = vec![pathspec_file("test_a.py")]; - let discovered = discover_tests_for_paths(dir.path(), &specs, &[]); + let discovered = discover_tests_for_paths(dir.path(), &specs, &[], None); let names: Vec<&str> = discovered.tests.iter().map(|t| t.name.as_str()).collect(); assert_eq!(names, vec!["test_a"], "got: {names:?}"); } @@ -573,7 +551,7 @@ mod tests { ), ]); let specs = vec![pathspec_file("tests")]; - let discovered = discover_tests_for_paths(dir.path(), &specs, &[]); + let discovered = discover_tests_for_paths(dir.path(), &specs, &[], None); let mut names: Vec<&str> = discovered.tests.iter().map(|t| t.name.as_str()).collect(); names.sort_unstable(); assert_eq!(names, vec!["test_a", "test_b"], "got: {names:?}"); @@ -594,7 +572,7 @@ mod tests { // Dir + a contained file should dedupe to just the dir; both // tests should be discovered (not just test_a). let specs = vec![pathspec_file("tests"), pathspec_file("tests/test_a.py")]; - let discovered = discover_tests_for_paths(dir.path(), &specs, &[]); + let discovered = discover_tests_for_paths(dir.path(), &specs, &[], None); let mut names: Vec<&str> = discovered.tests.iter().map(|t| t.name.as_str()).collect(); names.sort_unstable(); assert_eq!(names, vec!["test_a", "test_b"], "got: {names:?}"); @@ -607,7 +585,7 @@ mod tests { "from tryke import test\n@test\ndef test_real(): pass\n", )]); let specs = vec![pathspec_file("does_not_exist.py")]; - let discovered = discover_tests_for_paths(dir.path(), &specs, &[]); + let discovered = discover_tests_for_paths(dir.path(), &specs, &[], None); // Fallback runs full discovery; the post-filter (applied in // main, not here) is what would narrow the set. So we expect // every test in the project here. @@ -631,7 +609,7 @@ mod tests { ), ]); let specs = vec![PathSpec::FileLine(PathBuf::from("test_a.py"), 2)]; - let discovered = discover_tests_for_paths(dir.path(), &specs, &[]); + let discovered = discover_tests_for_paths(dir.path(), &specs, &[], None); let names: Vec<&str> = discovered.tests.iter().map(|t| t.name.as_str()).collect(); // The walk is restricted to test_a.py — test_b should not appear // even before the post-filter narrows by line. @@ -651,7 +629,8 @@ mod tests { ), ]); let specs = vec![pathspec_file("tests")]; - let discovered = discover_tests_for_paths(dir.path(), &specs, &["tests/skip".to_string()]); + let discovered = + discover_tests_for_paths(dir.path(), &specs, &["tests/skip".to_string()], None); let names: Vec<&str> = discovered.tests.iter().map(|t| t.name.as_str()).collect(); assert_eq!(names, vec!["test_a"], "got: {names:?}"); } @@ -666,7 +645,7 @@ mod tests { let outside_file = outside.path().join("stray.py"); std::fs::write(&outside_file, "x = 1\n").expect("write stray"); let specs = vec![PathSpec::File(outside_file)]; - let discovered = discover_tests_for_paths(dir.path(), &specs, &[]); + let discovered = discover_tests_for_paths(dir.path(), &specs, &[], None); let names: Vec<&str> = discovered.tests.iter().map(|t| t.name.as_str()).collect(); // Out-of-root spec falls back to full discovery rather than // attempting to walk outside the project. diff --git a/crates/tryke/src/execution.rs b/crates/tryke/src/execution.rs index 829c2c7..9505bd4 100644 --- a/crates/tryke/src/execution.rs +++ b/crates/tryke/src/execution.rs @@ -251,7 +251,7 @@ mod tests { let dir = tempfile::tempdir().expect("tempdir"); std::fs::write(dir.path().join("pyproject.toml"), "").expect("write pyproject.toml"); let excludes = resolved_excludes(dir.path(), &[], &[]); - let tests = discover_tests(dir.path(), false, None, &excludes).tests; + let tests = discover_tests(dir.path(), false, None, &excludes, None).tests; let _ = run_tests( reporter, dir.path(), @@ -353,7 +353,7 @@ mod tests { std::fs::write(dir.path().join("pyproject.toml"), "").expect("write pyproject.toml"); let mut reporter = TextReporter::new(); // Non-git directory → git_changed_files returns None → discover_tests runs all (0 here) - let tests = discover_tests(dir.path(), true, None, &[]).tests; + let tests = discover_tests(dir.path(), true, None, &[], None).tests; assert!( run_tests( &mut reporter, @@ -399,7 +399,7 @@ def test_failing(): ) .expect("write test file"); - let tests = discover_tests(dir.path(), false, None, &[]).tests; + let tests = discover_tests(dir.path(), false, None, &[], None).tests; assert_eq!(tests.len(), 2); let pool = WorkerPool::with_python_path( @@ -449,7 +449,7 @@ def test_failing(): "from tryke import test, expect\n\n@test\ndef test_ok():\n expect(1 + 1).to_equal(2)\n", ) .expect("write test file"); - let tests = discover_tests(dir.path(), false, None, &[]).tests; + let tests = discover_tests(dir.path(), false, None, &[], None).tests; let mut reporter = TextReporter::with_writer(Vec::new()); let pool = WorkerPool::with_python_path( 1, @@ -488,7 +488,7 @@ def test_failing(): "from tryke import test, expect\n\n@test\ndef test_bad():\n expect(1 + 1).to_equal(3)\n", ) .expect("write test file"); - let tests = discover_tests(dir.path(), false, None, &[]).tests; + let tests = discover_tests(dir.path(), false, None, &[], None).tests; let mut reporter = TextReporter::with_writer(Vec::new()); let pool = WorkerPool::with_python_path( 1, diff --git a/crates/tryke/src/graph.rs b/crates/tryke/src/graph.rs index 83ac2c1..1c0edc8 100644 --- a/crates/tryke/src/graph.rs +++ b/crates/tryke/src/graph.rs @@ -13,16 +13,6 @@ pub fn run_graph( connected_only: bool, changed: bool, base_branch: Option<&str>, -) -> Result<()> { - run_graph_with_cache_dir(root, excludes, connected_only, changed, base_branch, None) -} - -pub fn run_graph_with_cache_dir( - root: Option<&Path>, - excludes: &[String], - connected_only: bool, - changed: bool, - base_branch: Option<&str>, cache_dir: Option<&Path>, ) -> Result<()> { let cwd = std::env::current_dir()?; @@ -113,11 +103,7 @@ pub fn run_graph_with_cache_dir( /// dependency names (references to hooks that don't exist in any /// discovered module) are printed with a `?` suffix so users can spot /// typos or missing fixtures without reading through test output. -pub fn run_fixture_graph(root: Option<&Path>, excludes: &[String]) -> Result<()> { - run_fixture_graph_with_cache_dir(root, excludes, None) -} - -pub fn run_fixture_graph_with_cache_dir( +pub fn run_fixture_graph( root: Option<&Path>, excludes: &[String], cache_dir: Option<&Path>, @@ -222,7 +208,7 @@ mod tests { "from utils import helper\n@test\ndef test_foo(): pass\n", ) .expect("write"); - assert!(run_graph(Some(dir.path()), &[], false, false, None).is_ok()); + assert!(run_graph(Some(dir.path()), &[], false, false, None, None).is_ok()); } #[test] @@ -240,7 +226,7 @@ mod tests { "@test\ndef test_isolated(): pass\n", ) .expect("write"); - assert!(run_graph(Some(dir.path()), &[], true, false, None).is_ok()); + assert!(run_graph(Some(dir.path()), &[], true, false, None, None).is_ok()); } #[test] @@ -258,7 +244,7 @@ mod tests { def test_it(s=Depends(session)):\n pass\n", ) .expect("write"); - assert!(run_fixture_graph(Some(dir.path()), &[]).is_ok()); + assert!(run_fixture_graph(Some(dir.path()), &[], None).is_ok()); } #[test] @@ -270,6 +256,6 @@ mod tests { "@test\ndef test_it(): pass\n", ) .expect("write"); - assert!(run_fixture_graph(Some(dir.path()), &[]).is_ok()); + assert!(run_fixture_graph(Some(dir.path()), &[], None).is_ok()); } } diff --git a/crates/tryke/src/main.rs b/crates/tryke/src/main.rs index 5140bc8..4801586 100644 --- a/crates/tryke/src/main.rs +++ b/crates/tryke/src/main.rs @@ -5,11 +5,10 @@ use clap::Parser; use log::debug; use tryke::cli::{Cli, Commands, ReporterFormat}; use tryke::discovery::{ - discover_tests_changed_first_with_cache_dir, discover_tests_for_paths_with_cache_dir, - discover_tests_with_cache_dir, resolved_excludes, + discover_tests, discover_tests_changed_first, discover_tests_for_paths, resolved_excludes, }; use tryke::execution::run_tests; -use tryke::graph::{run_fixture_graph_with_cache_dir, run_graph_with_cache_dir}; +use tryke::graph::{run_fixture_graph, run_graph}; use tryke::watch::run_watch; use tryke_reporter::{ DotReporter, JSONReporter, JUnitReporter, LlmReporter, NextReporter, ProgressReporter, @@ -204,21 +203,21 @@ fn main() -> Result<()> { // post-filter (`test_filter.apply` below) still runs to // honor `:line` specs, `--filter`, and `--markers`. let discovered = if !paths.is_empty() && !*changed && !*changed_first { - discover_tests_for_paths_with_cache_dir( + discover_tests_for_paths( root_path, &test_filter.path_specs, &excludes, resolved_cache_dir.as_deref(), ) } else if *changed_first { - discover_tests_changed_first_with_cache_dir( + discover_tests_changed_first( root_path, base_branch.as_deref(), &excludes, resolved_cache_dir.as_deref(), ) } else { - discover_tests_with_cache_dir( + discover_tests( root_path, *changed, base_branch.as_deref(), @@ -275,7 +274,7 @@ fn main() -> Result<()> { let config = tryke_config::load_effective_config(&root_path); let resolved_python = tryke_config::resolve_python(python.as_deref(), &config); let resolved_cache_dir = tryke_config::resolve_cache_dir(cache_dir.as_deref(), &config); - let server = tryke_server::Server::new_with_cache_dir( + let server = tryke_server::Server::new( *port, root_path, excludes, @@ -303,13 +302,9 @@ fn main() -> Result<()> { let config = tryke_config::load_effective_config(root_path); let resolved_cache_dir = tryke_config::resolve_cache_dir(cache_dir.as_deref(), &config); if *fixtures { - run_fixture_graph_with_cache_dir( - Some(root_path), - &excludes, - resolved_cache_dir.as_deref(), - ) + run_fixture_graph(Some(root_path), &excludes, resolved_cache_dir.as_deref()) } else { - run_graph_with_cache_dir( + run_graph( Some(root_path), &excludes, *connected_only, diff --git a/crates/tryke/src/watch.rs b/crates/tryke/src/watch.rs index 104cc2f..15901a0 100644 --- a/crates/tryke/src/watch.rs +++ b/crates/tryke/src/watch.rs @@ -358,7 +358,7 @@ mod tests { "from tryke import test, expect\n\n@test\ndef test_bad():\n expect(1 + 1).to_equal(3)\n", ) .expect("write test file"); - let tests = discover_tests(dir.path(), false, None, &[]).tests; + let tests = discover_tests(dir.path(), false, None, &[], None).tests; let mut reporter = TextReporter::with_writer(Vec::new()); let pool = WorkerPool::with_python_path( 1, diff --git a/crates/tryke_server/src/server.rs b/crates/tryke_server/src/server.rs index 3bf9231..28b056a 100644 --- a/crates/tryke_server/src/server.rs +++ b/crates/tryke_server/src/server.rs @@ -31,17 +31,6 @@ impl Server { excludes: Vec, python: String, log_level: LevelFilter, - ) -> Self { - Self::new_with_cache_dir(port, root, excludes, python, log_level, None) - } - - #[must_use] - pub fn new_with_cache_dir( - port: u16, - root: PathBuf, - excludes: Vec, - python: String, - log_level: LevelFilter, cache_dir: Option, ) -> Self { Self { @@ -196,7 +185,7 @@ mod tests { let root = dir.path().to_path_buf(); let python = test_python_bin(); tokio::spawn(async move { - Server::new(port, root, vec![], python, LevelFilter::Off) + Server::new(port, root, vec![], python, LevelFilter::Off, None) .run_on_listener(listener) .await .unwrap(); From 3e494a8ad663b60fa4b388fbc378b4f247f12165 Mon Sep 17 00:00:00 2001 From: Justin Chapman Date: Wed, 20 May 2026 23:40:53 -0400 Subject: [PATCH 3/4] gitignore --- crates/tryke_discovery/src/cache.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/tryke_discovery/src/cache.rs b/crates/tryke_discovery/src/cache.rs index c76c964..88ac108 100644 --- a/crates/tryke_discovery/src/cache.rs +++ b/crates/tryke_discovery/src/cache.rs @@ -107,13 +107,15 @@ impl DiskCache { /// `.gitignore`, matching user intent for a custom cache location. Unlike /// the default `.tryke` state directory, this writes narrow patterns only: /// a user-provided cache directory may be an existing project directory. + /// The `.gitignore` ignores itself too, so it doesn't surface as an + /// untracked file when the cache dir lives inside a git repo. pub fn load_in_dir(cache_dir: PathBuf) -> Self { let path = cache_dir.join("discovery-v1.bin"); Self::load_with_gitignore( path, Some(GitignoreConfig { dir: cache_dir, - contents: "# created by tryke\n/discovery-v1.bin\n/discovery-v1.tmp\n", + contents: "# created by tryke\n/.gitignore\n/discovery-v1.bin\n/discovery-v1.tmp\n", }), ) } @@ -260,9 +262,11 @@ mod tests { let gitignore = fs::read_to_string(cache_dir.join(".gitignore")).expect("read gitignore"); assert_eq!( gitignore, - "# created by tryke\n/discovery-v1.bin\n/discovery-v1.tmp\n" + "# created by tryke\n/.gitignore\n/discovery-v1.bin\n/discovery-v1.tmp\n" ); assert!(!gitignore.lines().any(|line| line.trim() == "*")); + // The `.gitignore` ignores itself so it doesn't show up as untracked. + assert!(gitignore.lines().any(|line| line.trim() == "/.gitignore")); } #[test] From ca373ab9edb6918f23937c03a27fef59e98443ae Mon Sep 17 00:00:00 2001 From: Justin Chapman Date: Wed, 20 May 2026 23:58:38 -0400 Subject: [PATCH 4/4] refactor --- crates/tryke_discovery/src/cache.rs | 62 ++++++++++++++++-------- crates/tryke_discovery/src/discoverer.rs | 2 +- 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/crates/tryke_discovery/src/cache.rs b/crates/tryke_discovery/src/cache.rs index 88ac108..318ff07 100644 --- a/crates/tryke_discovery/src/cache.rs +++ b/crates/tryke_discovery/src/cache.rs @@ -32,6 +32,10 @@ use crate::db::DiscoveredFile; /// root) would miss resolutions under secondary roots like `python/`. const CACHE_VERSION: u32 = 3; +/// Name of the cache file within its directory. The stem is also reused +/// (with a `.tmp` extension) for the atomic write in `save`. +const CACHE_FILE_NAME: &str = "discovery-v1.bin"; + /// Identity of a source file derived from `stat`. Cheap to obtain /// without reading the file contents. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -88,17 +92,31 @@ struct GitignoreConfig { } impl DiskCache { - /// Load a cache from `path`. Returns an empty cache if the file is - /// missing, corrupted, or has a mismatched `CACHE_VERSION`. + /// Load a cache from an explicit `path`, writing no `.gitignore`. + /// + /// Test-only: production code must pick a gitignore policy via + /// `load_in_state_dir` (broad) or `load_in_dir` (narrow), so the cache + /// file's directory layout is always known to the constructor. + #[cfg(test)] pub fn load(path: PathBuf) -> Self { - let gitignore = path - .parent() - .and_then(Path::parent) - .map(|dir| GitignoreConfig { - dir: dir.to_path_buf(), + Self::load_with_gitignore(path, None) + } + + /// Load the discovery cache from the default `.tryke` state directory + /// layout: the cache file lives at `state_dir/cache/`. + /// + /// `state_dir` receives a broad `*` `.gitignore`. That is safe — and + /// future-proof for anything else tryke writes under it — because the + /// state directory is created and exclusively owned by tryke. + pub fn load_in_state_dir(state_dir: PathBuf) -> Self { + let path = state_dir.join("cache").join(CACHE_FILE_NAME); + Self::load_with_gitignore( + path, + Some(GitignoreConfig { + dir: state_dir, contents: "# created by tryke\n*\n", - }); - Self::load_with_gitignore(path, gitignore) + }), + ) } /// Load the standard discovery cache file inside `cache_dir`. @@ -110,7 +128,7 @@ impl DiskCache { /// The `.gitignore` ignores itself too, so it doesn't surface as an /// untracked file when the cache dir lives inside a git repo. pub fn load_in_dir(cache_dir: PathBuf) -> Self { - let path = cache_dir.join("discovery-v1.bin"); + let path = cache_dir.join(CACHE_FILE_NAME); Self::load_with_gitignore( path, Some(GitignoreConfig { @@ -226,28 +244,34 @@ mod tests { #[test] fn roundtrip_empty() { let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("cache.json"); + // Nest the cache file so both its parent and grandparent are + // inside the controlled tempdir — that lets us assert `load` + // writes no `.gitignore` into any ancestor. + let nested = dir.path().join("a").join("b"); + fs::create_dir_all(&nested).expect("create nested"); + let path = nested.join("cache.json"); let cache = DiskCache::load(path.clone()); assert_eq!(cache.entries.len(), 0); cache.save().expect("save"); let reloaded = DiskCache::load(path); assert_eq!(reloaded.entries.len(), 0); + // `load` writes no `.gitignore` — not beside the cache file, and + // crucially not in any ancestor directory. + assert!(!nested.join(".gitignore").exists()); + assert!(!dir.path().join("a").join(".gitignore").exists()); + assert!(!dir.path().join(".gitignore").exists()); } #[test] fn default_cache_writes_broad_gitignore_in_state_dir() { let dir = tempfile::tempdir().expect("tempdir"); - let path = dir - .path() - .join(".tryke") - .join("cache") - .join("discovery-v1.bin"); - let cache = DiskCache::load(path); + let state_dir = dir.path().join(".tryke"); + let cache = DiskCache::load_in_state_dir(state_dir.clone()); cache.save().expect("save"); - let gitignore = - fs::read_to_string(dir.path().join(".tryke/.gitignore")).expect("read gitignore"); + assert!(state_dir.join("cache").join("discovery-v1.bin").exists()); + let gitignore = fs::read_to_string(state_dir.join(".gitignore")).expect("read gitignore"); assert_eq!(gitignore, "# created by tryke\n*\n"); } diff --git a/crates/tryke_discovery/src/discoverer.rs b/crates/tryke_discovery/src/discoverer.rs index bb973ec..6403ad0 100644 --- a/crates/tryke_discovery/src/discoverer.rs +++ b/crates/tryke_discovery/src/discoverer.rs @@ -121,7 +121,7 @@ impl Discoverer { crate::resolve_src_roots(&root, src) }; let cache = cache_dir.map_or_else( - || DiskCache::load(root.join(".tryke").join("cache").join("discovery-v1.bin")), + || DiskCache::load_in_state_dir(root.join(".tryke")), |dir| DiskCache::load_in_dir(dir.to_path_buf()), ); Self {