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