Skip to main content

sqlite_graphrag/commands/
edit.rs

1//! Handler for the `edit` 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, versions};
9use serde::Serialize;
10use std::io::Read as _;
11
12#[derive(clap::Args)]
13pub struct EditArgs {
14    /// Memory name as a positional argument. Alternative to `--name`.
15    #[arg(value_name = "NAME", conflicts_with = "name")]
16    pub name_positional: Option<String>,
17    /// Memory name to edit. Soft-deleted memories are not editable; use `restore` first.
18    #[arg(long)]
19    pub name: Option<String>,
20    /// New inline body content. Mutually exclusive with --body-file and --body-stdin.
21    #[arg(long, conflicts_with_all = ["body_file", "body_stdin"])]
22    pub body: Option<String>,
23    /// Read new body from a file. Mutually exclusive with --body and --body-stdin.
24    #[arg(long, conflicts_with_all = ["body", "body_stdin"])]
25    pub body_file: Option<std::path::PathBuf>,
26    /// Read new body from stdin until EOF. Mutually exclusive with --body and --body-file.
27    #[arg(long, conflicts_with_all = ["body", "body_file"])]
28    pub body_stdin: bool,
29    /// New description (≤500 chars) replacing the existing one.
30    #[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    /// Total execution time in milliseconds from handler start to serialisation.
55    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    // Resolve name from positional or --name flag; both are optional, at least one is required.
63    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}