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