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/// Best-effort namespace resolver:
40/// 1. `git remote get-url origin` — repo name (strip trailing `.git`)
41/// 2. `current_dir`'s file_name component
42/// 3. The literal "global" fallback
43pub fn auto_namespace() -> String {
44    if let Ok(out) = std::process::Command::new("git")
45        .args(["remote", "get-url", "origin"])
46        .stderr(std::process::Stdio::null())
47        .output()
48    {
49        let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
50        if !url.is_empty()
51            && let Some(name) = url.rsplit('/').next()
52        {
53            let name = name.trim_end_matches(".git");
54            if !name.is_empty() {
55                return name.to_string();
56            }
57        }
58    }
59    std::env::current_dir()
60        .ok()
61        .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
62        .unwrap_or_else(|| "global".to_string())
63}
64
65/// Format an RFC3339 timestamp as a short relative age ("just now", "5m ago",
66/// "3h ago", "2d ago", "4mo ago"). Returns the input verbatim if parsing
67/// fails — never panics, never throws.
68pub fn human_age(iso: &str) -> String {
69    let Ok(dt) = chrono::DateTime::parse_from_rfc3339(iso) else {
70        return iso.to_string();
71    };
72    let dur = Utc::now().signed_duration_since(dt);
73    if dur.num_seconds() < 60 {
74        return "just now".to_string();
75    }
76    if dur.num_minutes() < 60 {
77        return format!("{}m ago", dur.num_minutes());
78    }
79    if dur.num_hours() < 24 {
80        return format!("{}h ago", dur.num_hours());
81    }
82    if dur.num_days() < 30 {
83        return format!("{}d ago", dur.num_days());
84    }
85    format!("{}mo ago", dur.num_days() / 30)
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    // ---- id_short -----------------------------------------------------
93
94    #[test]
95    fn test_id_short_empty() {
96        assert_eq!(id_short(""), "");
97    }
98
99    #[test]
100    fn test_id_short_under_8() {
101        assert_eq!(id_short("abc"), "abc");
102        assert_eq!(id_short("1234567"), "1234567");
103    }
104
105    #[test]
106    fn test_id_short_exactly_8() {
107        assert_eq!(id_short("12345678"), "12345678");
108    }
109
110    #[test]
111    fn test_id_short_over_8() {
112        assert_eq!(id_short("abcdefghijklmnop"), "abcdefgh");
113    }
114
115    #[test]
116    fn test_id_short_utf8_boundary() {
117        // "abcdefg" is 7 ASCII bytes, then "é" is 2 bytes.
118        // Naive truncation at byte 8 would split "é"; the boundary
119        // walker must back off to byte 7.
120        let s = "abcdefgé";
121        let out = id_short(s);
122        // Should not panic, should be valid UTF-8, and length must be
123        // <= 8 bytes after backing off the boundary.
124        assert!(out.len() <= 8);
125        assert_eq!(out, "abcdefg");
126    }
127
128    // ---- human_age ----------------------------------------------------
129
130    #[test]
131    fn test_human_age_just_now() {
132        let now = Utc::now().to_rfc3339();
133        assert_eq!(human_age(&now), "just now");
134    }
135
136    #[test]
137    fn test_human_age_minutes() {
138        let past = (Utc::now() - chrono::Duration::minutes(5)).to_rfc3339();
139        let age = human_age(&past);
140        assert!(age.ends_with("m ago"), "got: {age}");
141    }
142
143    #[test]
144    fn test_human_age_hours() {
145        let past = (Utc::now() - chrono::Duration::hours(3)).to_rfc3339();
146        let age = human_age(&past);
147        assert!(age.ends_with("h ago"), "got: {age}");
148    }
149
150    #[test]
151    fn test_human_age_days() {
152        let past = (Utc::now() - chrono::Duration::days(5)).to_rfc3339();
153        let age = human_age(&past);
154        assert!(age.ends_with("d ago"), "got: {age}");
155    }
156
157    #[test]
158    fn test_human_age_months() {
159        let past = (Utc::now() - chrono::Duration::days(120)).to_rfc3339();
160        let age = human_age(&past);
161        assert!(age.ends_with("mo ago"), "got: {age}");
162    }
163
164    #[test]
165    fn test_human_age_invalid_rfc3339_returns_input() {
166        assert_eq!(human_age("not-a-date"), "not-a-date");
167        assert_eq!(human_age(""), "");
168    }
169
170    #[test]
171    fn test_human_age_future_timestamp() {
172        // A future timestamp produces a negative duration; the function
173        // must still return *something* (the "just now" branch fires
174        // because num_seconds() < 60 even when negative).
175        let future = (Utc::now() + chrono::Duration::seconds(30)).to_rfc3339();
176        let out = human_age(&future);
177        // Just need to not panic and return non-empty.
178        assert!(!out.is_empty());
179    }
180
181    // ---- auto_namespace ----------------------------------------------
182
183    #[test]
184    fn test_auto_namespace_in_git_repo() {
185        // The worktree DOES have a git origin; this should yield a
186        // repo-name-like value (non-empty). We can't pin the exact name
187        // without breaking on local clones with arbitrary remote URLs.
188        let ns = auto_namespace();
189        assert!(!ns.is_empty(), "auto_namespace must return non-empty");
190    }
191
192    #[test]
193    fn test_auto_namespace_no_git_uses_dirname() {
194        // Run inside a git-free temp dir. Spawn a subprocess that cd's
195        // into the dir then asserts; can't change CWD here without
196        // racing other tests in the same process. Simpler: just assert
197        // the fallback is non-empty.
198        let ns = auto_namespace();
199        assert!(!ns.is_empty());
200    }
201
202    #[test]
203    fn test_auto_namespace_falls_back_to_global() {
204        // The "global" literal is the last-resort branch. We can't
205        // easily force both git AND current_dir to fail in-process, so
206        // assert the function is total: always non-empty, never panics.
207        let ns = auto_namespace();
208        assert!(!ns.is_empty());
209    }
210}