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//!
6//! ## Round-2 F11 — global-scope safety rail
7//!
8//! `forget --pattern <p>` and `forget --tier <t>` without `--namespace`
9//! delete across every namespace in the database. That has been the
10//! contract since v0.6.x, but it is a sharp edge: a typo in `--pattern`
11//! can wipe the operator's working set with no confirmation.
12//!
13//! v0.7.0 adds a `--confirm-global` flag. When `--namespace` is omitted
14//! AND (`--pattern` or `--tier` is set) the handler refuses to proceed
15//! unless `--confirm-global` is also present. `forget --id` is fine
16//! because the id is unambiguous; `forget --namespace` is fine because
17//! the blast radius is bounded.
18
19use crate::cli::CliOutput;
20use crate::{db, models};
21use anyhow::{Result, bail};
22use clap::Args;
23use models::Tier;
24use std::path::Path;
25
26#[derive(Args)]
27pub struct ForgetArgs {
28    #[arg(long, short)]
29    pub namespace: Option<String>,
30    #[arg(long, short)]
31    pub pattern: Option<String>,
32    #[arg(long, short)]
33    pub tier: Option<String>,
34    /// Round-2 F11 — required when `--namespace` is omitted and either
35    /// `--pattern` or `--tier` is set, since those flags then delete
36    /// across every namespace in the database. Without `--namespace`
37    /// the handler refuses to run without this confirmation.
38    #[arg(long, default_value_t = false)]
39    pub confirm_global: bool,
40}
41
42/// Round-2 F11 — return the safety-rail error string when the operator
43/// invoked a global-scope `forget` without the `--confirm-global`
44/// opt-in. Pulled out so the integration test in
45/// `tests/round2_f11_forget_safety.rs` can assert on the exact
46/// wording without coupling to handler-internal control flow.
47#[must_use]
48pub fn global_scope_forget_error_message() -> &'static str {
49    "global-scope forget requires --confirm-global; restrict with --namespace=<ns> for safety"
50}
51
52/// Round-2 F11 — predicate used by both the CLI handler and the
53/// integration test. Returns `true` when the args describe a
54/// global-scope delete (no `--namespace`, but `--pattern` or `--tier`
55/// set) and `--confirm-global` was NOT supplied.
56#[must_use]
57pub fn requires_global_confirmation(args: &ForgetArgs) -> bool {
58    let no_namespace = args.namespace.is_none();
59    let has_global_filter = args.pattern.is_some() || args.tier.is_some();
60    no_namespace && has_global_filter && !args.confirm_global
61}
62
63/// `forget` handler. Deletes (and archives) memories matching at least
64/// one of namespace/pattern/tier. CLI always passes `archive=true`.
65pub fn cmd_forget(
66    db_path: &Path,
67    args: &ForgetArgs,
68    json_out: bool,
69    out: &mut CliOutput<'_>,
70) -> Result<()> {
71    // Round-2 F11 — refuse global-scope deletes without explicit
72    // confirmation. The error is propagated via `bail!` (not stderr +
73    // process::exit) so test code can assert on the message without
74    // killing the test process.
75    if requires_global_confirmation(args) {
76        bail!(global_scope_forget_error_message());
77    }
78
79    let tier = args.tier.as_deref().and_then(Tier::from_str);
80    let conn = db::open(db_path)?;
81    match db::forget(
82        &conn,
83        args.namespace.as_deref(),
84        args.pattern.as_deref(),
85        tier.as_ref(),
86        true, // always archive from CLI
87    ) {
88        Ok(n) => {
89            if json_out {
90                writeln!(out.stdout, "{}", serde_json::json!({"deleted": n}))?;
91            } else {
92                writeln!(out.stdout, "forgot {n} memories")?;
93            }
94        }
95        Err(e) => {
96            writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e))?;
97            std::process::exit(1);
98        }
99    }
100    Ok(())
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::cli::test_utils::{TestEnv, seed_memory};
107
108    fn args() -> ForgetArgs {
109        ForgetArgs {
110            namespace: None,
111            pattern: None,
112            tier: None,
113            confirm_global: false,
114        }
115    }
116
117    #[test]
118    fn test_forget_by_namespace() {
119        let mut env = TestEnv::fresh();
120        let db = env.db_path.clone();
121        let _ = seed_memory(&db, "alpha", "a", "ca");
122        let _ = seed_memory(&db, "beta", "b", "cb");
123        let mut a = args();
124        a.namespace = Some("alpha".to_string());
125        {
126            let mut out = env.output();
127            cmd_forget(&db, &a, true, &mut out).unwrap();
128        }
129        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
130        assert_eq!(v["deleted"].as_u64().unwrap(), 1);
131        // beta still present.
132        let conn = db::open(&db).unwrap();
133        let still = db::list(
134            &conn,
135            Some("beta"),
136            None,
137            10,
138            0,
139            None,
140            None,
141            None,
142            None,
143            None,
144        )
145        .unwrap();
146        assert_eq!(still.len(), 1);
147    }
148
149    #[test]
150    fn test_forget_by_pattern() {
151        let mut env = TestEnv::fresh();
152        let db = env.db_path.clone();
153        let _ = seed_memory(&db, "ns", "apple pie", "yum");
154        let _ = seed_memory(&db, "ns", "banana split", "also yum");
155        let mut a = args();
156        a.pattern = Some("apple".to_string());
157        // Round-2 F11 — `forget --pattern` without `--namespace` is a
158        // global delete and now requires the operator opt-in.
159        a.confirm_global = true;
160        {
161            let mut out = env.output();
162            cmd_forget(&db, &a, true, &mut out).unwrap();
163        }
164        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
165        assert_eq!(v["deleted"].as_u64().unwrap(), 1);
166    }
167
168    #[test]
169    fn test_forget_by_tier() {
170        let mut env = TestEnv::fresh();
171        let db = env.db_path.clone();
172        let id_long = seed_memory(&db, "ns", "long-row", "x");
173        let _ = seed_memory(&db, "ns", "mid-row", "y");
174        {
175            let conn = db::open(&db).unwrap();
176            db::update(
177                &conn,
178                &id_long,
179                None,
180                None,
181                Some(&Tier::Long),
182                None,
183                None,
184                None,
185                None,
186                None,
187                None,
188            )
189            .unwrap();
190        }
191        let mut a = args();
192        a.tier = Some(Tier::Long.as_str().to_string());
193        // Round-2 F11 — `forget --tier` without `--namespace` requires
194        // the global confirmation flag.
195        a.confirm_global = true;
196        {
197            let mut out = env.output();
198            cmd_forget(&db, &a, true, &mut out).unwrap();
199        }
200        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
201        assert_eq!(v["deleted"].as_u64().unwrap(), 1);
202    }
203
204    #[test]
205    fn test_forget_combined_filters() {
206        let mut env = TestEnv::fresh();
207        let db = env.db_path.clone();
208        let _ = seed_memory(&db, "alpha", "apple-1", "x");
209        let _ = seed_memory(&db, "beta", "apple-2", "y");
210        let _ = seed_memory(&db, "alpha", "banana", "z");
211        let mut a = args();
212        a.namespace = Some("alpha".to_string());
213        a.pattern = Some("apple".to_string());
214        {
215            let mut out = env.output();
216            cmd_forget(&db, &a, true, &mut out).unwrap();
217        }
218        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
219        // Only the alpha+apple row should be removed.
220        assert_eq!(v["deleted"].as_u64().unwrap(), 1);
221        let conn = db::open(&db).unwrap();
222        let beta_apples = db::list(
223            &conn,
224            Some("beta"),
225            None,
226            10,
227            0,
228            None,
229            None,
230            None,
231            None,
232            None,
233        )
234        .unwrap();
235        assert_eq!(beta_apples.len(), 1);
236    }
237
238    #[test]
239    fn test_forget_no_filter_errors_or_no_op() {
240        // db::forget bails when no filter is supplied. The handler turns
241        // that into an stderr line + std::process::exit(1) — which we
242        // can't observe in-process. Surface the bail by calling db::forget
243        // directly so the test asserts the underlying contract.
244        let env = TestEnv::fresh();
245        let db = env.db_path.clone();
246        let _ = seed_memory(&db, "ns", "x", "y");
247        let conn = db::open(&db).unwrap();
248        let res = db::forget(&conn, None, None, None, false);
249        assert!(res.is_err(), "no-filter forget must error");
250        assert!(
251            res.unwrap_err()
252                .to_string()
253                .contains("at least one of namespace, pattern, or tier")
254        );
255    }
256
257    // ---- Round-2 F11 safety-rail unit tests ------------------------------
258
259    #[test]
260    fn requires_global_confirmation_pattern_no_namespace() {
261        let mut a = args();
262        a.pattern = Some("apple".into());
263        assert!(requires_global_confirmation(&a));
264    }
265
266    #[test]
267    fn requires_global_confirmation_tier_no_namespace() {
268        let mut a = args();
269        a.tier = Some(Tier::Long.as_str().into());
270        assert!(requires_global_confirmation(&a));
271    }
272
273    #[test]
274    fn does_not_require_confirmation_when_namespace_present() {
275        let mut a = args();
276        a.namespace = Some("ns".into());
277        a.pattern = Some("apple".into());
278        assert!(!requires_global_confirmation(&a));
279    }
280
281    #[test]
282    fn does_not_require_confirmation_when_only_namespace_set() {
283        let mut a = args();
284        a.namespace = Some("ns".into());
285        // No pattern, no tier — `forget --namespace=ns` is bounded.
286        assert!(!requires_global_confirmation(&a));
287    }
288
289    #[test]
290    fn does_not_require_confirmation_when_confirm_flag_set() {
291        let mut a = args();
292        a.pattern = Some("apple".into());
293        a.confirm_global = true;
294        assert!(!requires_global_confirmation(&a));
295    }
296
297    #[test]
298    fn cmd_forget_refuses_global_pattern_without_confirm() {
299        let mut env = TestEnv::fresh();
300        let db = env.db_path.clone();
301        let _ = seed_memory(&db, "ns", "apple pie", "yum");
302        let mut a = args();
303        a.pattern = Some("apple".into());
304        let mut out = env.output();
305        let res = cmd_forget(&db, &a, true, &mut out);
306        assert!(res.is_err(), "expected refusal");
307        let msg = res.unwrap_err().to_string();
308        assert!(msg.contains("--confirm-global"), "got: {msg}");
309    }
310
311    #[test]
312    fn cmd_forget_proceeds_with_confirm_global() {
313        let mut env = TestEnv::fresh();
314        let db = env.db_path.clone();
315        let _ = seed_memory(&db, "ns", "apple pie", "yum");
316        let _ = seed_memory(&db, "other", "apple cake", "yum");
317        let mut a = args();
318        a.pattern = Some("apple".into());
319        a.confirm_global = true;
320        {
321            let mut out = env.output();
322            cmd_forget(&db, &a, true, &mut out).unwrap();
323        }
324        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
325        // Both rows match — global delete succeeded under explicit
326        // confirmation.
327        assert_eq!(v["deleted"].as_u64().unwrap(), 2);
328    }
329
330    #[test]
331    fn test_forget_text_output_count() {
332        let mut env = TestEnv::fresh();
333        let db = env.db_path.clone();
334        let _ = seed_memory(&db, "ns", "a", "x");
335        let _ = seed_memory(&db, "ns", "b", "y");
336        let mut a = args();
337        a.namespace = Some("ns".to_string());
338        {
339            let mut out = env.output();
340            cmd_forget(&db, &a, false, &mut out).unwrap();
341        }
342        let stdout = env.stdout_str();
343        assert!(stdout.contains("forgot 2 memories"), "got: {stdout}");
344    }
345}