Skip to main content

ai_memory/cli/
gc.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_gc`, `cmd_stats`, and `cmd_namespaces` migrations. See
5//! `cli::store` for the design pattern.
6
7use crate::cli::CliOutput;
8use crate::{config, db};
9use anyhow::Result;
10use std::path::Path;
11
12/// `gc` handler.
13pub fn run_gc(
14    db_path: &Path,
15    json_out: bool,
16    app_config: &config::AppConfig,
17    out: &mut CliOutput<'_>,
18) -> Result<()> {
19    let conn = db::open(db_path)?;
20    let count = db::gc(&conn, app_config.effective_archive_on_gc())?;
21    if json_out {
22        writeln!(
23            out.stdout,
24            "{}",
25            serde_json::json!({"expired_deleted": count})
26        )?;
27    } else {
28        writeln!(out.stdout, "expired memories deleted: {count}")?;
29    }
30    Ok(())
31}
32
33/// `stats` handler.
34pub fn run_stats(db_path: &Path, json_out: bool, out: &mut CliOutput<'_>) -> Result<()> {
35    let conn = db::open(db_path)?;
36    let stats = db::stats(&conn, db_path)?;
37    if json_out {
38        writeln!(out.stdout, "{}", serde_json::to_string(&stats)?)?;
39        return Ok(());
40    }
41    writeln!(out.stdout, "total memories: {}", stats.total)?;
42    writeln!(out.stdout, "expiring within 1h: {}", stats.expiring_soon)?;
43    writeln!(out.stdout, "links: {}", stats.links_count)?;
44    writeln!(out.stdout, "database size: {} bytes", stats.db_size_bytes)?;
45    writeln!(out.stdout, "\nby tier:")?;
46    for t in &stats.by_tier {
47        writeln!(out.stdout, "  {}: {}", t.tier, t.count)?;
48    }
49    writeln!(out.stdout, "\nby namespace:")?;
50    for ns in &stats.by_namespace {
51        writeln!(out.stdout, "  {}: {}", ns.namespace, ns.count)?;
52    }
53    Ok(())
54}
55
56/// `namespaces` handler.
57pub fn run_namespaces(db_path: &Path, json_out: bool, out: &mut CliOutput<'_>) -> Result<()> {
58    let conn = db::open(db_path)?;
59    let ns = db::list_namespaces(&conn)?;
60    if json_out {
61        writeln!(
62            out.stdout,
63            "{}",
64            serde_json::to_string(&serde_json::json!({"namespaces": ns}))?
65        )?;
66        return Ok(());
67    }
68    if ns.is_empty() {
69        writeln!(out.stderr, "no namespaces")?;
70    } else {
71        for n in &ns {
72            writeln!(out.stdout, "  {}: {} memories", n.namespace, n.count)?;
73        }
74    }
75    Ok(())
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::cli::test_utils::{TestEnv, seed_memory};
82
83    #[test]
84    fn test_gc_empty_db() {
85        let mut env = TestEnv::fresh();
86        let db = env.db_path.clone();
87        let cfg = config::AppConfig::default();
88        {
89            let mut out = env.output();
90            run_gc(&db, false, &cfg, &mut out).unwrap();
91        }
92        assert!(env.stdout_str().contains("expired memories deleted: 0"));
93    }
94
95    #[test]
96    fn test_gc_json_output() {
97        let mut env = TestEnv::fresh();
98        let db = env.db_path.clone();
99        let cfg = config::AppConfig::default();
100        {
101            let mut out = env.output();
102            run_gc(&db, true, &cfg, &mut out).unwrap();
103        }
104        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
105        assert_eq!(v["expired_deleted"].as_u64().unwrap(), 0);
106    }
107
108    #[test]
109    fn test_gc_with_data_present() {
110        // Seed a memory with normal future expiry. gc should be a no-op.
111        let mut env = TestEnv::fresh();
112        let db = env.db_path.clone();
113        seed_memory(&db, "test-ns", "live", "still kicking");
114        let cfg = config::AppConfig::default();
115        {
116            let mut out = env.output();
117            run_gc(&db, false, &cfg, &mut out).unwrap();
118        }
119        assert!(env.stdout_str().contains("expired memories deleted:"));
120    }
121
122    #[test]
123    fn test_stats_on_empty_db() {
124        let mut env = TestEnv::fresh();
125        let db = env.db_path.clone();
126        {
127            let mut out = env.output();
128            run_stats(&db, false, &mut out).unwrap();
129        }
130        let s = env.stdout_str();
131        assert!(s.contains("total memories: 0"));
132        assert!(s.contains("links: 0"));
133    }
134
135    #[test]
136    fn test_stats_with_data() {
137        let mut env = TestEnv::fresh();
138        let db = env.db_path.clone();
139        seed_memory(&db, "ns-a", "t1", "c1");
140        seed_memory(&db, "ns-b", "t2", "c2");
141        {
142            let mut out = env.output();
143            run_stats(&db, false, &mut out).unwrap();
144        }
145        let s = env.stdout_str();
146        assert!(s.contains("total memories: 2"));
147        assert!(s.contains("by tier:"));
148        assert!(s.contains("by namespace:"));
149    }
150
151    #[test]
152    fn test_stats_json_output() {
153        let mut env = TestEnv::fresh();
154        let db = env.db_path.clone();
155        seed_memory(&db, "ns", "t", "c");
156        {
157            let mut out = env.output();
158            run_stats(&db, true, &mut out).unwrap();
159        }
160        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
161        assert_eq!(v["total"].as_u64().unwrap(), 1);
162    }
163
164    #[test]
165    fn test_stats_by_tier_breakdown() {
166        let mut env = TestEnv::fresh();
167        let db = env.db_path.clone();
168        seed_memory(&db, "ns", "t", "c");
169        {
170            let mut out = env.output();
171            run_stats(&db, true, &mut out).unwrap();
172        }
173        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
174        assert!(v["by_tier"].is_array());
175        assert!(v["by_namespace"].is_array());
176    }
177
178    #[test]
179    fn test_namespaces_empty_writes_stderr() {
180        let mut env = TestEnv::fresh();
181        let db = env.db_path.clone();
182        {
183            let mut out = env.output();
184            run_namespaces(&db, false, &mut out).unwrap();
185        }
186        assert!(env.stderr_str().contains("no namespaces"));
187        assert_eq!(env.stdout_str(), "");
188    }
189
190    #[test]
191    fn test_namespaces_with_data() {
192        let mut env = TestEnv::fresh();
193        let db = env.db_path.clone();
194        seed_memory(&db, "alpha", "t", "c");
195        seed_memory(&db, "beta", "t2", "c2");
196        {
197            let mut out = env.output();
198            run_namespaces(&db, false, &mut out).unwrap();
199        }
200        let s = env.stdout_str();
201        assert!(s.contains("alpha"));
202        assert!(s.contains("beta"));
203    }
204
205    #[test]
206    fn test_namespaces_json_output() {
207        let mut env = TestEnv::fresh();
208        let db = env.db_path.clone();
209        seed_memory(&db, "alpha", "t", "c");
210        {
211            let mut out = env.output();
212            run_namespaces(&db, true, &mut out).unwrap();
213        }
214        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
215        let arr = v["namespaces"].as_array().unwrap();
216        assert!(!arr.is_empty());
217    }
218
219    #[test]
220    fn test_namespaces_json_empty_array() {
221        let mut env = TestEnv::fresh();
222        let db = env.db_path.clone();
223        {
224            let mut out = env.output();
225            run_namespaces(&db, true, &mut out).unwrap();
226        }
227        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
228        assert_eq!(v["namespaces"].as_array().unwrap().len(), 0);
229    }
230}