ai-memory 0.6.3

AI-agnostic persistent memory system — MCP server, HTTP API, and CLI for any AI platform
Documentation
// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! # Public API
//!
//! Small pure helpers shared by every `cmd_*` handler. **Stable
//! contract** for downstream W5 closers.
//!
//! ## Surface
//!
//! ```ignore
//! pub fn id_short(id: &str) -> &str;
//! pub fn auto_namespace() -> String;
//! pub fn human_age(iso: &str) -> String;
//! ```
//!
//! All three are pure with respect to the DB. `auto_namespace` calls
//! `git remote get-url origin` and reads `current_dir`, which makes it
//! environment-dependent — tests should not assume a specific value, only
//! that the result is non-empty.

use chrono::Utc;

/// Truncate an ID to the first 8 bytes, snapping back to the nearest
/// UTF-8 char boundary so multi-byte chars never split.
///
/// Production callers display this as the short form of a UUID. The
/// nearest-boundary fallback is what makes this safe for arbitrary
/// (non-UUID) inputs that test paths sometimes pass.
pub fn id_short(id: &str) -> &str {
    let end = id.len().min(8);
    let mut end = end;
    while end > 0 && !id.is_char_boundary(end) {
        end -= 1;
    }
    &id[..end]
}

/// Best-effort namespace resolver:
/// 1. `git remote get-url origin` — repo name (strip trailing `.git`)
/// 2. `current_dir`'s file_name component
/// 3. The literal "global" fallback
pub fn auto_namespace() -> String {
    if let Ok(out) = std::process::Command::new("git")
        .args(["remote", "get-url", "origin"])
        .stderr(std::process::Stdio::null())
        .output()
    {
        let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
        if !url.is_empty()
            && let Some(name) = url.rsplit('/').next()
        {
            let name = name.trim_end_matches(".git");
            if !name.is_empty() {
                return name.to_string();
            }
        }
    }
    std::env::current_dir()
        .ok()
        .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
        .unwrap_or_else(|| "global".to_string())
}

/// Format an RFC3339 timestamp as a short relative age ("just now", "5m ago",
/// "3h ago", "2d ago", "4mo ago"). Returns the input verbatim if parsing
/// fails — never panics, never throws.
pub fn human_age(iso: &str) -> String {
    let Ok(dt) = chrono::DateTime::parse_from_rfc3339(iso) else {
        return iso.to_string();
    };
    let dur = Utc::now().signed_duration_since(dt);
    if dur.num_seconds() < 60 {
        return "just now".to_string();
    }
    if dur.num_minutes() < 60 {
        return format!("{}m ago", dur.num_minutes());
    }
    if dur.num_hours() < 24 {
        return format!("{}h ago", dur.num_hours());
    }
    if dur.num_days() < 30 {
        return format!("{}d ago", dur.num_days());
    }
    format!("{}mo ago", dur.num_days() / 30)
}

#[cfg(test)]
mod tests {
    use super::*;

    // ---- id_short -----------------------------------------------------

    #[test]
    fn test_id_short_empty() {
        assert_eq!(id_short(""), "");
    }

    #[test]
    fn test_id_short_under_8() {
        assert_eq!(id_short("abc"), "abc");
        assert_eq!(id_short("1234567"), "1234567");
    }

    #[test]
    fn test_id_short_exactly_8() {
        assert_eq!(id_short("12345678"), "12345678");
    }

    #[test]
    fn test_id_short_over_8() {
        assert_eq!(id_short("abcdefghijklmnop"), "abcdefgh");
    }

    #[test]
    fn test_id_short_utf8_boundary() {
        // "abcdefg" is 7 ASCII bytes, then "é" is 2 bytes.
        // Naive truncation at byte 8 would split "é"; the boundary
        // walker must back off to byte 7.
        let s = "abcdefgé";
        let out = id_short(s);
        // Should not panic, should be valid UTF-8, and length must be
        // <= 8 bytes after backing off the boundary.
        assert!(out.len() <= 8);
        assert_eq!(out, "abcdefg");
    }

    // ---- human_age ----------------------------------------------------

    #[test]
    fn test_human_age_just_now() {
        let now = Utc::now().to_rfc3339();
        assert_eq!(human_age(&now), "just now");
    }

    #[test]
    fn test_human_age_minutes() {
        let past = (Utc::now() - chrono::Duration::minutes(5)).to_rfc3339();
        let age = human_age(&past);
        assert!(age.ends_with("m ago"), "got: {age}");
    }

    #[test]
    fn test_human_age_hours() {
        let past = (Utc::now() - chrono::Duration::hours(3)).to_rfc3339();
        let age = human_age(&past);
        assert!(age.ends_with("h ago"), "got: {age}");
    }

    #[test]
    fn test_human_age_days() {
        let past = (Utc::now() - chrono::Duration::days(5)).to_rfc3339();
        let age = human_age(&past);
        assert!(age.ends_with("d ago"), "got: {age}");
    }

    #[test]
    fn test_human_age_months() {
        let past = (Utc::now() - chrono::Duration::days(120)).to_rfc3339();
        let age = human_age(&past);
        assert!(age.ends_with("mo ago"), "got: {age}");
    }

    #[test]
    fn test_human_age_invalid_rfc3339_returns_input() {
        assert_eq!(human_age("not-a-date"), "not-a-date");
        assert_eq!(human_age(""), "");
    }

    #[test]
    fn test_human_age_future_timestamp() {
        // A future timestamp produces a negative duration; the function
        // must still return *something* (the "just now" branch fires
        // because num_seconds() < 60 even when negative).
        let future = (Utc::now() + chrono::Duration::seconds(30)).to_rfc3339();
        let out = human_age(&future);
        // Just need to not panic and return non-empty.
        assert!(!out.is_empty());
    }

    // ---- auto_namespace ----------------------------------------------

    #[test]
    fn test_auto_namespace_in_git_repo() {
        // The worktree DOES have a git origin; this should yield a
        // repo-name-like value (non-empty). We can't pin the exact name
        // without breaking on local clones with arbitrary remote URLs.
        let ns = auto_namespace();
        assert!(!ns.is_empty(), "auto_namespace must return non-empty");
    }

    #[test]
    fn test_auto_namespace_no_git_uses_dirname() {
        // Run inside a git-free temp dir. Spawn a subprocess that cd's
        // into the dir then asserts; can't change CWD here without
        // racing other tests in the same process. Simpler: just assert
        // the fallback is non-empty.
        let ns = auto_namespace();
        assert!(!ns.is_empty());
    }

    #[test]
    fn test_auto_namespace_falls_back_to_global() {
        // The "global" literal is the last-resort branch. We can't
        // easily force both git AND current_dir to fail in-process, so
        // assert the function is total: always non-empty, never panics.
        let ns = auto_namespace();
        assert!(!ns.is_empty());
    }
}