Skip to main content

sqlite_graphrag/commands/
forget.rs

1//! Handler for the `forget` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::i18n::errors_msg;
5use crate::output;
6use crate::paths::AppPaths;
7use crate::storage::connection::open_rw;
8use crate::storage::memories;
9use serde::Serialize;
10
11#[derive(clap::Args)]
12pub struct ForgetArgs {
13    /// Memory name as a positional argument. Alternative to `--name`.
14    #[arg(value_name = "NAME", conflicts_with = "name")]
15    pub name_positional: Option<String>,
16    /// Memory name to soft-delete. The row is preserved with `deleted_at` set, recoverable via `restore`.
17    /// Use `purge` to permanently remove soft-deleted memories.
18    #[arg(long)]
19    pub name: Option<String>,
20    #[arg(long, default_value = "global")]
21    pub namespace: Option<String>,
22    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
23    pub json: bool,
24    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
25    pub db: Option<String>,
26}
27
28#[derive(Serialize)]
29struct ForgetResponse {
30    forgotten: bool,
31    name: String,
32    namespace: String,
33    /// Total execution time in milliseconds from handler start to serialisation.
34    elapsed_ms: u64,
35}
36
37pub fn run(args: ForgetArgs) -> Result<(), AppError> {
38    let inicio = std::time::Instant::now();
39    // Resolve name from positional or --name flag; both are optional, at least one is required.
40    let name = args.name_positional.or(args.name).ok_or_else(|| {
41        AppError::Validation("name required: pass as positional argument or via --name".to_string())
42    })?;
43    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
44    let paths = AppPaths::resolve(args.db.as_deref())?;
45    if !paths.db.exists() {
46        return Err(AppError::NotFound(errors_msg::database_not_found(
47            &paths.db.display().to_string(),
48        )));
49    }
50
51    let conn = open_rw(&paths.db)?;
52
53    let maybe_row = memories::read_by_name(&conn, &namespace, &name)?;
54    let forgotten = memories::soft_delete(&conn, &namespace, &name)?;
55
56    if !forgotten {
57        return Err(AppError::NotFound(errors_msg::memory_not_found(
58            &name, &namespace,
59        )));
60    }
61
62    if let Some(row) = maybe_row {
63        // FTS5 external-content: manual `DELETE FROM fts_memories WHERE rowid=?`
64        // corrompe o índice. A limpeza correta acontece via trigger `trg_fts_ad`
65        // quando `purge` remove fisicamente a linha de `memories`. Entre soft-delete
66        // e purge, as queries FTS filtram `m.deleted_at IS NULL` no JOIN.
67        if let Err(e) = memories::delete_vec(&conn, row.id) {
68            tracing::warn!(memory_id = row.id, error = %e, "vec cleanup failed — orphan vector left");
69        }
70    }
71
72    output::emit_json(&ForgetResponse {
73        forgotten: true,
74        name,
75        namespace,
76        elapsed_ms: inicio.elapsed().as_millis() as u64,
77    })?;
78
79    Ok(())
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn forget_response_serializa_campos_basicos() {
88        let resp = ForgetResponse {
89            forgotten: true,
90            name: "minha-memoria".to_string(),
91            namespace: "global".to_string(),
92            elapsed_ms: 5,
93        };
94        let json = serde_json::to_value(&resp).expect("serialização falhou");
95        assert_eq!(json["forgotten"], true);
96        assert_eq!(json["name"], "minha-memoria");
97        assert_eq!(json["namespace"], "global");
98        assert!(json["elapsed_ms"].is_number());
99    }
100
101    #[test]
102    fn forget_response_forgotten_true_indica_sucesso() {
103        let resp = ForgetResponse {
104            forgotten: true,
105            name: "teste".to_string(),
106            namespace: "ns".to_string(),
107            elapsed_ms: 1,
108        };
109        assert!(
110            resp.forgotten,
111            "forgotten deve ser true quando soft-delete bem-sucedido"
112        );
113    }
114
115    #[test]
116    fn forget_resposta_com_namespace_correto() {
117        let resp = ForgetResponse {
118            forgotten: true,
119            name: "abc".to_string(),
120            namespace: "meu-projeto".to_string(),
121            elapsed_ms: 0,
122        };
123        assert_eq!(
124            resp.namespace, "meu-projeto",
125            "namespace deve ser preservado na resposta"
126        );
127    }
128
129    #[test]
130    fn forget_elapsed_ms_zero_e_valido() {
131        let resp = ForgetResponse {
132            forgotten: true,
133            name: "qualquer".to_string(),
134            namespace: "global".to_string(),
135            elapsed_ms: 0,
136        };
137        let json = serde_json::to_value(&resp).expect("serialização falhou");
138        assert_eq!(json["elapsed_ms"], 0u64);
139    }
140}