sqlite_graphrag/commands/
edit.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, versions};
9use serde::Serialize;
10use std::io::Read as _;
11
12#[derive(clap::Args)]
13pub struct EditArgs {
14 #[arg(value_name = "NAME", conflicts_with = "name")]
16 pub name_positional: Option<String>,
17 #[arg(long)]
19 pub name: Option<String>,
20 #[arg(long, conflicts_with_all = ["body_file", "body_stdin"])]
22 pub body: Option<String>,
23 #[arg(long, conflicts_with_all = ["body", "body_stdin"])]
25 pub body_file: Option<std::path::PathBuf>,
26 #[arg(long, conflicts_with_all = ["body", "body_file"])]
28 pub body_stdin: bool,
29 #[arg(long)]
31 pub description: Option<String>,
32 #[arg(
33 long,
34 value_name = "EPOCH_OR_RFC3339",
35 value_parser = crate::parsers::parse_expected_updated_at,
36 long_help = "Optimistic lock: reject if updated_at does not match. \
37Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
38 )]
39 pub expected_updated_at: Option<i64>,
40 #[arg(long, default_value = "global")]
41 pub namespace: Option<String>,
42 #[arg(long, help = "No-op; JSON is always emitted on stdout")]
43 pub json: bool,
44 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
45 pub db: Option<String>,
46}
47
48#[derive(Serialize)]
49struct EditResponse {
50 memory_id: i64,
51 name: String,
52 action: String,
53 version: i64,
54 elapsed_ms: u64,
56}
57
58pub fn run(args: EditArgs) -> Result<(), AppError> {
59 use crate::constants::*;
60
61 let inicio = std::time::Instant::now();
62 let name = args.name_positional.or(args.name).ok_or_else(|| {
64 AppError::Validation("name required: pass as positional argument or via --name".to_string())
65 })?;
66 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
67
68 let paths = AppPaths::resolve(args.db.as_deref())?;
69 if !paths.db.exists() {
70 return Err(AppError::NotFound(errors_msg::database_not_found(
71 &paths.db.display().to_string(),
72 )));
73 }
74 let mut conn = open_rw(&paths.db)?;
75
76 let (memory_id, current_updated_at, _current_version) =
77 memories::find_by_name(&conn, &namespace, &name)?
78 .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&name, &namespace)))?;
79
80 if let Some(expected) = args.expected_updated_at {
81 if expected != current_updated_at {
82 return Err(AppError::Conflict(errors_msg::optimistic_lock_conflict(
83 expected,
84 current_updated_at,
85 )));
86 }
87 }
88
89 let mut raw_body: Option<String> = None;
90 if args.body.is_some() || args.body_file.is_some() || args.body_stdin {
91 let b = if let Some(b) = args.body {
92 b
93 } else if let Some(path) = &args.body_file {
94 std::fs::read_to_string(path).map_err(AppError::Io)?
95 } else {
96 let mut buf = String::new();
97 std::io::stdin()
98 .read_to_string(&mut buf)
99 .map_err(AppError::Io)?;
100 buf
101 };
102 if b.len() > MAX_MEMORY_BODY_LEN {
103 return Err(AppError::LimitExceeded(
104 crate::i18n::validation::body_exceeds(MAX_MEMORY_BODY_LEN),
105 ));
106 }
107 raw_body = Some(b);
108 }
109
110 if let Some(ref desc) = args.description {
111 if desc.len() > MAX_MEMORY_DESCRIPTION_LEN {
112 return Err(AppError::Validation(
113 crate::i18n::validation::description_exceeds(MAX_MEMORY_DESCRIPTION_LEN),
114 ));
115 }
116 }
117
118 let row = memories::read_by_name(&conn, &namespace, &name)?
119 .ok_or_else(|| AppError::Internal(anyhow::anyhow!("memory row not found after check")))?;
120
121 let new_body = raw_body.unwrap_or(row.body.clone());
122 let new_description = args.description.unwrap_or(row.description.clone());
123 let new_hash = blake3::hash(new_body.as_bytes()).to_hex().to_string();
124 let memory_type = row.memory_type.clone();
125 let metadata = row.metadata.clone();
126
127 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
128
129 let affected = if let Some(ts) = args.expected_updated_at {
130 tx.execute(
131 "UPDATE memories SET description=?2, body=?3, body_hash=?4
132 WHERE id=?1 AND updated_at=?5 AND deleted_at IS NULL",
133 rusqlite::params![memory_id, new_description, new_body, new_hash, ts],
134 )?
135 } else {
136 tx.execute(
137 "UPDATE memories SET description=?2, body=?3, body_hash=?4
138 WHERE id=?1 AND deleted_at IS NULL",
139 rusqlite::params![memory_id, new_description, new_body, new_hash],
140 )?
141 };
142
143 if affected == 0 {
144 return Err(AppError::Conflict(
145 "optimistic lock conflict: memory was modified by another process".to_string(),
146 ));
147 }
148
149 let next_v = versions::next_version(&tx, memory_id)?;
150
151 versions::insert_version(
152 &tx,
153 memory_id,
154 next_v,
155 &name,
156 &memory_type,
157 &new_description,
158 &new_body,
159 &metadata,
160 None,
161 "edit",
162 )?;
163
164 tx.commit()?;
165
166 output::emit_json(&EditResponse {
167 memory_id,
168 name,
169 action: "updated".to_string(),
170 version: next_v,
171 elapsed_ms: inicio.elapsed().as_millis() as u64,
172 })?;
173
174 Ok(())
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn edit_response_serializa_todos_campos() {
183 let resp = EditResponse {
184 memory_id: 42,
185 name: "minha-memoria".to_string(),
186 action: "updated".to_string(),
187 version: 3,
188 elapsed_ms: 7,
189 };
190 let json = serde_json::to_value(&resp).expect("serialização falhou");
191 assert_eq!(json["memory_id"], 42i64);
192 assert_eq!(json["name"], "minha-memoria");
193 assert_eq!(json["action"], "updated");
194 assert_eq!(json["version"], 3i64);
195 assert!(json["elapsed_ms"].is_number());
196 }
197
198 #[test]
199 fn edit_response_action_contem_updated() {
200 let resp = EditResponse {
201 memory_id: 1,
202 name: "n".to_string(),
203 action: "updated".to_string(),
204 version: 1,
205 elapsed_ms: 0,
206 };
207 assert_eq!(
208 resp.action, "updated",
209 "action deve ser 'updated' para edições bem-sucedidas"
210 );
211 }
212
213 #[test]
214 fn edit_body_excede_limite_retorna_erro() {
215 let limite = crate::constants::MAX_MEMORY_BODY_LEN;
216 let corpo_grande: String = "a".repeat(limite + 1);
217 assert!(
218 corpo_grande.len() > limite,
219 "corpo acima do limite deve ter tamanho > MAX_MEMORY_BODY_LEN"
220 );
221 }
222
223 #[test]
224 fn edit_description_excede_limite_retorna_erro() {
225 let limite = crate::constants::MAX_MEMORY_DESCRIPTION_LEN;
226 let desc_grande: String = "d".repeat(limite + 1);
227 assert!(
228 desc_grande.len() > limite,
229 "descrição acima do limite deve ter tamanho > MAX_MEMORY_DESCRIPTION_LEN"
230 );
231 }
232}