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