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;
10
11#[derive(clap::Args)]
12#[command(after_long_help = "EXAMPLES:\n  \
13    # Edit body inline\n  \
14    sqlite-graphrag edit onboarding --body \"updated content\"\n\n  \
15    # Edit body from a file\n  \
16    sqlite-graphrag edit onboarding --body-file ./updated.md\n\n  \
17    # Edit body from stdin (pipe)\n  \
18    cat updated.md | sqlite-graphrag edit onboarding --body-stdin\n\n  \
19    # Update only the description\n  \
20    sqlite-graphrag edit onboarding --description \"new short description\"")]
21pub struct EditArgs {
22    /// Memory name as a positional argument. Alternative to `--name`.
23    #[arg(
24        value_name = "NAME",
25        conflicts_with = "name",
26        help = "Memory name to edit; alternative to --name"
27    )]
28    pub name_positional: Option<String>,
29    /// Memory name to edit. Soft-deleted memories are not editable; use `restore` first.
30    #[arg(long)]
31    pub name: Option<String>,
32    /// New inline body content. Mutually exclusive with --body-file and --body-stdin.
33    #[arg(long, conflicts_with_all = ["body_file", "body_stdin"])]
34    pub body: Option<String>,
35    /// Read new body from a file. Mutually exclusive with --body and --body-stdin.
36    #[arg(long, conflicts_with_all = ["body", "body_stdin"])]
37    pub body_file: Option<std::path::PathBuf>,
38    /// Read new body from stdin until EOF. Mutually exclusive with --body and --body-file.
39    #[arg(long, conflicts_with_all = ["body", "body_file"])]
40    pub body_stdin: bool,
41    /// New description (≤500 chars) replacing the existing one.
42    #[arg(long)]
43    pub description: Option<String>,
44    /// Change the memory type (e.g. note, skill, decision).
45    #[arg(long, value_enum, help = "Change memory type")]
46    pub memory_type: Option<crate::cli::MemoryType>,
47    #[arg(
48        long,
49        value_name = "EPOCH_OR_RFC3339",
50        value_parser = crate::parsers::parse_expected_updated_at,
51        long_help = "Optimistic lock: reject if updated_at does not match. \
52Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
53    )]
54    pub expected_updated_at: Option<i64>,
55    #[arg(
56        long,
57        help = "Namespace (env: SQLITE_GRAPHRAG_NAMESPACE, default: global)"
58    )]
59    pub namespace: Option<String>,
60    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
61    pub json: bool,
62    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
63    pub db: Option<String>,
64}
65
66#[derive(Serialize)]
67struct EditResponse {
68    memory_id: i64,
69    name: String,
70    action: String,
71    version: i64,
72    /// Total execution time in milliseconds from handler start to serialisation.
73    elapsed_ms: u64,
74}
75
76pub fn run(args: EditArgs) -> Result<(), AppError> {
77    use crate::constants::*;
78
79    let inicio = std::time::Instant::now();
80    tracing::debug!(target: "edit", name = ?args.name_positional.as_deref().or(args.name.as_deref()), "updating memory");
81    // Resolve name from positional or --name flag; both are optional, at least one is required.
82    let name = args.name_positional.or(args.name).ok_or_else(|| {
83        AppError::Validation("name required: pass as positional argument or via --name".to_string())
84    })?;
85    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
86
87    let paths = AppPaths::resolve(args.db.as_deref())?;
88    crate::storage::connection::ensure_db_ready(&paths)?;
89    let mut conn = open_rw(&paths.db)?;
90
91    let (memory_id, current_updated_at, _current_version) =
92        memories::find_by_name(&conn, &namespace, &name)?
93            .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&name, &namespace)))?;
94
95    if let Some(expected) = args.expected_updated_at {
96        if expected != current_updated_at {
97            return Err(AppError::Conflict(errors_msg::optimistic_lock_conflict(
98                expected,
99                current_updated_at,
100            )));
101        }
102    }
103
104    let mut raw_body: Option<String> = None;
105    if args.body.is_some() || args.body_file.is_some() || args.body_stdin {
106        let b = if let Some(b) = args.body {
107            b
108        } else if let Some(path) = &args.body_file {
109            let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
110            if file_size > MAX_MEMORY_BODY_LEN as u64 {
111                return Err(AppError::LimitExceeded(
112                    crate::i18n::validation::body_exceeds(MAX_MEMORY_BODY_LEN),
113                ));
114            }
115            std::fs::read_to_string(path).map_err(AppError::Io)?
116        } else {
117            crate::stdin_helper::read_stdin_with_timeout(60)?
118        };
119        if b.len() > MAX_MEMORY_BODY_LEN {
120            return Err(AppError::LimitExceeded(
121                crate::i18n::validation::body_exceeds(MAX_MEMORY_BODY_LEN),
122            ));
123        }
124        raw_body = Some(b);
125    }
126
127    if let Some(ref desc) = args.description {
128        if desc.len() > MAX_MEMORY_DESCRIPTION_LEN {
129            return Err(AppError::Validation(
130                crate::i18n::validation::description_exceeds(MAX_MEMORY_DESCRIPTION_LEN),
131            ));
132        }
133    }
134
135    let row = memories::read_by_name(&conn, &namespace, &name)?
136        .ok_or_else(|| AppError::Internal(anyhow::anyhow!("memory row not found after check")))?;
137
138    let body_changed = raw_body.is_some();
139    let new_body = raw_body.unwrap_or(row.body.clone());
140    let new_description = args.description.unwrap_or(row.description.clone());
141    let new_hash = blake3::hash(new_body.as_bytes()).to_hex().to_string();
142    // Skip re-embedding when body content is identical to the stored version.
143    let body_changed = body_changed && new_hash != row.body_hash;
144    let memory_type = args
145        .memory_type
146        .map(|t| t.as_str().to_string())
147        .unwrap_or_else(|| row.memory_type.clone());
148    let type_changed = memory_type != row.memory_type;
149    let metadata = row.metadata.clone();
150
151    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
152
153    let affected = if let Some(ts) = args.expected_updated_at {
154        tx.execute(
155            "UPDATE memories SET description=?2, body=?3, body_hash=?4, type=?5
156             WHERE id=?1 AND updated_at=?6 AND deleted_at IS NULL",
157            rusqlite::params![
158                memory_id,
159                new_description,
160                new_body,
161                new_hash,
162                memory_type,
163                ts
164            ],
165        )?
166    } else {
167        tx.execute(
168            "UPDATE memories SET description=?2, body=?3, body_hash=?4, type=?5
169             WHERE id=?1 AND deleted_at IS NULL",
170            rusqlite::params![memory_id, new_description, new_body, new_hash, memory_type],
171        )?
172    };
173
174    if affected == 0 {
175        return Err(AppError::Conflict(
176            "optimistic lock conflict: memory was modified by another process".to_string(),
177        ));
178    }
179
180    if body_changed || type_changed {
181        output::emit_progress_i18n(
182            "Re-computing embedding for edited body...",
183            crate::i18n::validation::runtime_pt::edit_recomputing_embedding(),
184        );
185        let embedding = crate::daemon::embed_passage_or_local(&paths.models, &new_body)?;
186        let snippet: String = new_body.chars().take(300).collect();
187        memories::upsert_vec(
188            &tx,
189            memory_id,
190            &namespace,
191            &memory_type,
192            &embedding,
193            &name,
194            &snippet,
195        )?;
196    }
197
198    let next_v = versions::next_version(&tx, memory_id)?;
199
200    versions::insert_version(
201        &tx,
202        memory_id,
203        next_v,
204        &name,
205        &memory_type,
206        &new_description,
207        &new_body,
208        &metadata,
209        None,
210        "edit",
211    )?;
212
213    memories::sync_fts_after_update(
214        &tx,
215        memory_id,
216        &row.name,
217        &row.description,
218        &row.body,
219        &row.name,
220        &new_description,
221        &new_body,
222    )?;
223
224    tx.commit()?;
225
226    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
227
228    output::emit_json(&EditResponse {
229        memory_id,
230        name,
231        action: "updated".to_string(),
232        version: next_v,
233        elapsed_ms: inicio.elapsed().as_millis() as u64,
234    })?;
235
236    Ok(())
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn edit_response_serializes_all_fields() {
245        let resp = EditResponse {
246            memory_id: 42,
247            name: "my-memory".to_string(),
248            action: "updated".to_string(),
249            version: 3,
250            elapsed_ms: 7,
251        };
252        let json = serde_json::to_value(&resp).expect("serialization failed");
253        assert_eq!(json["memory_id"], 42i64);
254        assert_eq!(json["name"], "my-memory");
255        assert_eq!(json["action"], "updated");
256        assert_eq!(json["version"], 3i64);
257        assert!(json["elapsed_ms"].is_number());
258    }
259
260    #[test]
261    fn edit_response_action_contains_updated() {
262        let resp = EditResponse {
263            memory_id: 1,
264            name: "n".to_string(),
265            action: "updated".to_string(),
266            version: 1,
267            elapsed_ms: 0,
268        };
269        assert_eq!(
270            resp.action, "updated",
271            "action must be 'updated' for successful edits"
272        );
273    }
274
275    #[test]
276    fn edit_body_exceeds_limit_returns_error() {
277        let limit = crate::constants::MAX_MEMORY_BODY_LEN;
278        let large_body: String = "a".repeat(limit + 1);
279        assert!(
280            large_body.len() > limit,
281            "body above limit must have length > MAX_MEMORY_BODY_LEN"
282        );
283    }
284
285    #[test]
286    fn edit_description_exceeds_limit_returns_error() {
287        let limit = crate::constants::MAX_MEMORY_DESCRIPTION_LEN;
288        let large_desc: String = "d".repeat(limit + 1);
289        assert!(
290            large_desc.len() > limit,
291            "description above limit must have length > MAX_MEMORY_DESCRIPTION_LEN"
292        );
293    }
294}