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::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")]
25/// Rename entity args.
26pub struct RenameEntityArgs {
27    /// Current entity name as a positional argument. Alternative to `--name`.
28    ///
29    /// GAP-SG-272: matches the spelling `read` and `related` have always accepted.
30    /// It conflicts with `--id` for the same reason `--name` does — two different
31    /// designations of the same row is an ambiguity, not a convenience.
32    #[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    /// Current entity name to rename.
39    #[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    /// v1.1.1 (P5): entity ID to rename. IDs are globally unique, so --id
47    /// disambiguates homonyms across namespaces. Conflicts with --name; the
48    /// entity must belong to the resolved namespace.
49    #[arg(long, value_name = "ID")]
50    pub id: Option<i64>,
51    /// New name for the entity.
52    #[arg(long, value_name = "NEW_NAME")]
53    pub new_name: String,
54    /// Namespace scope.
55    #[arg(long)]
56    pub namespace: Option<String>,
57    /// Output format.
58    #[arg(long, value_enum, default_value = "json")]
59    pub format: OutputFormat,
60    /// Emit machine-readable JSON on stdout.
61    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
62    pub json: bool,
63    /// Path to the SQLite database file.
64    #[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
78/// v1.1.1 (P5): resolves an entity ID to `(id, type, stored name)`, enforcing
79/// that the entity exists AND belongs to the namespace — IDs are global, so a
80/// bare existence check could silently cross namespaces.
81fn 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        // Read path: the row exists either way, so an unusable stored label
90        // falls back to the default instead of failing the rename.
91        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
105/// Run.
106pub 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    // Verify the source entity exists and fetch id, type and stored name —
116    // by ID (v1.1.1 P5, unambiguous across homonyms) or by normalized name.
117    // Existence is validated here, BEFORE any mutation.
118    let (entity_id, entity_type, old_name) = match args.id {
119        Some(id) => lookup_entity_by_id(&conn, &namespace, id)?,
120        None => {
121            // GAP-SG-272: positional first, then the flag. Clap already refused
122            // the invocation carrying both, so `or` decides nothing.
123            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            // Normalize the lookup name to match the normalized stored names.
129            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    // Validate the raw new name first (catches short ALL_CAPS NER noise),
151    // then normalize it for storage to preserve the normalized-name invariant.
152    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    // Ensure new name is not already taken in this namespace.
162    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    // GAP-CLI-PERF-RENAME-01 / EMBED-NONE (v1.1.8): re-embed the new name only
169    // when a real embedding backend is available. Intentional `--llm-backend
170    // none` (or empty vectors) must not block a pure metadata rename for ~30s.
171    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        // v1.1.2 (Gap 2): typed payload rejections are permanent and must not
180        // be swallowed by --skip-embedding-on-failure.
181        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    // v1.0.76: BLOB-backed entity_embeddings table (PK = entity_id).
199    // G43: reuse the canonical writer instead of a duplicated INSERT that
200    // hardcoded dim=384 and a removed local model name; `upsert_entity_vec`
201    // records the real vector length and the CLI version as `model`.
202    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    // v1.1.1 (P5): ID lookup is namespace-scoped and returns the stored name,
236    // so homonyms across namespaces resolve deterministically.
237    #[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    // v1.1.1 (P5): --name and --id are mutually exclusive at the clap level,
268    // and at least one selector is required.
269    #[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        // Locale-safe: EN "identical" / PT "idênticos"
353        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}