Skip to main content

sqlite_graphrag/commands/
rename.rs

1//! Handler for the `rename` CLI subcommand.
2
3use crate::errors::AppError;
4use crate::i18n::errors_msg;
5use crate::output;
6use crate::output::JsonOutputFormat;
7use crate::paths::AppPaths;
8use crate::storage::connection::open_rw;
9use crate::storage::{memories, versions};
10use serde::Serialize;
11
12#[derive(clap::Args)]
13#[command(after_long_help = "EXAMPLES:\n  \
14    # Rename using two positional arguments (NAME NEW)\n  \
15    sqlite-graphrag rename onboarding welcome-guide\n\n  \
16    # Rename using the positional NAME + --new-name flag\n  \
17    sqlite-graphrag rename onboarding --new-name welcome-guide\n\n  \
18    # Rename using the named flag form\n  \
19    sqlite-graphrag rename --name onboarding --new-name welcome-guide\n\n  \
20    # Rename within a specific namespace\n  \
21    sqlite-graphrag rename onboarding welcome-guide --namespace my-project")]
22/// Rename args.
23pub struct RenameArgs {
24    /// Current memory name as a positional argument. Alternative to `--name` / `--old`.
25    #[arg(
26        value_name = "NAME",
27        conflicts_with = "name",
28        help = "Current memory name to rename; alternative to --name/--old"
29    )]
30    pub name_positional: Option<String>,
31    /// Current memory name. Also accepts the aliases `--old` and `--from` (since v1.0.35).
32    #[arg(long, alias = "old", alias = "from")]
33    pub name: Option<String>,
34    /// New memory name as a positional argument. Alternative to `--new-name`.
35    #[arg(
36        value_name = "NEW",
37        conflicts_with = "new_name",
38        help = "New memory name; alternative to --new-name/--new/--to"
39    )]
40    pub new_name_positional: Option<String>,
41    /// New memory name. Also accepts the aliases `--new` and `--to` (since v1.0.35).
42    #[arg(long, alias = "new", alias = "to")]
43    pub new_name: Option<String>,
44    #[arg(long, help = "Namespace (flag / XDG namespace.default / global)")]
45    /// Namespace scope.
46    pub namespace: Option<String>,
47    /// Optimistic locking: reject if the current updated_at does not match (exit 3).
48    #[arg(
49        long,
50        value_name = "EPOCH_OR_RFC3339",
51        value_parser = crate::parsers::parse_expected_updated_at,
52        long_help = "Optimistic lock: reject if updated_at does not match. \
53Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
54    )]
55    pub expected_updated_at: Option<i64>,
56    /// Optional session ID used to trace the origin of the change.
57    #[arg(long, value_name = "UUID")]
58    pub session_id: Option<String>,
59    /// Output format.
60    #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
61    pub format: JsonOutputFormat,
62    /// Emit machine-readable JSON on stdout.
63    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
64    pub json: bool,
65    /// Path to the SQLite database file.
66    #[arg(long)]
67    pub db: Option<String>,
68}
69
70#[derive(Serialize)]
71struct RenameResponse {
72    memory_id: i64,
73    name: String,
74    action: &'static str,
75    version: i64,
76    /// Set to `true` when a soft-deleted ghost occupying the target name was purged.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    ghost_purged: Option<bool>,
79    /// Total execution time in milliseconds from handler start to serialisation.
80    elapsed_ms: u64,
81}
82
83/// Run.
84pub fn run(args: RenameArgs) -> Result<(), AppError> {
85    let inicio = std::time::Instant::now();
86    let _ = args.format;
87    tracing::debug!(target: "rename", old = ?args.name, new = ?args.new_name, "renaming memory");
88    use crate::constants::*;
89
90    // Resolve current name from positional or --name/--old flag.
91    let name = args.name_positional.or(args.name).ok_or_else(|| {
92        AppError::Validation(crate::i18n::validation::name_required_positional_or_flag())
93    })?;
94    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
95
96    let raw_new_name = args.new_name.or(args.new_name_positional).ok_or_else(|| {
97        AppError::Validation(
98            "new name required: pass as positional <NEW> or via --new-name/--new/--to".to_string(),
99        )
100    })?;
101
102    // v1.0.20: trim_matches('-') also removes trailing/leading hyphens.
103    let normalized_new_name = {
104        let lower = raw_new_name.to_lowercase().replace(['_', ' '], "-");
105        let trimmed = lower.trim_matches('-').to_string();
106        if trimmed != raw_new_name {
107            tracing::warn!(target: "rename",
108                original = %raw_new_name,
109                normalized = %trimmed,
110                "new_name auto-normalized to kebab-case"
111            );
112        }
113        trimmed
114    };
115
116    if normalized_new_name == name {
117        return Err(AppError::Validation(
118            "source and target names are identical".to_string(),
119        ));
120    }
121
122    if normalized_new_name.starts_with("__") {
123        return Err(AppError::Validation(
124            crate::i18n::validation::reserved_name(),
125        ));
126    }
127
128    if normalized_new_name.is_empty() || normalized_new_name.len() > MAX_MEMORY_NAME_LEN {
129        return Err(AppError::Validation(
130            crate::i18n::validation::new_name_length(MAX_MEMORY_NAME_LEN),
131        ));
132    }
133
134    {
135        let slug_re = crate::constants::name_slug_regex();
136        if !slug_re.is_match(&normalized_new_name) {
137            return Err(AppError::Validation(
138                crate::i18n::validation::new_name_kebab(&normalized_new_name),
139            ));
140        }
141    }
142
143    let paths = AppPaths::resolve(args.db.as_deref())?;
144    crate::storage::connection::ensure_db_ready(&paths)?;
145    let mut conn = open_rw(&paths.db)?;
146
147    let (memory_id, current_updated_at, _) = memories::find_by_name(&conn, &namespace, &name)?
148        .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&name, &namespace)))?;
149
150    if let Some(expected) = args.expected_updated_at {
151        if expected != current_updated_at {
152            return Err(AppError::Conflict(errors_msg::optimistic_lock_conflict(
153                expected,
154                current_updated_at,
155            )));
156        }
157    }
158
159    let row = memories::read_by_name(&conn, &namespace, &name)?
160        .ok_or_else(|| AppError::Internal(anyhow::anyhow!("memory not found before rename")))?;
161
162    let memory_type = row.memory_type.clone();
163    let description = row.description.clone();
164    let body = row.body.clone();
165    let metadata = row.metadata.clone();
166
167    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
168
169    // G16: auto-purge soft-deleted ghost occupying the target name
170    let mut ghost_purged: Option<bool> = None;
171    if let Some((ghost_id, is_deleted)) =
172        memories::find_by_name_any_state(&tx, &namespace, &normalized_new_name)?
173    {
174        if is_deleted {
175            tracing::info!(target: "rename",
176                ghost_id,
177                name = %normalized_new_name,
178                "auto-purging soft-deleted ghost to free target name for rename"
179            );
180            tx.execute(
181                "DELETE FROM memory_versions WHERE memory_id = ?1",
182                rusqlite::params![ghost_id],
183            )?;
184            tx.execute(
185                "DELETE FROM memory_chunks WHERE memory_id = ?1",
186                rusqlite::params![ghost_id],
187            )?;
188            tx.execute(
189                "DELETE FROM memory_entities WHERE memory_id = ?1",
190                rusqlite::params![ghost_id],
191            )?;
192            tx.execute(
193                "DELETE FROM vec_memories WHERE memory_id = ?1",
194                rusqlite::params![ghost_id],
195            )?;
196            tx.execute(
197                "DELETE FROM memories WHERE id = ?1",
198                rusqlite::params![ghost_id],
199            )?;
200            ghost_purged = Some(true);
201        } else if ghost_id != memory_id {
202            return Err(AppError::Duplicate(format!(
203                "target name '{normalized_new_name}' is already occupied by active memory id {ghost_id}"
204            )));
205        }
206    }
207
208    let affected = if let Some(ts) = args.expected_updated_at {
209        tx.execute(
210            "UPDATE memories SET name=?2 WHERE id=?1 AND updated_at=?3 AND deleted_at IS NULL",
211            rusqlite::params![memory_id, normalized_new_name, ts],
212        )?
213    } else {
214        tx.execute(
215            "UPDATE memories SET name=?2 WHERE id=?1 AND deleted_at IS NULL",
216            rusqlite::params![memory_id, normalized_new_name],
217        )?
218    };
219
220    if affected == 0 {
221        return Err(AppError::Conflict(
222            "optimistic lock conflict: memory was modified by another process".to_string(),
223        ));
224    }
225
226    let next_v = versions::next_version(&tx, memory_id)?;
227
228    versions::insert_version(
229        &tx,
230        memory_id,
231        next_v,
232        &normalized_new_name,
233        &memory_type,
234        &description,
235        &body,
236        &metadata,
237        None,
238        "rename",
239    )?;
240
241    memories::sync_fts_after_update(
242        &tx,
243        memory_id,
244        &name,
245        &description,
246        &body,
247        &normalized_new_name,
248        &description,
249        &body,
250    )?;
251
252    tx.commit()?;
253
254    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
255
256    output::emit_json(&RenameResponse {
257        memory_id,
258        name: normalized_new_name,
259        action: "renamed",
260        version: next_v,
261        ghost_purged,
262        elapsed_ms: inicio.elapsed().as_millis() as u64,
263    })?;
264
265    Ok(())
266}
267
268#[cfg(test)]
269mod tests {
270    use crate::storage::memories::{insert, NewMemory};
271    use tempfile::TempDir;
272
273    fn setup_db() -> (TempDir, rusqlite::Connection) {
274        crate::storage::connection::register_vec_extension();
275        let dir = TempDir::new().unwrap();
276        let db_path = dir.path().join("test.db");
277        let mut conn = rusqlite::Connection::open(&db_path).unwrap();
278        crate::migrations::runner().run(&mut conn).unwrap();
279        (dir, conn)
280    }
281
282    fn new_memory(name: &str) -> NewMemory {
283        NewMemory {
284            namespace: "global".to_string(),
285            name: name.to_string(),
286            memory_type: "user".to_string(),
287            description: "desc".to_string(),
288            body: "corpo".to_string(),
289            body_hash: format!("hash-{name}"),
290            session_id: None,
291            source: "agent".to_string(),
292            metadata: serde_json::json!({}),
293        }
294    }
295
296    #[test]
297    fn rejects_new_name_with_double_underscore_prefix() {
298        use crate::errors::AppError;
299        let (_dir, conn) = setup_db();
300        insert(&conn, &new_memory("mem-teste")).unwrap();
301        drop(conn);
302
303        let err = AppError::Validation(
304            "names and namespaces starting with __ are reserved for internal use".to_string(),
305        );
306        assert!(err.to_string().contains("__"));
307        assert_eq!(err.exit_code(), 1);
308    }
309
310    #[test]
311    fn rejects_rename_to_same_name() {
312        use crate::errors::AppError;
313        let err = AppError::Validation(crate::i18n::validation::source_target_names_identical());
314        assert_eq!(err.exit_code(), 1);
315        let msg = err.to_string();
316        // Locale-safe: EN "identical" / PT "idênticos"
317        assert!(
318            msg.contains("identical") || msg.contains("idênticos") || msg.contains("idêntico"),
319            "got: {msg}"
320        );
321    }
322
323    #[test]
324    fn optimistic_lock_conflict_returns_exit_3() {
325        use crate::errors::AppError;
326        let err = AppError::Conflict(
327            "optimistic lock conflict: expected updated_at=100, but current is 200".to_string(),
328        );
329        assert_eq!(err.exit_code(), 3);
330    }
331}