Skip to main content

harn_vm/
runtime_paths.rs

1use std::path::{Path, PathBuf};
2
3use sha2::{Digest, Sha256};
4
5pub const HARN_STATE_DIR_ENV: &str = "HARN_STATE_DIR";
6pub const HARN_RUN_DIR_ENV: &str = "HARN_RUN_DIR";
7pub const HARN_WORKTREE_DIR_ENV: &str = "HARN_WORKTREE_DIR";
8const NEXTEST_ENV: &str = "NEXTEST";
9const NEXTEST_RUN_ID_ENV: &str = "NEXTEST_RUN_ID";
10const NEXTEST_BINARY_ID_ENV: &str = "NEXTEST_BINARY_ID";
11const NEXTEST_TEST_NAME_ENV: &str = "NEXTEST_TEST_NAME";
12const NEXTEST_ATTEMPT_ID_ENV: &str = "NEXTEST_ATTEMPT_ID";
13
14#[cfg(test)]
15pub(crate) fn test_env_lock() -> &'static std::sync::Mutex<()> {
16    use std::sync::{Mutex, OnceLock};
17    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
18    LOCK.get_or_init(|| Mutex::new(()))
19}
20
21fn resolve_root_value(base_dir: &Path, env_value: Option<&str>, default_relative: &str) -> PathBuf {
22    match env_value {
23        Some(value) if !value.trim().is_empty() => {
24            let candidate = PathBuf::from(value);
25            if candidate.is_absolute() {
26                candidate
27            } else {
28                base_dir.join(candidate)
29            }
30        }
31        _ => base_dir.join(default_relative),
32    }
33}
34
35fn resolve_root(base_dir: &Path, env_key: &str, default_relative: &str) -> PathBuf {
36    let env_value = std::env::var(env_key).ok();
37    resolve_root_value(base_dir, env_value.as_deref(), default_relative)
38}
39
40pub fn state_root(base_dir: &Path) -> PathBuf {
41    let state_env_value = std::env::var(HARN_STATE_DIR_ENV).ok();
42    state_root_value(
43        base_dir,
44        state_env_value.as_deref(),
45        nextest_state_root().as_deref(),
46    )
47}
48
49pub fn run_root(base_dir: &Path) -> PathBuf {
50    resolve_root(base_dir, HARN_RUN_DIR_ENV, ".harn-runs")
51}
52
53fn worktree_root_value(
54    base_dir: &Path,
55    state_env_value: Option<&str>,
56    worktree_env_value: Option<&str>,
57) -> PathBuf {
58    match worktree_env_value {
59        Some(value) if !value.trim().is_empty() => {
60            let candidate = PathBuf::from(value);
61            if candidate.is_absolute() {
62                candidate
63            } else {
64                base_dir.join(candidate)
65            }
66        }
67        _ => resolve_root_value(base_dir, state_env_value, ".harn").join("worktrees"),
68    }
69}
70
71pub fn worktree_root(base_dir: &Path) -> PathBuf {
72    let state_env_value = std::env::var(HARN_STATE_DIR_ENV).ok();
73    let worktree_env_value = std::env::var(HARN_WORKTREE_DIR_ENV).ok();
74    worktree_root_value(
75        base_dir,
76        state_env_value.as_deref(),
77        worktree_env_value.as_deref(),
78    )
79}
80
81pub fn store_path(base_dir: &Path) -> PathBuf {
82    state_root(base_dir).join("store.json")
83}
84
85pub fn checkpoint_dir(base_dir: &Path) -> PathBuf {
86    state_root(base_dir).join("checkpoints")
87}
88
89pub fn metadata_dir(base_dir: &Path) -> PathBuf {
90    state_root(base_dir).join("metadata")
91}
92
93pub fn event_log_dir(base_dir: &Path) -> PathBuf {
94    event_log_dir_at_state_root(&state_root(base_dir))
95}
96
97pub fn event_log_sqlite_path(base_dir: &Path) -> PathBuf {
98    event_log_sqlite_path_at_state_root(&state_root(base_dir))
99}
100
101/// Event-log directory under an already-resolved state root.
102///
103/// Callers that own their state root exactly — an orchestrator told where to
104/// keep its state, an embedder running concurrent isolated VMs — use this and
105/// the sibling sqlite helper instead of the `base_dir` forms, which route
106/// through [`state_root`] and therefore let an absolute `HARN_STATE_DIR`
107/// discard the caller's path entirely.
108pub fn event_log_dir_at_state_root(state_root: &Path) -> PathBuf {
109    state_root.join("events")
110}
111
112/// Sqlite event-log path under an already-resolved state root. See
113/// [`event_log_dir_at_state_root`].
114pub fn event_log_sqlite_path_at_state_root(state_root: &Path) -> PathBuf {
115    state_root.join("events.sqlite")
116}
117
118pub fn workflow_dir(base_dir: &Path) -> PathBuf {
119    state_root(base_dir).join("workflows")
120}
121
122fn state_root_value(
123    base_dir: &Path,
124    state_env_value: Option<&str>,
125    nextest_root: Option<&Path>,
126) -> PathBuf {
127    match state_env_value {
128        Some(value) if !value.trim().is_empty() => {
129            resolve_root_value(base_dir, Some(value), ".harn")
130        }
131        _ => nextest_root
132            .map(Path::to_path_buf)
133            .unwrap_or_else(|| base_dir.join(".harn")),
134    }
135}
136
137/// Nextest runs each test attempt in its own process and identifies that
138/// attempt in the environment. Keep default runtime state inside the attempt
139/// instead of letting concurrent tests persist transcripts into the checkout's
140/// shared `.harn` database. Explicit `HARN_STATE_DIR` still wins in
141/// [`state_root`], and child processes inherit the same Nextest identity.
142fn nextest_state_root() -> Option<PathBuf> {
143    std::env::var_os(NEXTEST_ENV)?;
144    let identity = [
145        std::env::var(NEXTEST_RUN_ID_ENV).ok()?,
146        std::env::var(NEXTEST_BINARY_ID_ENV).ok()?,
147        std::env::var(NEXTEST_TEST_NAME_ENV).ok()?,
148        std::env::var(NEXTEST_ATTEMPT_ID_ENV).ok()?,
149    ];
150    Some(nextest_state_root_for_identity(
151        &std::env::temp_dir(),
152        identity.iter().map(String::as_str),
153    ))
154}
155
156fn nextest_state_root_for_identity<'a>(
157    temp_dir: &Path,
158    identity: impl IntoIterator<Item = &'a str>,
159) -> PathBuf {
160    let mut hasher = Sha256::new();
161    for part in identity {
162        hasher.update((part.len() as u64).to_le_bytes());
163        hasher.update(part.as_bytes());
164    }
165    let digest = hasher.finalize();
166    temp_dir
167        .join("harn-nextest-state")
168        .join(hex::encode(&digest[..16]))
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn defaults_resolve_under_base_dir() {
177        let base = Path::new("/tmp/harn-runtime-paths");
178        assert_eq!(resolve_root_value(base, None, ".harn"), base.join(".harn"));
179        assert_eq!(
180            resolve_root_value(base, None, ".harn-runs"),
181            base.join(".harn-runs")
182        );
183        assert_eq!(
184            worktree_root_value(base, None, None),
185            base.join(".harn").join("worktrees")
186        );
187        assert_eq!(
188            resolve_root_value(base, None, ".harn").join("events"),
189            base.join(".harn").join("events")
190        );
191        assert_eq!(
192            resolve_root_value(base, None, ".harn").join("workflows"),
193            base.join(".harn").join("workflows")
194        );
195        assert_eq!(
196            resolve_root_value(base, None, ".harn").join("events.sqlite"),
197            base.join(".harn").join("events.sqlite")
198        );
199    }
200
201    #[test]
202    fn nextest_default_state_is_attempt_scoped_but_explicit_state_still_wins() {
203        let base = Path::new("/workspace");
204        let temp = Path::new("/tmp");
205        let first =
206            nextest_state_root_for_identity(temp, ["run-1", "harn-vm", "test-a", "attempt-1"]);
207        let same =
208            nextest_state_root_for_identity(temp, ["run-1", "harn-vm", "test-a", "attempt-1"]);
209        let other =
210            nextest_state_root_for_identity(temp, ["run-1", "harn-vm", "test-b", "attempt-1"]);
211
212        assert_eq!(first, same);
213        assert_ne!(first, other);
214        assert_eq!(state_root_value(base, None, Some(&first)), first);
215        assert_eq!(
216            state_root_value(base, Some("/operator/state"), Some(&other)),
217            PathBuf::from("/operator/state")
218        );
219        assert_eq!(
220            state_root_value(base, Some("relative-state"), Some(&other)),
221            base.join("relative-state")
222        );
223    }
224}