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