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!({(crate::models::field_names::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(
65                &serde_json::json!({(crate::models::field_names::NAMESPACES): ns})
66            )?
67        )?;
68        return Ok(());
69    }
70    if ns.is_empty() {
71        writeln!(out.stderr, "no namespaces")?;
72    } else {
73        for n in &ns {
74            writeln!(out.stdout, "  {}: {} memories", n.namespace, n.count)?;
75        }
76    }
77    Ok(())
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::cli::test_utils::{TestEnv, seed_memory};
84
85    #[test]
86    fn test_gc_empty_db() {
87        let mut env = TestEnv::fresh();
88        let db = env.db_path.clone();
89        let cfg = config::AppConfig::default();
90        {
91            let mut out = env.output();
92            run_gc(&db, false, &cfg, &mut out).unwrap();
93        }
94        assert!(env.stdout_str().contains("expired memories deleted: 0"));
95    }
96
97    #[test]
98    fn test_gc_json_output() {
99        let mut env = TestEnv::fresh();
100        let db = env.db_path.clone();
101        let cfg = config::AppConfig::default();
102        {
103            let mut out = env.output();
104            run_gc(&db, true, &cfg, &mut out).unwrap();
105        }
106        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
107        assert_eq!(v["expired_deleted"].as_u64().unwrap(), 0);
108    }
109
110    #[test]
111    fn test_gc_with_data_present() {
112        // Seed a memory with normal future expiry. gc should be a no-op.
113        let mut env = TestEnv::fresh();
114        let db = env.db_path.clone();
115        seed_memory(&db, "test-ns", "live", "still kicking");
116        let cfg = config::AppConfig::default();
117        {
118            let mut out = env.output();
119            run_gc(&db, false, &cfg, &mut out).unwrap();
120        }
121        assert!(env.stdout_str().contains("expired memories deleted:"));
122    }
123
124    #[test]
125    fn test_stats_on_empty_db() {
126        let mut env = TestEnv::fresh();
127        let db = env.db_path.clone();
128        {
129            let mut out = env.output();
130            run_stats(&db, false, &mut out).unwrap();
131        }
132        let s = env.stdout_str();
133        assert!(s.contains("total memories: 0"));
134        assert!(s.contains("links: 0"));
135    }
136
137    #[test]
138    fn test_stats_with_data() {
139        let mut env = TestEnv::fresh();
140        let db = env.db_path.clone();
141        seed_memory(&db, "ns-a", "t1", "c1");
142        seed_memory(&db, "ns-b", "t2", "c2");
143        {
144            let mut out = env.output();
145            run_stats(&db, false, &mut out).unwrap();
146        }
147        let s = env.stdout_str();
148        assert!(s.contains("total memories: 2"));
149        assert!(s.contains("by tier:"));
150        assert!(s.contains("by namespace:"));
151    }
152
153    #[test]
154    fn test_stats_json_output() {
155        let mut env = TestEnv::fresh();
156        let db = env.db_path.clone();
157        seed_memory(&db, "ns", "t", "c");
158        {
159            let mut out = env.output();
160            run_stats(&db, true, &mut out).unwrap();
161        }
162        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
163        assert_eq!(v["total"].as_u64().unwrap(), 1);
164    }
165
166    #[test]
167    fn test_stats_by_tier_breakdown() {
168        let mut env = TestEnv::fresh();
169        let db = env.db_path.clone();
170        seed_memory(&db, "ns", "t", "c");
171        {
172            let mut out = env.output();
173            run_stats(&db, true, &mut out).unwrap();
174        }
175        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
176        assert!(v["by_tier"].is_array());
177        assert!(v["by_namespace"].is_array());
178    }
179
180    #[test]
181    fn test_namespaces_empty_writes_stderr() {
182        let mut env = TestEnv::fresh();
183        let db = env.db_path.clone();
184        {
185            let mut out = env.output();
186            run_namespaces(&db, false, &mut out).unwrap();
187        }
188        assert!(env.stderr_str().contains("no namespaces"));
189        assert_eq!(env.stdout_str(), "");
190    }
191
192    #[test]
193    fn test_namespaces_with_data() {
194        let mut env = TestEnv::fresh();
195        let db = env.db_path.clone();
196        seed_memory(&db, "alpha", "t", "c");
197        seed_memory(&db, "beta", "t2", "c2");
198        {
199            let mut out = env.output();
200            run_namespaces(&db, false, &mut out).unwrap();
201        }
202        let s = env.stdout_str();
203        assert!(s.contains("alpha"));
204        assert!(s.contains("beta"));
205    }
206
207    #[test]
208    fn test_namespaces_json_output() {
209        let mut env = TestEnv::fresh();
210        let db = env.db_path.clone();
211        seed_memory(&db, "alpha", "t", "c");
212        {
213            let mut out = env.output();
214            run_namespaces(&db, true, &mut out).unwrap();
215        }
216        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
217        let arr = v["namespaces"].as_array().unwrap();
218        assert!(!arr.is_empty());
219    }
220
221    #[test]
222    fn test_namespaces_json_empty_array() {
223        let mut env = TestEnv::fresh();
224        let db = env.db_path.clone();
225        {
226            let mut out = env.output();
227            run_namespaces(&db, true, &mut out).unwrap();
228        }
229        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
230        assert_eq!(v["namespaces"].as_array().unwrap().len(), 0);
231    }
232}