1use crate::entity_type::EntityType;
8use crate::errors::AppError;
9use crate::i18n::errors_msg;
10use crate::output::{self, OutputFormat};
11use crate::paths::AppPaths;
12use crate::storage::connection::open_rw;
13use crate::storage::entities;
14use rusqlite::params;
15use serde::Serialize;
16
17#[derive(clap::Args)]
18#[command(after_long_help = "EXAMPLES:\n \
19 # Rename an entity\n \
20 sqlite-graphrag rename-entity --name old-name --new-name new-name\n\n \
21 # Rename with namespace\n \
22 sqlite-graphrag rename-entity --name auth --new-name authentication --namespace my-project\n\n \
23 # Rename by ID (unambiguous when homonyms exist across namespaces)\n \
24 sqlite-graphrag rename-entity --id 42 --new-name authentication")]
25pub struct RenameEntityArgs {
27 #[arg(
29 long,
30 value_name = "NAME",
31 required_unless_present = "id",
32 conflicts_with = "id"
33 )]
34 pub name: Option<String>,
35 #[arg(long, value_name = "ID")]
39 pub id: Option<i64>,
40 #[arg(long, value_name = "NEW_NAME")]
42 pub new_name: String,
43 #[arg(long)]
45 pub namespace: Option<String>,
46 #[arg(long, value_enum, default_value = "json")]
48 pub format: OutputFormat,
49 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
51 pub json: bool,
52 #[arg(long)]
54 pub db: Option<String>,
55}
56
57#[derive(Serialize)]
58struct RenameEntityResponse {
59 action: String,
60 old_name: String,
61 new_name: String,
62 entity_id: i64,
63 namespace: String,
64 elapsed_ms: u64,
65}
66
67fn lookup_entity_by_id(
71 conn: &rusqlite::Connection,
72 namespace: &str,
73 id: i64,
74) -> Result<(i64, EntityType, String), AppError> {
75 let mut stmt = conn
76 .prepare_cached("SELECT id, type, name FROM entities WHERE id = ?1 AND namespace = ?2")?;
77 match stmt.query_row(params![id, namespace], |r| {
78 Ok((
79 r.get::<_, i64>(0)?,
80 r.get::<_, EntityType>(1)?,
81 r.get::<_, String>(2)?,
82 ))
83 }) {
84 Ok(row) => Ok(row),
85 Err(rusqlite::Error::QueryReturnedNoRows) => Err(AppError::NotFound(format!(
86 "entity id={id} not found in namespace '{namespace}'"
87 ))),
88 Err(e) => Err(AppError::Database(e)),
89 }
90}
91
92pub fn run(
94 args: RenameEntityArgs,
95 llm_backend: crate::cli::LlmBackendChoice,
96 embedding_backend: crate::cli::EmbeddingBackendChoice,
97) -> Result<(), AppError> {
98 let start = std::time::Instant::now();
99 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
100 let paths = AppPaths::resolve(args.db.as_deref())?;
101
102 crate::storage::connection::ensure_db_ready(&paths)?;
103
104 let mut conn = open_rw(&paths.db)?;
105
106 let (entity_id, entity_type, old_name) = match args.id {
110 Some(id) => lookup_entity_by_id(&conn, &namespace, id)?,
111 None => {
112 let Some(ref raw_name) = args.name else {
113 return Err(AppError::Validation(
114 "--name or --id is required".to_string(),
115 ));
116 };
117 let lookup_name = crate::parsers::normalize_entity_name(raw_name);
119 let mut stmt = conn.prepare_cached(
120 "SELECT id, type FROM entities WHERE namespace = ?1 AND name = ?2",
121 )?;
122 match stmt.query_row(params![namespace, lookup_name], |r| {
123 Ok((r.get::<_, i64>(0)?, r.get::<_, EntityType>(1)?))
124 }) {
125 Ok((id, ty)) => (id, ty, lookup_name),
126 Err(rusqlite::Error::QueryReturnedNoRows) => {
127 return Err(AppError::NotFound(errors_msg::entity_not_found(
128 raw_name, &namespace,
129 )))
130 }
131 Err(e) => return Err(AppError::Database(e)),
132 }
133 }
134 };
135
136 entities::validate_entity_name(&args.new_name)?;
139 let new_name = crate::parsers::normalize_entity_name(&args.new_name);
140
141 if old_name == new_name {
142 return Err(AppError::Validation(
143 "source and target entity names are identical".to_string(),
144 ));
145 }
146
147 if entities::find_entity_id(&conn, &namespace, &new_name)?.is_some() {
149 return Err(AppError::Validation(
150 crate::i18n::validation::entity_name_already_exists(&new_name, &namespace),
151 ));
152 }
153
154 let skip_embed = crate::embedder::should_skip_embedding_on_failure();
158 let embedding: Option<Vec<f32>> = match crate::embedder::embed_passage_with_embedding_choice(
159 &paths.models,
160 &new_name,
161 embedding_backend,
162 llm_backend,
163 ) {
164 Ok((emb, _backend)) if emb.is_empty() => None,
165 Ok((emb, _backend)) => Some(emb),
166 Err(
169 e @ (AppError::Validation(_)
170 | AppError::BodyTooLarge { .. }
171 | AppError::TooManyTokens { .. }),
172 ) => return Err(e),
173 Err(e) if skip_embed => {
174 tracing::warn!(error = %e, "rename-entity: embedding failed; --skip-embedding-on-failure active, persisting without embedding");
175 None
176 }
177 Err(e) => return Err(e),
178 };
179
180 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
181 tx.execute(
182 "UPDATE entities SET name = ?1, updated_at = unixepoch() WHERE id = ?2",
183 params![new_name, entity_id],
184 )?;
185 if let Some(ref emb) = embedding {
190 entities::upsert_entity_vec(&tx, entity_id, &namespace, entity_type, emb, &new_name)?;
191 }
192 tx.commit()?;
193
194 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
195
196 let response = RenameEntityResponse {
197 action: "renamed".to_string(),
198 old_name,
199 new_name,
200 entity_id,
201 namespace: namespace.clone(),
202 elapsed_ms: start.elapsed().as_millis() as u64,
203 };
204
205 match args.format {
206 OutputFormat::Json => output::emit_json(&response)?,
207 OutputFormat::Text | OutputFormat::Markdown => {
208 output::emit_text(&format!(
209 "renamed entity: '{}' → '{}' [{}]",
210 response.old_name, response.new_name, response.namespace
211 ));
212 }
213 }
214
215 Ok(())
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
225 fn lookup_entity_by_id_disambiguates_homonyms_across_namespaces() {
226 let conn = rusqlite::Connection::open_in_memory().unwrap();
227 conn.execute_batch(
228 "CREATE TABLE entities (
229 id INTEGER PRIMARY KEY,
230 namespace TEXT NOT NULL,
231 name TEXT NOT NULL,
232 type TEXT NOT NULL,
233 UNIQUE(namespace, name)
234 );",
235 )
236 .unwrap();
237 conn.execute(
238 "INSERT INTO entities (id, namespace, name, type)
239 VALUES (1, 'ns-a', 'auth', 'concept'), (2, 'ns-b', 'auth', 'tool')",
240 [],
241 )
242 .unwrap();
243
244 let (id, ty, name) = lookup_entity_by_id(&conn, "ns-b", 2).unwrap();
245 assert_eq!(id, 2);
246 assert_eq!(name, "auth");
247 assert_eq!(ty, EntityType::Tool);
248
249 let err = lookup_entity_by_id(&conn, "ns-b", 1).unwrap_err();
250 assert_eq!(err.exit_code(), 4, "cross-namespace ID must be NotFound");
251 assert!(err.to_string().contains("id=1"), "obtido: {err}");
252 }
253
254 #[derive(clap::Parser)]
257 struct TestCli {
258 #[command(flatten)]
259 args: RenameEntityArgs,
260 }
261
262 #[test]
263 fn clap_rejects_name_combined_with_id() {
264 use clap::Parser;
265 let err =
266 match TestCli::try_parse_from(["t", "--name", "auth", "--id", "42", "--new-name", "x"])
267 {
268 Ok(_) => panic!("expected argument conflict"),
269 Err(e) => e,
270 };
271 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
272 }
273
274 #[test]
275 fn clap_requires_name_or_id() {
276 use clap::Parser;
277 assert!(TestCli::try_parse_from(["t", "--new-name", "x"]).is_err());
278 let ok = match TestCli::try_parse_from(["t", "--id", "7", "--new-name", "x"]) {
279 Ok(cli) => cli,
280 Err(e) => panic!("expected successful parse: {e}"),
281 };
282 assert_eq!(ok.args.id, Some(7));
283 assert!(ok.args.name.is_none());
284 }
285
286 #[test]
287 fn rename_entity_response_serializes_all_fields() {
288 let resp = RenameEntityResponse {
289 action: "renamed".to_string(),
290 old_name: "auth".to_string(),
291 new_name: "authentication".to_string(),
292 entity_id: 42,
293 namespace: "global".to_string(),
294 elapsed_ms: 7,
295 };
296 let json = serde_json::to_value(&resp).expect("serialization failed");
297 assert_eq!(json["action"], "renamed");
298 assert_eq!(json["old_name"], "auth");
299 assert_eq!(json["new_name"], "authentication");
300 assert_eq!(json["entity_id"], 42);
301 assert_eq!(json["namespace"], "global");
302 assert!(json["elapsed_ms"].is_number());
303 }
304
305 #[test]
306 fn rename_entity_response_action_is_renamed() {
307 let resp = RenameEntityResponse {
308 action: "renamed".to_string(),
309 old_name: "x".to_string(),
310 new_name: "y".to_string(),
311 entity_id: 1,
312 namespace: "ns".to_string(),
313 elapsed_ms: 1,
314 };
315 assert_eq!(resp.action, "renamed");
316 }
317
318 #[test]
319 fn rename_entity_response_entity_id_preserved() {
320 let resp = RenameEntityResponse {
321 action: "renamed".to_string(),
322 old_name: "old".to_string(),
323 new_name: "new".to_string(),
324 entity_id: 999,
325 namespace: "test-ns".to_string(),
326 elapsed_ms: 5,
327 };
328 let json = serde_json::to_value(&resp).expect("serialization failed");
329 assert_eq!(json["entity_id"], 999);
330 }
331
332 #[test]
333 fn rejects_rename_entity_to_same_name() {
334 use crate::errors::AppError;
335 let err =
336 AppError::Validation(crate::i18n::validation::source_target_entity_names_identical());
337 assert_eq!(err.exit_code(), 1);
338 let msg = err.to_string();
339 assert!(
341 msg.contains("identical") || msg.contains("idênticos") || msg.contains("idêntico"),
342 "got: {msg}"
343 );
344 }
345
346 #[test]
347 fn rename_entity_response_namespace_reflected() {
348 let resp = RenameEntityResponse {
349 action: "renamed".to_string(),
350 old_name: "a".to_string(),
351 new_name: "b".to_string(),
352 entity_id: 10,
353 namespace: "my-project".to_string(),
354 elapsed_ms: 2,
355 };
356 let json = serde_json::to_value(&resp).expect("serialization failed");
357 assert_eq!(json["namespace"], "my-project");
358 }
359}