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