Skip to main content

sqlite_graphrag/commands/
edit.rs

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