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    #[arg(
45        long,
46        value_name = "EPOCH_OR_RFC3339",
47        value_parser = crate::parsers::parse_expected_updated_at,
48        long_help = "Optimistic lock: reject if updated_at does not match. \
49Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
50    )]
51    pub expected_updated_at: Option<i64>,
52    #[arg(
53        long,
54        help = "Namespace (env: SQLITE_GRAPHRAG_NAMESPACE, default: global)"
55    )]
56    pub namespace: Option<String>,
57    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
58    pub json: bool,
59    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
60    pub db: Option<String>,
61}
62
63#[derive(Serialize)]
64struct EditResponse {
65    memory_id: i64,
66    name: String,
67    action: String,
68    version: i64,
69    /// Total execution time in milliseconds from handler start to serialisation.
70    elapsed_ms: u64,
71}
72
73pub fn run(args: EditArgs) -> Result<(), AppError> {
74    use crate::constants::*;
75
76    let inicio = std::time::Instant::now();
77    // Resolve name from positional or --name flag; both are optional, at least one is required.
78    let name = args.name_positional.or(args.name).ok_or_else(|| {
79        AppError::Validation("name required: pass as positional argument or via --name".to_string())
80    })?;
81    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
82
83    let paths = AppPaths::resolve(args.db.as_deref())?;
84    crate::storage::connection::ensure_db_ready(&paths)?;
85    let mut conn = open_rw(&paths.db)?;
86
87    let (memory_id, current_updated_at, _current_version) =
88        memories::find_by_name(&conn, &namespace, &name)?
89            .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&name, &namespace)))?;
90
91    if let Some(expected) = args.expected_updated_at {
92        if expected != current_updated_at {
93            return Err(AppError::Conflict(errors_msg::optimistic_lock_conflict(
94                expected,
95                current_updated_at,
96            )));
97        }
98    }
99
100    let mut raw_body: Option<String> = None;
101    if args.body.is_some() || args.body_file.is_some() || args.body_stdin {
102        let b = if let Some(b) = args.body {
103            b
104        } else if let Some(path) = &args.body_file {
105            std::fs::read_to_string(path).map_err(AppError::Io)?
106        } else {
107            crate::stdin_helper::read_stdin_with_timeout(60)?
108        };
109        if b.len() > MAX_MEMORY_BODY_LEN {
110            return Err(AppError::LimitExceeded(
111                crate::i18n::validation::body_exceeds(MAX_MEMORY_BODY_LEN),
112            ));
113        }
114        raw_body = Some(b);
115    }
116
117    if let Some(ref desc) = args.description {
118        if desc.len() > MAX_MEMORY_DESCRIPTION_LEN {
119            return Err(AppError::Validation(
120                crate::i18n::validation::description_exceeds(MAX_MEMORY_DESCRIPTION_LEN),
121            ));
122        }
123    }
124
125    let row = memories::read_by_name(&conn, &namespace, &name)?
126        .ok_or_else(|| AppError::Internal(anyhow::anyhow!("memory row not found after check")))?;
127
128    let new_body = raw_body.unwrap_or(row.body.clone());
129    let new_description = args.description.unwrap_or(row.description.clone());
130    let new_hash = blake3::hash(new_body.as_bytes()).to_hex().to_string();
131    let memory_type = row.memory_type.clone();
132    let metadata = row.metadata.clone();
133
134    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
135
136    let affected = if let Some(ts) = args.expected_updated_at {
137        tx.execute(
138            "UPDATE memories SET description=?2, body=?3, body_hash=?4
139             WHERE id=?1 AND updated_at=?5 AND deleted_at IS NULL",
140            rusqlite::params![memory_id, new_description, new_body, new_hash, ts],
141        )?
142    } else {
143        tx.execute(
144            "UPDATE memories SET description=?2, body=?3, body_hash=?4
145             WHERE id=?1 AND deleted_at IS NULL",
146            rusqlite::params![memory_id, new_description, new_body, new_hash],
147        )?
148    };
149
150    if affected == 0 {
151        return Err(AppError::Conflict(
152            "optimistic lock conflict: memory was modified by another process".to_string(),
153        ));
154    }
155
156    let next_v = versions::next_version(&tx, memory_id)?;
157
158    versions::insert_version(
159        &tx,
160        memory_id,
161        next_v,
162        &name,
163        &memory_type,
164        &new_description,
165        &new_body,
166        &metadata,
167        None,
168        "edit",
169    )?;
170
171    memories::sync_fts_after_update(
172        &tx,
173        memory_id,
174        &row.name,
175        &row.description,
176        &row.body,
177        &row.name,
178        &new_description,
179        &new_body,
180    )?;
181
182    tx.commit()?;
183
184    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
185
186    output::emit_json(&EditResponse {
187        memory_id,
188        name,
189        action: "updated".to_string(),
190        version: next_v,
191        elapsed_ms: inicio.elapsed().as_millis() as u64,
192    })?;
193
194    Ok(())
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn edit_response_serializes_all_fields() {
203        let resp = EditResponse {
204            memory_id: 42,
205            name: "my-memory".to_string(),
206            action: "updated".to_string(),
207            version: 3,
208            elapsed_ms: 7,
209        };
210        let json = serde_json::to_value(&resp).expect("serialization failed");
211        assert_eq!(json["memory_id"], 42i64);
212        assert_eq!(json["name"], "my-memory");
213        assert_eq!(json["action"], "updated");
214        assert_eq!(json["version"], 3i64);
215        assert!(json["elapsed_ms"].is_number());
216    }
217
218    #[test]
219    fn edit_response_action_contains_updated() {
220        let resp = EditResponse {
221            memory_id: 1,
222            name: "n".to_string(),
223            action: "updated".to_string(),
224            version: 1,
225            elapsed_ms: 0,
226        };
227        assert_eq!(
228            resp.action, "updated",
229            "action must be 'updated' for successful edits"
230        );
231    }
232
233    #[test]
234    fn edit_body_exceeds_limit_returns_error() {
235        let limit = crate::constants::MAX_MEMORY_BODY_LEN;
236        let large_body: String = "a".repeat(limit + 1);
237        assert!(
238            large_body.len() > limit,
239            "body above limit must have length > MAX_MEMORY_BODY_LEN"
240        );
241    }
242
243    #[test]
244    fn edit_description_exceeds_limit_returns_error() {
245        let limit = crate::constants::MAX_MEMORY_DESCRIPTION_LEN;
246        let large_desc: String = "d".repeat(limit + 1);
247        assert!(
248            large_desc.len() > limit,
249            "description above limit must have length > MAX_MEMORY_DESCRIPTION_LEN"
250        );
251    }
252}