sqlite_graphrag/commands/
forget.rs1use 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 rusqlite::{params, OptionalExtension};
10use serde::Serialize;
11
12#[derive(clap::Args)]
13#[command(after_long_help = "EXAMPLES:\n \
14 # Soft-delete a memory by name (positional form)\n \
15 sqlite-graphrag forget onboarding\n\n \
16 # Soft-delete using the named flag form\n \
17 sqlite-graphrag forget --name onboarding\n\n \
18 # Soft-delete from a specific namespace\n \
19 sqlite-graphrag forget onboarding --namespace my-project")]
20pub struct ForgetArgs {
21 #[arg(
23 value_name = "NAME",
24 conflicts_with = "name",
25 help = "Memory name to soft-delete; alternative to --name"
26 )]
27 pub name_positional: Option<String>,
28 #[arg(long)]
31 pub name: Option<String>,
32 #[arg(long, default_value = "global")]
33 pub namespace: Option<String>,
34 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
35 pub json: bool,
36 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
37 pub db: Option<String>,
38}
39
40#[derive(Serialize)]
41struct ForgetResponse {
42 action: String,
44 forgotten: bool,
46 name: String,
47 namespace: String,
48 deleted_at: Option<i64>,
50 elapsed_ms: u64,
52}
53
54pub fn run(args: ForgetArgs) -> Result<(), AppError> {
55 let start = std::time::Instant::now();
56 let name = args.name_positional.or(args.name).ok_or_else(|| {
58 AppError::Validation("name required: pass as positional argument or via --name".to_string())
59 })?;
60 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
61 let paths = AppPaths::resolve(args.db.as_deref())?;
62 crate::storage::connection::ensure_db_ready(&paths)?;
63
64 let conn = open_rw(&paths.db)?;
65
66 let probe: Option<(i64, Option<i64>)> = conn
70 .query_row(
71 "SELECT id, deleted_at FROM memories WHERE namespace = ?1 AND name = ?2",
72 params![namespace, name],
73 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Option<i64>>(1)?)),
74 )
75 .optional()?;
76
77 let (action, forgotten, deleted_at, memory_id) = match probe {
78 None => ("not_found", false, None, None),
79 Some((id, Some(existing))) => ("already_deleted", false, Some(existing), Some(id)),
80 Some((id, None)) => {
81 let ok = memories::soft_delete(&conn, &namespace, &name)?;
82 if !ok {
83 let current: Option<i64> = conn
86 .query_row(
87 "SELECT deleted_at FROM memories WHERE id = ?1",
88 params![id],
89 |r| r.get::<_, Option<i64>>(0),
90 )
91 .optional()?
92 .flatten();
93 ("already_deleted", false, current, Some(id))
94 } else {
95 let ts: Option<i64> = conn
96 .query_row(
97 "SELECT deleted_at FROM memories WHERE id = ?1",
98 params![id],
99 |r| r.get::<_, Option<i64>>(0),
100 )
101 .optional()?
102 .flatten();
103 ("soft_deleted", true, ts, Some(id))
104 }
105 }
106 };
107
108 if forgotten {
109 if let Some(id) = memory_id {
110 if let Err(e) = memories::delete_vec(&conn, id) {
115 tracing::warn!(memory_id = id, error = %e, "vec cleanup failed — orphan vector left");
116 }
117 }
118 }
119
120 let response = ForgetResponse {
121 action: action.to_string(),
122 forgotten,
123 name: name.clone(),
124 namespace: namespace.clone(),
125 deleted_at,
126 elapsed_ms: start.elapsed().as_millis() as u64,
127 };
128 output::emit_json(&response)?;
129
130 if action == "not_found" {
131 return Err(AppError::NotFound(errors_msg::memory_not_found(
132 &name, &namespace,
133 )));
134 }
135
136 Ok(())
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn forget_response_serializes_basic_fields() {
145 let resp = ForgetResponse {
146 action: "soft_deleted".to_string(),
147 forgotten: true,
148 name: "my-memory".to_string(),
149 namespace: "global".to_string(),
150 deleted_at: Some(1_700_000_000),
151 elapsed_ms: 5,
152 };
153 let json = serde_json::to_value(&resp).expect("serialization failed");
154 assert_eq!(json["action"], "soft_deleted");
155 assert_eq!(json["forgotten"], true);
156 assert_eq!(json["name"], "my-memory");
157 assert_eq!(json["namespace"], "global");
158 assert_eq!(json["deleted_at"], 1_700_000_000i64);
159 assert!(json["elapsed_ms"].is_number());
160 }
161
162 #[test]
163 fn forget_response_action_soft_deleted_implies_forgotten_true() {
164 let resp = ForgetResponse {
165 action: "soft_deleted".to_string(),
166 forgotten: true,
167 name: "test".to_string(),
168 namespace: "ns".to_string(),
169 deleted_at: Some(42),
170 elapsed_ms: 1,
171 };
172 assert_eq!(resp.action, "soft_deleted");
173 assert!(resp.forgotten);
174 assert_eq!(resp.deleted_at, Some(42));
175 }
176
177 #[test]
178 fn forget_response_already_deleted_preserves_timestamp() {
179 let resp = ForgetResponse {
180 action: "already_deleted".to_string(),
181 forgotten: false,
182 name: "abc".to_string(),
183 namespace: "my-project".to_string(),
184 deleted_at: Some(1_650_000_000),
185 elapsed_ms: 2,
186 };
187 let json = serde_json::to_value(&resp).expect("serialization failed");
188 assert_eq!(json["action"], "already_deleted");
189 assert_eq!(json["forgotten"], false);
190 assert_eq!(json["deleted_at"], 1_650_000_000i64);
191 }
192
193 #[test]
194 fn forget_response_not_found_emits_deleted_at_null() {
195 let resp = ForgetResponse {
196 action: "not_found".to_string(),
197 forgotten: false,
198 name: "phantom".to_string(),
199 namespace: "global".to_string(),
200 deleted_at: None,
201 elapsed_ms: 0,
202 };
203 let json = serde_json::to_value(&resp).expect("serialization failed");
204 assert_eq!(json["action"], "not_found");
205 assert_eq!(json["forgotten"], false);
206 assert!(json["deleted_at"].is_null());
207 assert_eq!(json["elapsed_ms"], 0u64);
208 }
209}