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 a memory using the positional form\n  \
15    sqlite-graphrag rename onboarding --new-name welcome-guide\n\n  \
16    # Rename using the named flag form\n  \
17    sqlite-graphrag rename --name onboarding --new-name welcome-guide\n\n  \
18    # Rename within a specific namespace\n  \
19    sqlite-graphrag rename onboarding --new-name welcome-guide --namespace my-project")]
20pub struct RenameArgs {
21    /// Current memory name as a positional argument. Alternative to `--name` / `--old`.
22    #[arg(
23        value_name = "NAME",
24        conflicts_with = "name",
25        help = "Current memory name to rename; alternative to --name/--old"
26    )]
27    pub name_positional: Option<String>,
28    /// Current memory name. Also accepts the aliases `--old` and `--from` (since v1.0.35).
29    #[arg(long, alias = "old", alias = "from")]
30    pub name: Option<String>,
31    /// New memory name. Also accepts the aliases `--new` and `--to` (since v1.0.35).
32    #[arg(long, alias = "new", alias = "to")]
33    pub new_name: String,
34    #[arg(long, default_value = "global")]
35    pub namespace: Option<String>,
36    /// Optimistic locking: reject if the current updated_at does not match (exit 3).
37    #[arg(
38        long,
39        value_name = "EPOCH_OR_RFC3339",
40        value_parser = crate::parsers::parse_expected_updated_at,
41        long_help = "Optimistic lock: reject if updated_at does not match. \
42Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
43    )]
44    pub expected_updated_at: Option<i64>,
45    /// Optional session ID used to trace the origin of the change.
46    #[arg(long, value_name = "UUID")]
47    pub session_id: Option<String>,
48    /// Output format.
49    #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
50    pub format: JsonOutputFormat,
51    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
52    pub json: bool,
53    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
54    pub db: Option<String>,
55}
56
57#[derive(Serialize)]
58struct RenameResponse {
59    memory_id: i64,
60    name: String,
61    action: &'static str,
62    version: i64,
63    /// Total execution time in milliseconds from handler start to serialisation.
64    elapsed_ms: u64,
65}
66
67pub fn run(args: RenameArgs) -> Result<(), AppError> {
68    let inicio = std::time::Instant::now();
69    let _ = args.format;
70    use crate::constants::*;
71
72    // Resolve current name from positional or --name/--old flag.
73    let name = args.name_positional.or(args.name).ok_or_else(|| {
74        AppError::Validation("name required: pass as positional argument or via --name".to_string())
75    })?;
76    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
77
78    // v1.0.20: trim_matches('-') also removes trailing/leading hyphens.
79    let normalized_new_name = {
80        let lower = args.new_name.to_lowercase().replace(['_', ' '], "-");
81        let trimmed = lower.trim_matches('-').to_string();
82        if trimmed != args.new_name {
83            tracing::warn!(
84                original = %args.new_name,
85                normalized = %trimmed,
86                "new_name auto-normalized to kebab-case"
87            );
88        }
89        trimmed
90    };
91
92    if normalized_new_name.starts_with("__") {
93        return Err(AppError::Validation(
94            crate::i18n::validation::reserved_name(),
95        ));
96    }
97
98    if normalized_new_name.is_empty() || normalized_new_name.len() > MAX_MEMORY_NAME_LEN {
99        return Err(AppError::Validation(
100            crate::i18n::validation::new_name_length(MAX_MEMORY_NAME_LEN),
101        ));
102    }
103
104    {
105        let slug_re = regex::Regex::new(crate::constants::NAME_SLUG_REGEX)
106            .map_err(|e| AppError::Internal(anyhow::anyhow!("regex: {e}")))?;
107        if !slug_re.is_match(&normalized_new_name) {
108            return Err(AppError::Validation(
109                crate::i18n::validation::new_name_kebab(&normalized_new_name),
110            ));
111        }
112    }
113
114    let paths = AppPaths::resolve(args.db.as_deref())?;
115    crate::storage::connection::ensure_db_ready(&paths)?;
116    let mut conn = open_rw(&paths.db)?;
117
118    let (memory_id, current_updated_at, _) = memories::find_by_name(&conn, &namespace, &name)?
119        .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(&name, &namespace)))?;
120
121    if let Some(expected) = args.expected_updated_at {
122        if expected != current_updated_at {
123            return Err(AppError::Conflict(errors_msg::optimistic_lock_conflict(
124                expected,
125                current_updated_at,
126            )));
127        }
128    }
129
130    let row = memories::read_by_name(&conn, &namespace, &name)?
131        .ok_or_else(|| AppError::Internal(anyhow::anyhow!("memory not found before rename")))?;
132
133    let memory_type = row.memory_type.clone();
134    let description = row.description.clone();
135    let body = row.body.clone();
136    let metadata = row.metadata.clone();
137
138    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
139
140    let affected = if let Some(ts) = args.expected_updated_at {
141        tx.execute(
142            "UPDATE memories SET name=?2 WHERE id=?1 AND updated_at=?3 AND deleted_at IS NULL",
143            rusqlite::params![memory_id, normalized_new_name, ts],
144        )?
145    } else {
146        tx.execute(
147            "UPDATE memories SET name=?2 WHERE id=?1 AND deleted_at IS NULL",
148            rusqlite::params![memory_id, normalized_new_name],
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        &normalized_new_name,
165        &memory_type,
166        &description,
167        &body,
168        &metadata,
169        None,
170        "rename",
171    )?;
172
173    tx.commit()?;
174
175    output::emit_json(&RenameResponse {
176        memory_id,
177        name: normalized_new_name,
178        action: "renamed",
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 crate::storage::memories::{insert, NewMemory};
189    use tempfile::TempDir;
190
191    fn setup_db() -> (TempDir, rusqlite::Connection) {
192        crate::storage::connection::register_vec_extension();
193        let dir = TempDir::new().unwrap();
194        let db_path = dir.path().join("test.db");
195        let mut conn = rusqlite::Connection::open(&db_path).unwrap();
196        crate::migrations::runner().run(&mut conn).unwrap();
197        (dir, conn)
198    }
199
200    fn new_memory(name: &str) -> NewMemory {
201        NewMemory {
202            namespace: "global".to_string(),
203            name: name.to_string(),
204            memory_type: "user".to_string(),
205            description: "desc".to_string(),
206            body: "corpo".to_string(),
207            body_hash: format!("hash-{name}"),
208            session_id: None,
209            source: "agent".to_string(),
210            metadata: serde_json::json!({}),
211        }
212    }
213
214    #[test]
215    fn rejects_new_name_with_double_underscore_prefix() {
216        use crate::errors::AppError;
217        let (_dir, conn) = setup_db();
218        insert(&conn, &new_memory("mem-teste")).unwrap();
219        drop(conn);
220
221        let err = AppError::Validation(
222            "names and namespaces starting with __ are reserved for internal use".to_string(),
223        );
224        assert!(err.to_string().contains("__"));
225        assert_eq!(err.exit_code(), 1);
226    }
227
228    #[test]
229    fn optimistic_lock_conflict_returns_exit_3() {
230        use crate::errors::AppError;
231        let err = AppError::Conflict(
232            "optimistic lock conflict: expected updated_at=100, but current is 200".to_string(),
233        );
234        assert_eq!(err.exit_code(), 3);
235    }
236}