Skip to main content

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}