1use crate::entity_type::normalize_entity_type_or_default;
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(
33 value_name = "NAME",
34 conflicts_with_all = ["name", "id"],
35 help = "Current entity name (kebab-case slug); alternative to --name"
36 )]
37 pub name_positional: Option<String>,
38 #[arg(
40 long,
41 value_name = "NAME",
42 required_unless_present_any = ["id", "name_positional"],
43 conflicts_with = "id"
44 )]
45 pub name: Option<String>,
46 #[arg(long, value_name = "ID")]
50 pub id: Option<i64>,
51 #[arg(long, value_name = "NEW_NAME")]
53 pub new_name: String,
54 #[arg(long)]
56 pub namespace: Option<String>,
57 #[arg(long, value_enum, default_value = "json")]
59 pub format: OutputFormat,
60 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
62 pub json: bool,
63 #[arg(long)]
65 pub db: Option<String>,
66}
67
68#[derive(Serialize)]
69struct RenameEntityResponse {
70 action: String,
71 old_name: String,
72 new_name: String,
73 entity_id: i64,
74 namespace: String,
75 elapsed_ms: u64,
76}
77
78fn lookup_entity_by_id(
82 conn: &rusqlite::Connection,
83 namespace: &str,
84 id: i64,
85) -> Result<(i64, String, String), AppError> {
86 let mut stmt = conn
87 .prepare_cached("SELECT id, type, name FROM entities WHERE id = ?1 AND namespace = ?2")?;
88 match stmt.query_row(params![id, namespace], |r| {
89 Ok((
92 r.get::<_, i64>(0)?,
93 normalize_entity_type_or_default(&r.get::<_, String>(1)?),
94 r.get::<_, String>(2)?,
95 ))
96 }) {
97 Ok(row) => Ok(row),
98 Err(rusqlite::Error::QueryReturnedNoRows) => Err(AppError::NotFound(
99 crate::i18n::validation::entity_id_not_found_in_namespace(id, namespace),
100 )),
101 Err(e) => Err(AppError::Database(e)),
102 }
103}
104
105pub fn run(args: RenameEntityArgs, backends: crate::cli::BackendChoice) -> Result<(), AppError> {
107 let start = std::time::Instant::now();
108 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
109 let paths = AppPaths::resolve(args.db.as_deref())?;
110
111 crate::storage::connection::ensure_db_ready(&paths)?;
112
113 let mut conn = open_rw(&paths.db)?;
114
115 let (entity_id, entity_type, old_name) = match args.id {
119 Some(id) => lookup_entity_by_id(&conn, &namespace, id)?,
120 None => {
121 let Some(raw_name) = args.name_positional.as_deref().or(args.name.as_deref()) else {
124 return Err(AppError::Validation(
125 crate::i18n::validation::name_or_id_required(),
126 ));
127 };
128 let lookup_name = crate::parsers::normalize_entity_name(raw_name);
130 let mut stmt = conn.prepare_cached(
131 "SELECT id, type FROM entities WHERE namespace = ?1 AND name = ?2",
132 )?;
133 match stmt.query_row(params![namespace, lookup_name], |r| {
134 Ok((
135 r.get::<_, i64>(0)?,
136 normalize_entity_type_or_default(&r.get::<_, String>(1)?),
137 ))
138 }) {
139 Ok((id, ty)) => (id, ty, lookup_name),
140 Err(rusqlite::Error::QueryReturnedNoRows) => {
141 return Err(AppError::NotFound(errors_msg::entity_not_found(
142 raw_name, &namespace,
143 )))
144 }
145 Err(e) => return Err(AppError::Database(e)),
146 }
147 }
148 };
149
150 entities::validate_entity_name(&args.new_name)?;
153 let new_name = crate::parsers::normalize_entity_name(&args.new_name);
154
155 if old_name == new_name {
156 return Err(AppError::Validation(
157 "source and target entity names are identical".to_string(),
158 ));
159 }
160
161 if entities::find_entity_id(&conn, &namespace, &new_name)?.is_some() {
163 return Err(AppError::Validation(
164 crate::i18n::validation::entity_name_already_exists(&new_name, &namespace),
165 ));
166 }
167
168 let skip_embed = crate::embedder::should_skip_embedding_on_failure();
172 let embedding: Option<Vec<f32>> = match crate::embedder::embed_passage_with_embedding_choice(
173 &paths.models,
174 &new_name,
175 backends,
176 ) {
177 Ok((emb, _backend)) if emb.is_empty() => None,
178 Ok((emb, _backend)) => Some(emb),
179 Err(
182 e @ (AppError::Validation(_)
183 | AppError::BodyTooLarge { .. }
184 | AppError::TooManyTokens { .. }),
185 ) => return Err(e),
186 Err(e) if skip_embed => {
187 tracing::warn!(error = %e, "rename-entity: embedding failed; --skip-embedding-on-failure active, persisting without embedding");
188 None
189 }
190 Err(e) => return Err(e),
191 };
192
193 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
194 tx.execute(
195 "UPDATE entities SET name = ?1, updated_at = unixepoch() WHERE id = ?2",
196 params![new_name, entity_id],
197 )?;
198 if let Some(ref emb) = embedding {
203 entities::upsert_entity_vec(&tx, entity_id, &namespace, &entity_type, emb, &new_name)?;
204 }
205 tx.commit()?;
206
207 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
208
209 let response = RenameEntityResponse {
210 action: "renamed".to_string(),
211 old_name,
212 new_name,
213 entity_id,
214 namespace: namespace.clone(),
215 elapsed_ms: start.elapsed().as_millis() as u64,
216 };
217
218 match args.format {
219 OutputFormat::Json => output::emit_json(&response)?,
220 OutputFormat::Text | OutputFormat::Markdown => {
221 output::emit_text(&format!(
222 "renamed entity: '{}' → '{}' [{}]",
223 response.old_name, response.new_name, response.namespace
224 ));
225 }
226 }
227
228 Ok(())
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
238 fn lookup_entity_by_id_disambiguates_homonyms_across_namespaces() {
239 let conn = rusqlite::Connection::open_in_memory().unwrap();
240 conn.execute_batch(
241 "CREATE TABLE entities (
242 id INTEGER PRIMARY KEY,
243 namespace TEXT NOT NULL,
244 name TEXT NOT NULL,
245 type TEXT NOT NULL,
246 UNIQUE(namespace, name)
247 );",
248 )
249 .unwrap();
250 conn.execute(
251 "INSERT INTO entities (id, namespace, name, type)
252 VALUES (1, 'ns-a', 'auth', 'concept'), (2, 'ns-b', 'auth', 'tool')",
253 [],
254 )
255 .unwrap();
256
257 let (id, ty, name) = lookup_entity_by_id(&conn, "ns-b", 2).unwrap();
258 assert_eq!(id, 2);
259 assert_eq!(name, "auth");
260 assert_eq!(ty, "tool");
261
262 let err = lookup_entity_by_id(&conn, "ns-b", 1).unwrap_err();
263 assert_eq!(err.exit_code(), 4, "cross-namespace ID must be NotFound");
264 assert!(err.to_string().contains("id=1"), "obtido: {err}");
265 }
266
267 #[derive(clap::Parser)]
270 struct TestCli {
271 #[command(flatten)]
272 args: RenameEntityArgs,
273 }
274
275 #[test]
276 fn clap_rejects_name_combined_with_id() {
277 use clap::Parser;
278 let err =
279 match TestCli::try_parse_from(["t", "--name", "auth", "--id", "42", "--new-name", "x"])
280 {
281 Ok(_) => panic!("expected argument conflict"),
282 Err(e) => e,
283 };
284 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
285 }
286
287 #[test]
288 fn clap_requires_name_or_id() {
289 use clap::Parser;
290 assert!(TestCli::try_parse_from(["t", "--new-name", "x"]).is_err());
291 let ok = match TestCli::try_parse_from(["t", "--id", "7", "--new-name", "x"]) {
292 Ok(cli) => cli,
293 Err(e) => panic!("expected successful parse: {e}"),
294 };
295 assert_eq!(ok.args.id, Some(7));
296 assert!(ok.args.name.is_none());
297 }
298
299 #[test]
300 fn rename_entity_response_serializes_all_fields() {
301 let resp = RenameEntityResponse {
302 action: "renamed".to_string(),
303 old_name: "auth".to_string(),
304 new_name: "authentication".to_string(),
305 entity_id: 42,
306 namespace: "global".to_string(),
307 elapsed_ms: 7,
308 };
309 let json = serde_json::to_value(&resp).expect("serialization failed");
310 assert_eq!(json["action"], "renamed");
311 assert_eq!(json["old_name"], "auth");
312 assert_eq!(json["new_name"], "authentication");
313 assert_eq!(json["entity_id"], 42);
314 assert_eq!(json["namespace"], "global");
315 assert!(json["elapsed_ms"].is_number());
316 }
317
318 #[test]
319 fn rename_entity_response_action_is_renamed() {
320 let resp = RenameEntityResponse {
321 action: "renamed".to_string(),
322 old_name: "x".to_string(),
323 new_name: "y".to_string(),
324 entity_id: 1,
325 namespace: "ns".to_string(),
326 elapsed_ms: 1,
327 };
328 assert_eq!(resp.action, "renamed");
329 }
330
331 #[test]
332 fn rename_entity_response_entity_id_preserved() {
333 let resp = RenameEntityResponse {
334 action: "renamed".to_string(),
335 old_name: "old".to_string(),
336 new_name: "new".to_string(),
337 entity_id: 999,
338 namespace: "test-ns".to_string(),
339 elapsed_ms: 5,
340 };
341 let json = serde_json::to_value(&resp).expect("serialization failed");
342 assert_eq!(json["entity_id"], 999);
343 }
344
345 #[test]
346 fn rejects_rename_entity_to_same_name() {
347 use crate::errors::AppError;
348 let err =
349 AppError::Validation(crate::i18n::validation::source_target_entity_names_identical());
350 assert_eq!(err.exit_code(), 1);
351 let msg = err.to_string();
352 assert!(
354 msg.contains("identical") || msg.contains("idênticos") || msg.contains("idêntico"),
355 "got: {msg}"
356 );
357 }
358
359 #[test]
360 fn rename_entity_response_namespace_reflected() {
361 let resp = RenameEntityResponse {
362 action: "renamed".to_string(),
363 old_name: "a".to_string(),
364 new_name: "b".to_string(),
365 entity_id: 10,
366 namespace: "my-project".to_string(),
367 elapsed_ms: 2,
368 };
369 let json = serde_json::to_value(&resp).expect("serialization failed");
370 assert_eq!(json["namespace"], "my-project");
371 }
372}