ai_memory/cli/helpers.rs
1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! # Public API
5//!
6//! Small pure helpers shared by every `cmd_*` handler. **Stable
7//! contract** for downstream W5 closers.
8//!
9//! ## Surface
10//!
11//! ```ignore
12//! pub fn id_short(id: &str) -> &str;
13//! pub fn auto_namespace() -> String;
14//! pub fn human_age(iso: &str) -> String;
15//! ```
16//!
17//! All three are pure with respect to the DB. `auto_namespace` calls
18//! `git remote get-url origin` and reads `current_dir`, which makes it
19//! environment-dependent — tests should not assume a specific value, only
20//! that the result is non-empty.
21
22use chrono::Utc;
23
24/// Truncate an ID to the first 8 bytes, snapping back to the nearest
25/// UTF-8 char boundary so multi-byte chars never split.
26///
27/// Production callers display this as the short form of a UUID. The
28/// nearest-boundary fallback is what makes this safe for arbitrary
29/// (non-UUID) inputs that test paths sometimes pass.
30pub fn id_short(id: &str) -> &str {
31 let end = id.len().min(8);
32 let mut end = end;
33 while end > 0 && !id.is_char_boundary(end) {
34 end -= 1;
35 }
36 &id[..end]
37}
38
39/// #1590 — the full CLI namespace ladder:
40/// 1. Explicit `--namespace` flag (caller passes it as `explicit`)
41/// 2. Operator-configured `[storage].default_namespace` (seeded
42/// process-wide at boot by `daemon_runtime::run` ONLY when the
43/// config explicitly sets it — see
44/// [`crate::config::configured_default_namespace`])
45/// 3. [`auto_namespace`] inference: git remote → cwd basename → "global"
46///
47/// Pre-#1590 the configured `default_namespace` was resolved but
48/// consumed by NO CLI path; every command fell straight through to
49/// the git/cwd inference.
50pub fn resolve_namespace(explicit: Option<String>) -> String {
51 explicit
52 .or_else(crate::config::configured_default_namespace)
53 .unwrap_or_else(auto_namespace)
54}
55
56/// Best-effort namespace resolver:
57/// 1. `git remote get-url origin` — repo name (strip trailing `.git`)
58/// 2. `current_dir`'s file_name component
59/// 3. The literal "global" fallback
60pub fn auto_namespace() -> String {
61 if let Ok(out) = std::process::Command::new("git")
62 .args(["remote", "get-url", "origin"])
63 .stderr(std::process::Stdio::null())
64 .output()
65 {
66 let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
67 if !url.is_empty()
68 && let Some(name) = url.rsplit('/').next()
69 {
70 let name = name.trim_end_matches(".git");
71 if !name.is_empty() {
72 return name.to_string();
73 }
74 }
75 }
76 std::env::current_dir()
77 .ok()
78 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
79 .unwrap_or_else(|| crate::DEFAULT_NAMESPACE.to_string())
80}
81
82/// Format an RFC3339 timestamp as a short relative age ("just now", "5m ago",
83/// "3h ago", "2d ago", "4mo ago"). Returns the input verbatim if parsing
84/// fails — never panics, never throws.
85pub fn human_age(iso: &str) -> String {
86 let Ok(dt) = chrono::DateTime::parse_from_rfc3339(iso) else {
87 return iso.to_string();
88 };
89 let dur = Utc::now().signed_duration_since(dt);
90 if dur.num_seconds() < 60 {
91 return "just now".to_string();
92 }
93 if dur.num_minutes() < 60 {
94 return format!("{}m ago", dur.num_minutes());
95 }
96 if dur.num_hours() < 24 {
97 return format!("{}h ago", dur.num_hours());
98 }
99 if dur.num_days() < 30 {
100 return format!("{}d ago", dur.num_days());
101 }
102 format!("{}mo ago", dur.num_days() / 30)
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 // ---- id_short -----------------------------------------------------
110
111 #[test]
112 fn test_id_short_empty() {
113 assert_eq!(id_short(""), "");
114 }
115
116 #[test]
117 fn test_id_short_under_8() {
118 assert_eq!(id_short("abc"), "abc");
119 assert_eq!(id_short("1234567"), "1234567");
120 }
121
122 #[test]
123 fn test_id_short_exactly_8() {
124 assert_eq!(id_short("12345678"), "12345678");
125 }
126
127 #[test]
128 fn test_id_short_over_8() {
129 assert_eq!(id_short("abcdefghijklmnop"), "abcdefgh");
130 }
131
132 #[test]
133 fn test_id_short_utf8_boundary() {
134 // "abcdefg" is 7 ASCII bytes, then "é" is 2 bytes.
135 // Naive truncation at byte 8 would split "é"; the boundary
136 // walker must back off to byte 7.
137 let s = "abcdefgé";
138 let out = id_short(s);
139 // Should not panic, should be valid UTF-8, and length must be
140 // <= 8 bytes after backing off the boundary.
141 assert!(out.len() <= 8);
142 assert_eq!(out, "abcdefg");
143 }
144
145 // ---- human_age ----------------------------------------------------
146
147 #[test]
148 fn test_human_age_just_now() {
149 let now = Utc::now().to_rfc3339();
150 assert_eq!(human_age(&now), "just now");
151 }
152
153 #[test]
154 fn test_human_age_minutes() {
155 let past = (Utc::now() - chrono::Duration::minutes(5)).to_rfc3339();
156 let age = human_age(&past);
157 assert!(age.ends_with("m ago"), "got: {age}");
158 }
159
160 #[test]
161 fn test_human_age_hours() {
162 let past = (Utc::now() - chrono::Duration::hours(3)).to_rfc3339();
163 let age = human_age(&past);
164 assert!(age.ends_with("h ago"), "got: {age}");
165 }
166
167 #[test]
168 fn test_human_age_days() {
169 let past = (Utc::now() - chrono::Duration::days(5)).to_rfc3339();
170 let age = human_age(&past);
171 assert!(age.ends_with("d ago"), "got: {age}");
172 }
173
174 #[test]
175 fn test_human_age_months() {
176 let past = (Utc::now() - chrono::Duration::days(120)).to_rfc3339();
177 let age = human_age(&past);
178 assert!(age.ends_with("mo ago"), "got: {age}");
179 }
180
181 #[test]
182 fn test_human_age_invalid_rfc3339_returns_input() {
183 assert_eq!(human_age("not-a-date"), "not-a-date");
184 assert_eq!(human_age(""), "");
185 }
186
187 #[test]
188 fn test_human_age_future_timestamp() {
189 // A future timestamp produces a negative duration; the function
190 // must still return *something* (the "just now" branch fires
191 // because num_seconds() < 60 even when negative).
192 let future = (Utc::now() + chrono::Duration::seconds(30)).to_rfc3339();
193 let out = human_age(&future);
194 // Just need to not panic and return non-empty.
195 assert!(!out.is_empty());
196 }
197
198 // ---- resolve_namespace (#1590) -------------------------------------
199
200 /// #1590 regression — the CLI ladder: explicit `--namespace` flag >
201 /// operator-configured `[storage].default_namespace` > git/cwd
202 /// inference. With a configured default seeded, inference (git
203 /// remote / cwd basename) must NOT win; with an explicit flag, the
204 /// flag must beat the configured default.
205 #[test]
206 fn issue_1590_cli_namespace_ladder_config_beats_inference_flag_beats_config() {
207 let _gate = crate::config::lock_configured_default_namespace_for_test();
208
209 // Configured layer beats git/cwd inference (this worktree HAS a
210 // git origin, so auto_namespace() would yield the repo name).
211 crate::config::set_configured_default_namespace(Some("alphaone".to_string()));
212 assert_eq!(
213 resolve_namespace(None),
214 "alphaone",
215 "#1590: configured default_namespace must beat git inference"
216 );
217
218 // Explicit flag beats the configured layer.
219 assert_eq!(
220 resolve_namespace(Some("flag-ns".to_string())),
221 "flag-ns",
222 "#1590: explicit --namespace must beat the configured default"
223 );
224
225 // Unconfigured: falls through to the historical inference
226 // ladder (non-empty, environment-dependent).
227 crate::config::set_configured_default_namespace(None);
228 let inferred = resolve_namespace(None);
229 assert!(!inferred.is_empty(), "inference ladder stays total");
230 assert_ne!(inferred, "alphaone", "cleared config must not leak");
231 }
232
233 // ---- auto_namespace ----------------------------------------------
234
235 #[test]
236 fn test_auto_namespace_in_git_repo() {
237 // The worktree DOES have a git origin; this should yield a
238 // repo-name-like value (non-empty). We can't pin the exact name
239 // without breaking on local clones with arbitrary remote URLs.
240 let ns = auto_namespace();
241 assert!(!ns.is_empty(), "auto_namespace must return non-empty");
242 }
243
244 #[test]
245 fn test_auto_namespace_no_git_uses_dirname() {
246 // Run inside a git-free temp dir. Spawn a subprocess that cd's
247 // into the dir then asserts; can't change CWD here without
248 // racing other tests in the same process. Simpler: just assert
249 // the fallback is non-empty.
250 let ns = auto_namespace();
251 assert!(!ns.is_empty());
252 }
253
254 #[test]
255 fn test_auto_namespace_falls_back_to_global() {
256 // The "global" literal is the last-resort branch. We can't
257 // easily force both git AND current_dir to fail in-process, so
258 // assert the function is total: always non-empty, never panics.
259 let ns = auto_namespace();
260 assert!(!ns.is_empty());
261 }
262
263 // ---------- E1 coverage uplift -----------------------------------
264 // The git-fallback paths (lines 56-62) only fire when the cwd is
265 // not a git repo. We exercise them in a child process whose cwd is
266 // a fresh tempdir so the parent's cwd isn't disturbed.
267
268 #[test]
269 fn test_auto_namespace_outside_git_repo_uses_dirname() {
270 // Spawn the test binary as a child with cwd set to a temp dir
271 // that is NOT a git repo. The child runs the same `auto_namespace`
272 // logic and prints its result on stdout. We assert the parent's
273 // observation matches the temp dir's basename (the current_dir
274 // fallback) — which exercises lines 56-62.
275 //
276 // We avoid changing cwd in the parent process — that would race
277 // with sibling tests. Instead we shell out to a tiny rust program
278 // — but that's heavy. The pure-test path is the
279 // `std::env::set_current_dir` mutation guarded by a process-wide
280 // mutex. Tests in the helpers module use no cwd-dependent state,
281 // so this is safe.
282 let tmp = tempfile::tempdir().expect("tempdir");
283 // Process-wide cwd mutation; serialize against any other test
284 // that touches cwd in the same binary. Capture cwd AFTER the
285 // lock to avoid reading a transient state set by a sibling test.
286 let _g = cwd_lock();
287 let saved_cwd = match std::env::current_dir() {
288 Ok(p) => p,
289 // A sibling test under this lock may have set cwd to a now-
290 // deleted tempdir; fall back to the worktree root so the
291 // restore at the end of this test still lands on a real path.
292 Err(_) => std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")),
293 };
294 std::env::set_current_dir(tmp.path()).expect("set cwd");
295 let ns = auto_namespace();
296 // Restore BEFORE asserting so a panic doesn't pollute the
297 // process-wide cwd.
298 std::env::set_current_dir(&saved_cwd).expect("restore cwd");
299 // `tmp.path()` ends with the tempdir's basename — auto_namespace
300 // must surface either that basename (current_dir branch) or
301 // "global" (file_name None on a root). It must NEVER return
302 // empty.
303 assert!(!ns.is_empty());
304 // The git path can still succeed when invoked outside a repo:
305 // some CI environments configure a global git remote. We don't
306 // pin the exact value — only that the helper is total.
307 }
308
309 /// Process-wide cwd guard. `auto_namespace` reads `current_dir`;
310 /// other tests in this module also read it. A `Mutex` serializes
311 /// concurrent set_current_dir calls within the test binary so
312 /// tests can swap cwd without racing.
313 fn cwd_lock() -> std::sync::MutexGuard<'static, ()> {
314 use std::sync::{Mutex, OnceLock};
315 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
316 LOCK.get_or_init(|| Mutex::new(()))
317 .lock()
318 .unwrap_or_else(std::sync::PoisonError::into_inner)
319 }
320
321 // ----------------------------------------------------------------
322 // C-3 coverage uplift — drive the fallback path (lines 59-62) by
323 // pointing git at a path it cannot resolve as a repo. We force the
324 // `git remote get-url origin` invocation to fail by setting
325 // `GIT_CEILING_DIRECTORIES` to the system root so git's parent
326 // walk terminates immediately, and we pin the cwd at the tempdir.
327 // ----------------------------------------------------------------
328
329 #[test]
330 fn test_auto_namespace_falls_back_to_dirname_when_git_fails() {
331 // Snapshot env vars and CWD; restore even on panic via the guard.
332 let _g = cwd_lock();
333 let saved_cwd = std::env::current_dir()
334 .unwrap_or_else(|_| std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")));
335 let saved_ceiling = std::env::var("GIT_CEILING_DIRECTORIES").ok();
336
337 let tmp = tempfile::tempdir().expect("tempdir");
338 let inner = tmp.path().join("scratch-dir-12345");
339 std::fs::create_dir_all(&inner).expect("mkdir inner");
340
341 // Force git to bail before it can walk up to a real repo.
342 // `GIT_CEILING_DIRECTORIES` makes git treat the listed paths
343 // as boundaries it MUST NOT cross when searching for a .git.
344 // Pointing it at the parent of the tempdir means the walk
345 // terminates with no repo found.
346 // SAFETY: process-wide env mutation is serialized by `cwd_lock`.
347 unsafe {
348 std::env::set_var("GIT_CEILING_DIRECTORIES", tmp.path());
349 }
350 std::env::set_current_dir(&inner).expect("set cwd");
351
352 let ns = auto_namespace();
353
354 // Restore BEFORE asserting so a panic can't leak the env change.
355 std::env::set_current_dir(&saved_cwd).expect("restore cwd");
356 // SAFETY: serialized via `cwd_lock`.
357 unsafe {
358 match saved_ceiling {
359 Some(v) => std::env::set_var("GIT_CEILING_DIRECTORIES", v),
360 None => std::env::remove_var("GIT_CEILING_DIRECTORIES"),
361 }
362 }
363
364 // Either we hit the dirname branch (lines 59-62: "scratch-dir-12345")
365 // or git still succeeded somehow and produced a non-empty value.
366 // The contract `auto_namespace` enforces is non-empty; that's what
367 // we pin. In practice on a Linux/macOS box with no global git
368 // remote, the dirname is what we see.
369 assert!(!ns.is_empty(), "auto_namespace must be total");
370 }
371
372 #[test]
373 fn test_auto_namespace_dirname_branch_via_root_cwd() {
374 // Force-cd to "/" which has no file_name() component — exercises
375 // the `unwrap_or_else(|| "global".to_string())` arm of line 62.
376 // Combined with `GIT_CEILING_DIRECTORIES = /`, git also fails,
377 // so both branches in the fallback chain are observed.
378 let _g = cwd_lock();
379 let saved_cwd = std::env::current_dir()
380 .unwrap_or_else(|_| std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")));
381 let saved_ceiling = std::env::var("GIT_CEILING_DIRECTORIES").ok();
382
383 // SAFETY: serialized via `cwd_lock`.
384 unsafe {
385 std::env::set_var("GIT_CEILING_DIRECTORIES", "/");
386 }
387 std::env::set_current_dir("/").expect("cd /");
388
389 let ns = auto_namespace();
390
391 std::env::set_current_dir(&saved_cwd).expect("restore cwd");
392 // SAFETY: serialized via `cwd_lock`.
393 unsafe {
394 match saved_ceiling {
395 Some(v) => std::env::set_var("GIT_CEILING_DIRECTORIES", v),
396 None => std::env::remove_var("GIT_CEILING_DIRECTORIES"),
397 }
398 }
399
400 // The helper is total — must return non-empty.
401 assert!(!ns.is_empty(), "auto_namespace must be total");
402 }
403}