sqlite_graphrag/commands/
rename.rs1use crate::errors::AppError;
2use crate::i18n::erros;
3use crate::output;
4use crate::output::JsonOutputFormat;
5use crate::paths::AppPaths;
6use crate::storage::connection::open_rw;
7use crate::storage::{memories, versions};
8use serde::Serialize;
9
10#[derive(clap::Args)]
11pub struct RenameArgs {
12 #[arg(value_name = "NAME", conflicts_with = "name")]
14 pub name_positional: Option<String>,
15 #[arg(long, alias = "old")]
17 pub name: Option<String>,
18 #[arg(long, alias = "new")]
20 pub new_name: String,
21 #[arg(long, default_value = "global")]
22 pub namespace: Option<String>,
23 #[arg(
25 long,
26 value_name = "EPOCH_OR_RFC3339",
27 value_parser = crate::parsers::parse_expected_updated_at,
28 long_help = "Optimistic lock: reject if updated_at does not match. \
29Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
30 )]
31 pub expected_updated_at: Option<i64>,
32 #[arg(long, value_name = "UUID")]
34 pub session_id: Option<String>,
35 #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
37 pub format: JsonOutputFormat,
38 #[arg(long, help = "No-op; JSON is always emitted on stdout")]
39 pub json: bool,
40 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
41 pub db: Option<String>,
42}
43
44#[derive(Serialize)]
45struct RenameResponse {
46 memory_id: i64,
47 name: String,
48 action: &'static str,
49 version: i64,
50 elapsed_ms: u64,
52}
53
54pub fn run(args: RenameArgs) -> Result<(), AppError> {
55 let inicio = std::time::Instant::now();
56 let _ = args.format;
57 use crate::constants::*;
58
59 let name = args.name_positional.or(args.name).ok_or_else(|| {
61 AppError::Validation("name required: pass as positional argument or via --name".to_string())
62 })?;
63 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
64
65 let normalized_new_name = {
67 let lower = args.new_name.to_lowercase().replace(['_', ' '], "-");
68 let trimmed = lower.trim_matches('-').to_string();
69 if trimmed != args.new_name {
70 tracing::warn!(
71 original = %args.new_name,
72 normalized = %trimmed,
73 "new_name auto-normalized to kebab-case"
74 );
75 }
76 trimmed
77 };
78
79 if normalized_new_name.starts_with("__") {
80 return Err(AppError::Validation(
81 crate::i18n::validacao::nome_reservado(),
82 ));
83 }
84
85 if normalized_new_name.is_empty() || normalized_new_name.len() > MAX_MEMORY_NAME_LEN {
86 return Err(AppError::Validation(
87 crate::i18n::validacao::novo_nome_comprimento(MAX_MEMORY_NAME_LEN),
88 ));
89 }
90
91 {
92 let slug_re = regex::Regex::new(crate::constants::NAME_SLUG_REGEX)
93 .map_err(|e| AppError::Internal(anyhow::anyhow!("regex: {e}")))?;
94 if !slug_re.is_match(&normalized_new_name) {
95 return Err(AppError::Validation(
96 crate::i18n::validacao::novo_nome_kebab(&normalized_new_name),
97 ));
98 }
99 }
100
101 let paths = AppPaths::resolve(args.db.as_deref())?;
102 if !paths.db.exists() {
103 return Err(AppError::NotFound(erros::banco_nao_encontrado(
104 &paths.db.display().to_string(),
105 )));
106 }
107 let mut conn = open_rw(&paths.db)?;
108
109 let (memory_id, current_updated_at, _) = memories::find_by_name(&conn, &namespace, &name)?
110 .ok_or_else(|| AppError::NotFound(erros::memoria_nao_encontrada(&name, &namespace)))?;
111
112 if let Some(expected) = args.expected_updated_at {
113 if expected != current_updated_at {
114 return Err(AppError::Conflict(erros::conflito_optimistic_lock(
115 expected,
116 current_updated_at,
117 )));
118 }
119 }
120
121 let row = memories::read_by_name(&conn, &namespace, &name)?
122 .ok_or_else(|| AppError::Internal(anyhow::anyhow!("memory not found before rename")))?;
123
124 let memory_type = row.memory_type.clone();
125 let description = row.description.clone();
126 let body = row.body.clone();
127 let metadata = row.metadata.clone();
128
129 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
130
131 let affected = if let Some(ts) = args.expected_updated_at {
132 tx.execute(
133 "UPDATE memories SET name=?2 WHERE id=?1 AND updated_at=?3 AND deleted_at IS NULL",
134 rusqlite::params![memory_id, normalized_new_name, ts],
135 )?
136 } else {
137 tx.execute(
138 "UPDATE memories SET name=?2 WHERE id=?1 AND deleted_at IS NULL",
139 rusqlite::params![memory_id, normalized_new_name],
140 )?
141 };
142
143 if affected == 0 {
144 return Err(AppError::Conflict(
145 "optimistic lock conflict: memory was modified by another process".to_string(),
146 ));
147 }
148
149 let next_v = versions::next_version(&tx, memory_id)?;
150
151 versions::insert_version(
152 &tx,
153 memory_id,
154 next_v,
155 &normalized_new_name,
156 &memory_type,
157 &description,
158 &body,
159 &metadata,
160 None,
161 "rename",
162 )?;
163
164 tx.commit()?;
165
166 output::emit_json(&RenameResponse {
167 memory_id,
168 name: normalized_new_name,
169 action: "renamed",
170 version: next_v,
171 elapsed_ms: inicio.elapsed().as_millis() as u64,
172 })?;
173
174 Ok(())
175}
176
177#[cfg(test)]
178mod testes {
179 use crate::storage::memories::{insert, NewMemory};
180 use tempfile::TempDir;
181
182 fn setup_db() -> (TempDir, rusqlite::Connection) {
183 crate::storage::connection::register_vec_extension();
184 let dir = TempDir::new().unwrap();
185 let db_path = dir.path().join("test.db");
186 let mut conn = rusqlite::Connection::open(&db_path).unwrap();
187 crate::migrations::runner().run(&mut conn).unwrap();
188 (dir, conn)
189 }
190
191 fn nova_memoria(name: &str) -> NewMemory {
192 NewMemory {
193 namespace: "global".to_string(),
194 name: name.to_string(),
195 memory_type: "user".to_string(),
196 description: "desc".to_string(),
197 body: "corpo".to_string(),
198 body_hash: format!("hash-{name}"),
199 session_id: None,
200 source: "agent".to_string(),
201 metadata: serde_json::json!({}),
202 }
203 }
204
205 #[test]
206 fn rejeita_new_name_com_prefixo_duplo_underscore() {
207 use crate::errors::AppError;
208 let (_dir, conn) = setup_db();
209 insert(&conn, &nova_memoria("mem-teste")).unwrap();
210 drop(conn);
211
212 let err = AppError::Validation(
213 "names and namespaces starting with __ are reserved for internal use".to_string(),
214 );
215 assert!(err.to_string().contains("__"));
216 assert_eq!(err.exit_code(), 1);
217 }
218
219 #[test]
220 fn optimistic_lock_conflict_retorna_exit_3() {
221 use crate::errors::AppError;
222 let err = AppError::Conflict(
223 "optimistic lock conflict: expected updated_at=100, but current is 200".to_string(),
224 );
225 assert_eq!(err.exit_code(), 3);
226 }
227}