Skip to main content

sqlite_graphrag/commands/
rename_entity.rs

1//! Handler for the `rename-entity` CLI subcommand.
2//!
3//! Renames an entity preserving all relationships and memory bindings.
4//! Only the `name` column in `entities` and the corresponding `vec_entities`
5//! row need updating because relationships use integer FK `entity_id`.
6
7use 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")]
25/// Rename entity args.
26pub struct RenameEntityArgs {
27    /// Current entity name to rename.
28    #[arg(
29        long,
30        value_name = "NAME",
31        required_unless_present = "id",
32        conflicts_with = "id"
33    )]
34    pub name: Option<String>,
35    /// v1.1.1 (P5): entity ID to rename. IDs are globally unique, so --id
36    /// disambiguates homonyms across namespaces. Conflicts with --name; the
37    /// entity must belong to the resolved namespace.
38    #[arg(long, value_name = "ID")]
39    pub id: Option<i64>,
40    /// New name for the entity.
41    #[arg(long, value_name = "NEW_NAME")]
42    pub new_name: String,
43    /// Namespace scope.
44    #[arg(long)]
45    pub namespace: Option<String>,
46    /// Output format.
47    #[arg(long, value_enum, default_value = "json")]
48    pub format: OutputFormat,
49    /// Emit machine-readable JSON on stdout.
50    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
51    pub json: bool,
52    /// Path to the SQLite database file.
53    #[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
67/// v1.1.1 (P5): resolves an entity ID to `(id, type, stored name)`, enforcing
68/// that the entity exists AND belongs to the namespace — IDs are global, so a
69/// bare existence check could silently cross namespaces.
70fn 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
92/// Run.
93pub 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    // Verify the source entity exists and fetch id, type and stored name —
107    // by ID (v1.1.1 P5, unambiguous across homonyms) or by normalized name.
108    // Existence is validated here, BEFORE any mutation.
109    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            // Normalize the lookup name to match the normalized stored names.
118            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    // Validate the raw new name first (catches short ALL_CAPS NER noise),
137    // then normalize it for storage to preserve the normalized-name invariant.
138    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    // Ensure new name is not already taken in this namespace.
148    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    // GAP-CLI-PERF-RENAME-01 / EMBED-NONE (v1.1.8): re-embed the new name only
155    // when a real embedding backend is available. Intentional `--llm-backend
156    // none` (or empty vectors) must not block a pure metadata rename for ~30s.
157    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        // v1.1.2 (Gap 2): typed payload rejections are permanent and must not
167        // be swallowed by --skip-embedding-on-failure.
168        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    // v1.0.76: BLOB-backed entity_embeddings table (PK = entity_id).
186    // G43: reuse the canonical writer instead of a duplicated INSERT that
187    // hardcoded dim=384 and a removed local model name; `upsert_entity_vec`
188    // records the real vector length and the CLI version as `model`.
189    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    // v1.1.1 (P5): ID lookup is namespace-scoped and returns the stored name,
223    // so homonyms across namespaces resolve deterministically.
224    #[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    // v1.1.1 (P5): --name and --id are mutually exclusive at the clap level,
255    // and at least one selector is required.
256    #[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        // Locale-safe: EN "identical" / PT "idênticos"
340        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}