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 {
23 #[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 #[arg(long, alias = "old", alias = "from")]
32 pub name: Option<String>,
33 #[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 #[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 #[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 #[arg(long, value_name = "UUID")]
59 pub session_id: Option<String>,
60 #[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 #[serde(skip_serializing_if = "Option::is_none")]
77 ghost_purged: Option<bool>,
78 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 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 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 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}