Skip to main content

sqlite_graphrag/commands/
reclassify.rs

1//! Handler for the `reclassify` CLI subcommand (GAP-18).
2//!
3//! Reclassifies one entity (single mode) or a whole group of entities (batch
4//! mode) by updating the `type` column in the `entities` table.
5//!
6//! Single mode: `--name <entity>` changes the type of one entity.
7//! Batch mode: `--from-type <old> --to-type <new> --batch` changes every
8//! entity in the namespace that currently has `<old>` as its type.
9
10use crate::entity_type::normalize_entity_type;
11use crate::errors::AppError;
12use crate::i18n::errors_msg;
13use crate::output::{self, OutputFormat};
14use crate::paths::AppPaths;
15use crate::storage::connection::open_rw;
16use crate::storage::entities;
17use rusqlite::params;
18use serde::Serialize;
19
20#[derive(clap::Args)]
21#[command(after_long_help = "EXAMPLES:\n  \
22    # Reclassify a single entity from its current type to 'tool'\n  \
23    sqlite-graphrag reclassify --name tokio-runtime --new-type tool\n\n  \
24    # Reclassify all 'concept' entities to 'tool' in one shot (batch)\n  \
25    sqlite-graphrag reclassify --from-type concept --to-type tool --batch\n\n  \
26    # Reclassify in a specific namespace\n  \
27    sqlite-graphrag reclassify --name alice --new-type person --namespace my-project\n\n\
28NOTE:\n  \
29    Single mode requires --name and at least one of --new-type or --description.\n  \
30    Batch mode requires --from-type, --to-type and --batch.\n  \
31    Providing --name together with --batch is an error.\n  \
32    In batch mode, --from-type is counted before the update: a value that\n  \
33    matches no entity is refused instead of reported as a zero-row success.\n\n\
34RECOMMENDED ENTITY TYPES (the vocabulary is open; any label is accepted):\n  \
35    project, tool, person, file, concept, incident, decision,\n  \
36    memory, dashboard, issue_tracker, organization, location, date")]
37/// Reclassify args.
38pub struct ReclassifyArgs {
39    /// Entity name as a positional argument. Alternative to `--name`.
40    ///
41    /// GAP-SG-272: matches the spelling `read` and `related` have always accepted.
42    /// It carries the SAME conflict set as `--name`, because a positional that
43    /// coexisted with `--batch` would let the caller ask for one entity and get
44    /// every entity of a type.
45    #[arg(
46        value_name = "NAME",
47        conflicts_with_all = ["name", "from_type", "batch"],
48        help = "Entity name (kebab-case slug); alternative to --name"
49    )]
50    pub name_positional: Option<String>,
51    /// Entity name to reclassify (single mode). Mutually exclusive with --from-type + --batch.
52    #[arg(long, conflicts_with_all = ["from_type", "batch"])]
53    pub name: Option<String>,
54    /// New entity type for single mode. Any label is accepted (v1.2.8); the
55    /// canonical thirteen are recommended, not exhaustive.
56    #[arg(long, value_name = "TYPE", visible_alias = "entity-type")]
57    pub new_type: Option<String>,
58    /// New description for the entity (single mode only). Ignored in batch mode.
59    #[arg(long, value_name = "TEXT")]
60    pub description: Option<String>,
61    /// Current entity type to match in batch mode. Requires --to-type and --batch.
62    ///
63    /// Counted before the update: a label that matches no entity is refused,
64    /// never reported as a successful zero-row batch.
65    #[arg(long, value_name = "TYPE", requires = "to_type", requires = "batch")]
66    pub from_type: Option<String>,
67    /// New entity type to assign in batch mode. Requires --from-type and --batch.
68    #[arg(long, value_name = "TYPE", requires = "from_type")]
69    pub to_type: Option<String>,
70    /// Enable batch reclassification (--from-type to --to-type). Requires --from-type and --to-type.
71    #[arg(long, default_value_t = false, requires = "from_type")]
72    pub batch: bool,
73    /// Namespace scope.
74    #[arg(long)]
75    pub namespace: Option<String>,
76    /// Output format.
77    #[arg(long, value_enum, default_value = "json")]
78    pub format: OutputFormat,
79    /// Emit machine-readable JSON on stdout.
80    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
81    pub json: bool,
82    /// Path to the SQLite database file.
83    #[arg(long)]
84    pub db: Option<String>,
85}
86
87#[derive(Serialize)]
88struct ReclassifyResponse {
89    action: String,
90    count: usize,
91    /// Entities matching `--from-type` counted BEFORE the update (batch mode
92    /// only). Emitted so a caller can tell a real batch from one whose
93    /// `--from-type` was a typo — the open vocabulary no longer lets clap catch
94    /// that for us.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    matched_targets: Option<usize>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    description_updated: Option<bool>,
99    namespace: String,
100    /// Total execution time in milliseconds from handler start to serialisation.
101    elapsed_ms: u64,
102}
103
104/// Run.
105pub fn run(args: ReclassifyArgs) -> Result<(), AppError> {
106    let started = std::time::Instant::now();
107    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
108    let paths = AppPaths::resolve(args.db.as_deref())?;
109
110    crate::storage::connection::ensure_db_ready(&paths)?;
111
112    let mut conn = open_rw(&paths.db)?;
113
114    let mut matched_targets: Option<usize> = None;
115
116    let count = if args.batch {
117        // Batch mode: --from-type + --to-type + --batch
118        let from_type = args.from_type.as_deref().ok_or_else(|| {
119            AppError::Validation(crate::i18n::validation::from_type_required_batch())
120        })?;
121        let to_type = args.to_type.as_deref().ok_or_else(|| {
122            AppError::Validation(crate::i18n::validation::to_type_required_batch())
123        })?;
124        let from_type = normalize_entity_type(from_type)?;
125        let to_type = normalize_entity_type(to_type)?;
126
127        // v1.2.8: count the targets BEFORE mutating. While the vocabulary was a
128        // closed enum, a typo in --from-type was refused by clap; now it parses
129        // and would quietly update zero rows, which reads as success. Failing
130        // closed on an empty match restores the refusal at the only layer that
131        // can still see it.
132        let targets: i64 = conn.query_row(
133            "SELECT COUNT(*) FROM entities WHERE type = ?1 AND namespace = ?2",
134            params![from_type, namespace],
135            |r| r.get(0),
136        )?;
137        if targets == 0 {
138            return Err(AppError::Validation(
139                crate::i18n::validation::reclassify_batch_no_targets(&from_type, &namespace),
140            ));
141        }
142        matched_targets = Some(targets as usize);
143
144        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
145        let affected = tx.execute(
146            "UPDATE entities SET type = ?1, updated_at = unixepoch()
147             WHERE type = ?2 AND namespace = ?3",
148            params![to_type, from_type, namespace],
149        )?;
150        tx.commit()?;
151        affected
152    } else {
153        // Single mode: name (positional or --name) + --new-type
154        //
155        // GAP-SG-272: `or` cannot mask a conflict here, because clap already
156        // refused the invocation that supplied both spellings.
157        let entity_name = args
158            .name_positional
159            .as_deref()
160            .or(args.name.as_deref())
161            .ok_or_else(|| {
162                AppError::Validation(crate::i18n::validation::name_required_single_mode())
163            })?;
164        if args.new_type.is_none() && args.description.is_none() {
165            return Err(AppError::Validation(
166                crate::i18n::validation::reclassify_needs_type_or_description(),
167            ));
168        }
169
170        // Verify entity exists.
171        entities::find_entity_id(&conn, &namespace, entity_name)?.ok_or_else(|| {
172            AppError::NotFound(errors_msg::entity_not_found(entity_name, &namespace))
173        })?;
174
175        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
176        let mut affected = 0;
177        if let Some(ref new_type) = args.new_type {
178            let new_type = normalize_entity_type(new_type)?;
179            affected = tx.execute(
180                "UPDATE entities SET type = ?1, updated_at = unixepoch()
181                 WHERE name = ?2 AND namespace = ?3",
182                params![new_type, entity_name, namespace],
183            )?;
184        }
185        if let Some(ref desc) = args.description {
186            let rows = tx.execute(
187                "UPDATE entities SET description = ?1, updated_at = unixepoch()
188                 WHERE name = ?2 AND namespace = ?3",
189                params![desc, entity_name, namespace],
190            )?;
191            if affected == 0 {
192                affected = rows;
193            }
194        }
195        tx.commit()?;
196        affected
197    };
198
199    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
200
201    let response = ReclassifyResponse {
202        action: "reclassified".to_string(),
203        count,
204        matched_targets,
205        description_updated: if args.description.is_some() {
206            Some(true)
207        } else {
208            None
209        },
210        namespace: namespace.clone(),
211        elapsed_ms: started.elapsed().as_millis() as u64,
212    };
213
214    match args.format {
215        OutputFormat::Json => output::emit_json(&response)?,
216        OutputFormat::Text | OutputFormat::Markdown => {
217            output::emit_text(&format!(
218                "reclassified: {} entities [{}]",
219                response.count, response.namespace
220            ));
221        }
222    }
223
224    Ok(())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[derive(clap::Parser)]
232    struct TestCli {
233        #[command(flatten)]
234        args: ReclassifyArgs,
235    }
236
237    #[test]
238    fn entity_type_flag_is_a_visible_alias_of_new_type() {
239        // G47: llms-full.txt and external docs promise `--entity-type`; the
240        // flag was only reachable as --new-type, breaking the documented CLI.
241        use clap::Parser;
242        let cli = TestCli::try_parse_from(["reclassify", "--name", "e", "--entity-type", "tool"])
243            .expect("--entity-type must parse as an alias of --new-type");
244        assert!(cli.args.new_type.is_some());
245    }
246
247    #[test]
248    fn reclassify_response_serializes_all_fields() {
249        let resp = ReclassifyResponse {
250            action: "reclassified".to_string(),
251            count: 5,
252            matched_targets: None,
253            description_updated: None,
254            namespace: "global".to_string(),
255            elapsed_ms: 12,
256        };
257        let json = serde_json::to_value(&resp).expect("serialization failed");
258        assert_eq!(json["action"], "reclassified");
259        assert_eq!(json["count"], 5);
260        assert_eq!(json["namespace"], "global");
261        assert!(json["elapsed_ms"].is_number());
262        assert!(json.get("description_updated").is_none());
263    }
264
265    #[test]
266    fn reclassify_response_count_zero_is_valid() {
267        let resp = ReclassifyResponse {
268            action: "reclassified".to_string(),
269            count: 0,
270            matched_targets: None,
271            description_updated: None,
272            namespace: "my-project".to_string(),
273            elapsed_ms: 3,
274        };
275        let json = serde_json::to_value(&resp).expect("serialization failed");
276        assert_eq!(json["count"], 0);
277        assert_eq!(json["action"], "reclassified");
278    }
279
280    #[test]
281    fn reclassify_response_action_is_reclassified() {
282        let resp = ReclassifyResponse {
283            action: "reclassified".to_string(),
284            count: 1,
285            matched_targets: None,
286            description_updated: None,
287            namespace: "ns".to_string(),
288            elapsed_ms: 1,
289        };
290        assert_eq!(resp.action, "reclassified");
291    }
292
293    #[test]
294    fn reclassify_response_description_updated_present_when_set() {
295        let resp = ReclassifyResponse {
296            action: "reclassified".to_string(),
297            count: 1,
298            matched_targets: None,
299            description_updated: Some(true),
300            namespace: "global".to_string(),
301            elapsed_ms: 2,
302        };
303        let json = serde_json::to_value(&resp).expect("serialization failed");
304        assert_eq!(json["description_updated"], true);
305    }
306}