Skip to main content

omni_dev/utils/
ai_scratch.rs

1//! AI scratch directory utilities.
2
3use std::env;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7
8use crate::utils::env::{EnvSource, SystemEnv};
9
10/// Resolves the scratch directory from an injected [`EnvSource`], deferring
11/// git-root discovery to `git_root` (only invoked for the `git-root:` form).
12///
13/// This is the env-parsing core shared by [`get_ai_scratch_dir`] and
14/// [`get_ai_scratch_dir_at`]; tests drive it with a pure `MapEnv` so the
15/// `AI_SCRATCH` / `TMPDIR` branches are covered without mutating the
16/// process-global environment (issue #1030).
17fn resolve_scratch_dir(
18    env: &impl EnvSource,
19    git_root: impl FnOnce() -> Result<PathBuf>,
20) -> Result<PathBuf> {
21    // Check for AI_SCRATCH environment variable first
22    if let Some(ai_scratch) = env.var("AI_SCRATCH") {
23        if let Some(git_root_path) = ai_scratch.strip_prefix("git-root:") {
24            // Find git root and append the path
25            Ok(git_root()?.join(git_root_path))
26        } else {
27            // Use AI_SCRATCH directly
28            Ok(PathBuf::from(ai_scratch))
29        }
30    } else {
31        // Fall back to TMPDIR
32        let tmpdir = env.var("TMPDIR").unwrap_or_else(|| "/tmp".to_string());
33        Ok(PathBuf::from(tmpdir))
34    }
35}
36
37/// Returns the AI scratch directory path based on environment variables and git root detection.
38pub fn get_ai_scratch_dir() -> Result<PathBuf> {
39    resolve_scratch_dir(&SystemEnv, find_git_root)
40}
41
42/// Returns the AI scratch directory, resolving the `git-root:` form against
43/// `repo_root` instead of the process current working directory.
44///
45/// Behaves identically to [`get_ai_scratch_dir`] for the direct-path and
46/// `TMPDIR` fallback cases; only the `git-root:` walk-up is anchored to the
47/// injected `repo_root`.
48pub fn get_ai_scratch_dir_at(repo_root: &Path) -> Result<PathBuf> {
49    resolve_scratch_dir(&SystemEnv, || find_git_root_from_path(repo_root))
50}
51
52/// Finds the closest ancestor directory containing a .git directory.
53fn find_git_root() -> Result<PathBuf> {
54    let current_dir = env::current_dir().context("Failed to get current directory")?;
55    find_git_root_from_path(&current_dir)
56}
57
58/// Finds the git root starting from a specific path.
59fn find_git_root_from_path(start_path: &Path) -> Result<PathBuf> {
60    let mut current = start_path;
61
62    loop {
63        let git_dir = current.join(".git");
64        if git_dir.exists() {
65            return Ok(current.to_path_buf());
66        }
67
68        match current.parent() {
69            Some(parent) => current = parent,
70            None => {
71                return Err(anyhow::anyhow!(
72                    "No git repository found in current directory or any parent directory"
73                ))
74            }
75        }
76    }
77}
78
79#[cfg(test)]
80#[allow(clippy::unwrap_used, clippy::expect_used)]
81mod tests {
82    use super::*;
83    use crate::test_support::env::MapEnv;
84    use tempfile::TempDir;
85
86    /// [`get_ai_scratch_dir_at`] over an injected [`EnvSource`], so the
87    /// `AI_SCRATCH` / `TMPDIR` branches are tested without mutating the
88    /// process environment. The `repo_root` is only consulted for the
89    /// `git-root:` form.
90    fn scratch_dir_with(env: &impl EnvSource, repo_root: &Path) -> Result<PathBuf> {
91        resolve_scratch_dir(env, || super::find_git_root_from_path(repo_root))
92    }
93
94    #[test]
95    fn get_ai_scratch_dir_with_direct_path() {
96        let env = MapEnv::new().with("AI_SCRATCH", "/custom/scratch/path");
97
98        let result = scratch_dir_with(&env, Path::new("/unused")).unwrap();
99        assert_eq!(result, PathBuf::from("/custom/scratch/path"));
100    }
101
102    #[test]
103    fn get_ai_scratch_dir_fallback_to_tmpdir() {
104        // AI_SCRATCH absent → TMPDIR. A pure MapEnv means no process-global
105        // TMPDIR mutation (which previously raced other tests' TempDir::new).
106        let env = MapEnv::new().with("TMPDIR", "/custom/tmp");
107
108        let result = scratch_dir_with(&env, Path::new("/unused")).unwrap();
109        assert_eq!(result, PathBuf::from("/custom/tmp"));
110    }
111
112    #[test]
113    fn get_ai_scratch_dir_at_resolves_git_root_from_injected_path() {
114        let temp_dir = {
115            std::fs::create_dir_all("tmp").ok();
116            TempDir::new_in("tmp").unwrap()
117        };
118        std::fs::create_dir(temp_dir.path().join(".git")).unwrap();
119        let env = MapEnv::new().with("AI_SCRATCH", "git-root:scratch");
120
121        // Resolves the `git-root:` form against the injected path, not the
122        // process current working directory.
123        let result = scratch_dir_with(&env, temp_dir.path()).unwrap();
124        assert_eq!(result, temp_dir.path().join("scratch"));
125    }
126
127    #[test]
128    fn public_wrappers_resolve_without_panicking() {
129        // The thin `SystemEnv` wrappers read the real environment (no mutation,
130        // no network, no side effects), so we exercise them for coverage and
131        // assert only that resolution completes — the path depends on the
132        // ambient AI_SCRATCH/TMPDIR, which we deliberately don't control here.
133        let _ = get_ai_scratch_dir();
134        let _ = get_ai_scratch_dir_at(Path::new("/tmp"));
135    }
136
137    #[test]
138    fn find_git_root_from_path() {
139        let temp_dir = {
140            std::fs::create_dir_all("tmp").ok();
141            TempDir::new_in("tmp").unwrap()
142        };
143        let git_dir = temp_dir.path().join(".git");
144        std::fs::create_dir(&git_dir).unwrap();
145
146        let sub_dir = temp_dir.path().join("subdir").join("deeper");
147        std::fs::create_dir_all(&sub_dir).unwrap();
148
149        let result = super::find_git_root_from_path(&sub_dir).unwrap();
150        assert_eq!(result, temp_dir.path());
151    }
152}