somatize_runtime/tracking/
head.rs1use somatize_core::error::{Result, SomaError};
23use std::fs;
24use std::path::{Path, PathBuf};
25
26pub const PARENT_ENV: &str = "SOMA_PARENT_RUN";
28
29pub fn head_path(root: impl AsRef<Path>) -> PathBuf {
31 root.as_ref().join("HEAD")
32}
33
34pub fn read_head(root: impl AsRef<Path>) -> Option<String> {
38 let text = fs::read_to_string(head_path(root)).ok()?;
39 let trimmed = text.trim();
40 (!trimmed.is_empty()).then(|| trimmed.to_string())
41}
42
43pub fn write_head(root: impl AsRef<Path>, run_id: &str) -> Result<()> {
47 let root = root.as_ref();
48 fs::create_dir_all(root)?;
49 let final_path = head_path(root);
50 let tmp = final_path.with_extension("tmp");
51 fs::write(&tmp, format!("{run_id}\n"))?;
52 fs::rename(&tmp, &final_path)?;
53 Ok(())
54}
55
56pub fn clear_head(root: impl AsRef<Path>) -> Result<()> {
58 match fs::remove_file(head_path(root)) {
59 Ok(()) => Ok(()),
60 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
61 Err(e) => Err(SomaError::Io(e)),
62 }
63}
64
65pub fn run_exists(root: impl AsRef<Path>, run_id: &str) -> bool {
67 root.as_ref()
68 .join("runs")
69 .join(run_id)
70 .join("manifest.json")
71 .exists()
72}
73
74pub fn checkout(root: impl AsRef<Path>, run_id: &str) -> Result<()> {
80 let root = root.as_ref();
81 if !run_exists(root, run_id) {
82 return Err(SomaError::Other(format!(
83 "no run '{run_id}' under {}/runs — checkout needs a run that exists",
84 root.display()
85 )));
86 }
87 write_head(root, run_id)
88}
89
90pub fn resolve_parent(root: impl AsRef<Path>, explicit: Option<&str>) -> Option<String> {
95 let root = root.as_ref();
96 let env = std::env::var(PARENT_ENV).ok();
97 resolve_parent_from(explicit, env.as_deref(), || read_head(root))
98}
99
100pub fn resolve_parent_from(
105 explicit: Option<&str>,
106 env: Option<&str>,
107 head: impl FnOnce() -> Option<String>,
108) -> Option<String> {
109 let non_empty = |s: &str| {
110 let s = s.trim();
111 (!s.is_empty()).then(|| s.to_string())
112 };
113 explicit
114 .and_then(non_empty)
115 .or_else(|| env.and_then(non_empty))
116 .or_else(head)
117}
118
119pub fn advance_head(root: impl AsRef<Path>, run_id: &str) -> bool {
124 write_head(root, run_id).is_ok()
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use tempfile::TempDir;
131
132 #[test]
133 fn head_roundtrips_and_tolerates_absence() {
134 let root = TempDir::new().unwrap();
135 assert_eq!(read_head(root.path()), None);
136
137 write_head(root.path(), "run_a").unwrap();
138 assert_eq!(read_head(root.path()).as_deref(), Some("run_a"));
139
140 let raw = fs::read_to_string(head_path(root.path())).unwrap();
142 assert_eq!(raw, "run_a\n");
143
144 write_head(root.path(), "run_b").unwrap();
145 assert_eq!(read_head(root.path()).as_deref(), Some("run_b"));
146
147 clear_head(root.path()).unwrap();
148 assert_eq!(read_head(root.path()), None);
149 clear_head(root.path()).unwrap();
151 }
152
153 #[test]
154 fn a_blank_head_reads_as_no_parent() {
155 let root = TempDir::new().unwrap();
156 fs::write(head_path(root.path()), " \n").unwrap();
157 assert_eq!(read_head(root.path()), None);
158 }
159
160 #[test]
161 fn write_head_creates_the_root() {
162 let root = TempDir::new().unwrap();
163 let nested = root.path().join("deep").join(".soma");
164 write_head(&nested, "run_x").unwrap();
165 assert_eq!(read_head(&nested).as_deref(), Some("run_x"));
166 }
167
168 #[test]
169 fn precedence_is_explicit_then_env_then_head() {
170 let head = || Some("from_head".to_string());
171 assert_eq!(
172 resolve_parent_from(Some("explicit"), Some("env"), head).as_deref(),
173 Some("explicit")
174 );
175 assert_eq!(
176 resolve_parent_from(None, Some("env"), head).as_deref(),
177 Some("env")
178 );
179 assert_eq!(
180 resolve_parent_from(None, None, head).as_deref(),
181 Some("from_head")
182 );
183 assert_eq!(resolve_parent_from(None, None, || None), None);
184 assert_eq!(resolve_parent_from(Some(" "), Some(""), || None), None);
186 }
187
188 #[test]
189 fn head_is_not_read_when_a_parent_is_already_known() {
190 let mut read = false;
191 let head = || {
192 read = true;
193 Some("from_head".to_string())
194 };
195 assert_eq!(
196 resolve_parent_from(Some("explicit"), None, head).as_deref(),
197 Some("explicit")
198 );
199 assert!(!read, "the filesystem is only touched as a last resort");
200 }
201
202 #[test]
203 fn checkout_refuses_a_run_that_does_not_exist() {
204 let root = TempDir::new().unwrap();
205 let err = checkout(root.path(), "typo_run").unwrap_err();
206 assert!(err.to_string().contains("no run 'typo_run'"), "{err}");
207 assert_eq!(read_head(root.path()), None, "HEAD must not move");
208
209 let run_dir = root.path().join("runs").join("run_real");
211 fs::create_dir_all(&run_dir).unwrap();
212 fs::write(run_dir.join("manifest.json"), "{}").unwrap();
213 assert!(run_exists(root.path(), "run_real"));
214 checkout(root.path(), "run_real").unwrap();
215 assert_eq!(read_head(root.path()).as_deref(), Some("run_real"));
216 }
217
218 #[test]
219 fn advancing_head_reports_whether_it_moved() {
220 let root = TempDir::new().unwrap();
221 assert!(advance_head(root.path(), "run_1"));
222 assert_eq!(read_head(root.path()).as_deref(), Some("run_1"));
223 }
224}