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 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 #[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 #[arg(long, alias = "old", alias = "from")]
30 pub name: Option<String>,
31 #[arg(long, alias = "new", alias = "to")]
33 pub new_name: String,
34 #[arg(long, default_value = "global")]
35 pub namespace: Option<String>,
36 #[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 #[arg(long, value_name = "UUID")]
47 pub session_id: Option<String>,
48 #[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 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 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 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}