sqlite_graphrag/commands/
rename.rs1use 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 {
24 #[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 #[arg(long, alias = "old", alias = "from")]
33 pub name: Option<String>,
34 #[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 #[arg(long, alias = "new", alias = "to")]
43 pub new_name: Option<String>,
44 #[arg(long, help = "Namespace (flag / XDG namespace.default / global)")]
45 pub namespace: Option<String>,
47 #[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 #[arg(long, value_name = "UUID")]
58 pub session_id: Option<String>,
59 #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
61 pub format: JsonOutputFormat,
62 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
64 pub json: bool,
65 #[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 #[serde(skip_serializing_if = "Option::is_none")]
78 ghost_purged: Option<bool>,
79 elapsed_ms: u64,
81}
82
83pub 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 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 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 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 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}