Skip to main content

ai_memory/cli/
forget.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_forget` migration. See `cli::store` for the design pattern.
5
6use crate::cli::CliOutput;
7use crate::{db, models};
8use anyhow::Result;
9use clap::Args;
10use models::Tier;
11use std::path::Path;
12
13#[derive(Args)]
14pub struct ForgetArgs {
15    #[arg(long, short)]
16    pub namespace: Option<String>,
17    #[arg(long, short)]
18    pub pattern: Option<String>,
19    #[arg(long, short)]
20    pub tier: Option<String>,
21}
22
23/// `forget` handler. Deletes (and archives) memories matching at least
24/// one of namespace/pattern/tier. CLI always passes `archive=true`.
25pub fn cmd_forget(
26    db_path: &Path,
27    args: &ForgetArgs,
28    json_out: bool,
29    out: &mut CliOutput<'_>,
30) -> Result<()> {
31    let tier = args.tier.as_deref().and_then(Tier::from_str);
32    let conn = db::open(db_path)?;
33    match db::forget(
34        &conn,
35        args.namespace.as_deref(),
36        args.pattern.as_deref(),
37        tier.as_ref(),
38        true, // always archive from CLI
39    ) {
40        Ok(n) => {
41            if json_out {
42                writeln!(out.stdout, "{}", serde_json::json!({"deleted": n}))?;
43            } else {
44                writeln!(out.stdout, "forgot {n} memories")?;
45            }
46        }
47        Err(e) => {
48            writeln!(out.stderr, "error: {e}")?;
49            std::process::exit(1);
50        }
51    }
52    Ok(())
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58    use crate::cli::test_utils::{TestEnv, seed_memory};
59
60    fn args() -> ForgetArgs {
61        ForgetArgs {
62            namespace: None,
63            pattern: None,
64            tier: None,
65        }
66    }
67
68    #[test]
69    fn test_forget_by_namespace() {
70        let mut env = TestEnv::fresh();
71        let db = env.db_path.clone();
72        let _ = seed_memory(&db, "alpha", "a", "ca");
73        let _ = seed_memory(&db, "beta", "b", "cb");
74        let mut a = args();
75        a.namespace = Some("alpha".to_string());
76        {
77            let mut out = env.output();
78            cmd_forget(&db, &a, true, &mut out).unwrap();
79        }
80        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
81        assert_eq!(v["deleted"].as_u64().unwrap(), 1);
82        // beta still present.
83        let conn = db::open(&db).unwrap();
84        let still = db::list(
85            &conn,
86            Some("beta"),
87            None,
88            10,
89            0,
90            None,
91            None,
92            None,
93            None,
94            None,
95        )
96        .unwrap();
97        assert_eq!(still.len(), 1);
98    }
99
100    #[test]
101    fn test_forget_by_pattern() {
102        let mut env = TestEnv::fresh();
103        let db = env.db_path.clone();
104        let _ = seed_memory(&db, "ns", "apple pie", "yum");
105        let _ = seed_memory(&db, "ns", "banana split", "also yum");
106        let mut a = args();
107        a.pattern = Some("apple".to_string());
108        {
109            let mut out = env.output();
110            cmd_forget(&db, &a, true, &mut out).unwrap();
111        }
112        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
113        assert_eq!(v["deleted"].as_u64().unwrap(), 1);
114    }
115
116    #[test]
117    fn test_forget_by_tier() {
118        let mut env = TestEnv::fresh();
119        let db = env.db_path.clone();
120        let id_long = seed_memory(&db, "ns", "long-row", "x");
121        let _ = seed_memory(&db, "ns", "mid-row", "y");
122        {
123            let conn = db::open(&db).unwrap();
124            db::update(
125                &conn,
126                &id_long,
127                None,
128                None,
129                Some(&Tier::Long),
130                None,
131                None,
132                None,
133                None,
134                None,
135                None,
136            )
137            .unwrap();
138        }
139        let mut a = args();
140        a.tier = Some("long".to_string());
141        {
142            let mut out = env.output();
143            cmd_forget(&db, &a, true, &mut out).unwrap();
144        }
145        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
146        assert_eq!(v["deleted"].as_u64().unwrap(), 1);
147    }
148
149    #[test]
150    fn test_forget_combined_filters() {
151        let mut env = TestEnv::fresh();
152        let db = env.db_path.clone();
153        let _ = seed_memory(&db, "alpha", "apple-1", "x");
154        let _ = seed_memory(&db, "beta", "apple-2", "y");
155        let _ = seed_memory(&db, "alpha", "banana", "z");
156        let mut a = args();
157        a.namespace = Some("alpha".to_string());
158        a.pattern = Some("apple".to_string());
159        {
160            let mut out = env.output();
161            cmd_forget(&db, &a, true, &mut out).unwrap();
162        }
163        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
164        // Only the alpha+apple row should be removed.
165        assert_eq!(v["deleted"].as_u64().unwrap(), 1);
166        let conn = db::open(&db).unwrap();
167        let beta_apples = db::list(
168            &conn,
169            Some("beta"),
170            None,
171            10,
172            0,
173            None,
174            None,
175            None,
176            None,
177            None,
178        )
179        .unwrap();
180        assert_eq!(beta_apples.len(), 1);
181    }
182
183    #[test]
184    fn test_forget_no_filter_errors_or_no_op() {
185        // db::forget bails when no filter is supplied. The handler turns
186        // that into an stderr line + std::process::exit(1) — which we
187        // can't observe in-process. Surface the bail by calling db::forget
188        // directly so the test asserts the underlying contract.
189        let env = TestEnv::fresh();
190        let db = env.db_path.clone();
191        let _ = seed_memory(&db, "ns", "x", "y");
192        let conn = db::open(&db).unwrap();
193        let res = db::forget(&conn, None, None, None, false);
194        assert!(res.is_err(), "no-filter forget must error");
195        assert!(
196            res.unwrap_err()
197                .to_string()
198                .contains("at least one of namespace, pattern, or tier")
199        );
200    }
201
202    #[test]
203    fn test_forget_text_output_count() {
204        let mut env = TestEnv::fresh();
205        let db = env.db_path.clone();
206        let _ = seed_memory(&db, "ns", "a", "x");
207        let _ = seed_memory(&db, "ns", "b", "y");
208        let mut a = args();
209        a.namespace = Some("ns".to_string());
210        {
211            let mut out = env.output();
212            cmd_forget(&db, &a, false, &mut out).unwrap();
213        }
214        let stdout = env.stdout_str();
215        assert!(stdout.contains("forgot 2 memories"), "got: {stdout}");
216    }
217}