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(long, default_value = "global")]
44 pub namespace: Option<String>,
45 #[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 #[arg(long, value_name = "UUID")]
56 pub session_id: Option<String>,
57 #[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 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 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 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}