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