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::EntityType;
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\n\
32VALID ENTITY TYPES:\n  \
33    project, tool, person, file, concept, incident, decision,\n  \
34    memory, dashboard, issue_tracker, organization, location, date")]
35/// Reclassify args.
36pub struct ReclassifyArgs {
37    /// Entity name to reclassify (single mode). Mutually exclusive with --from-type + --batch.
38    #[arg(long, conflicts_with_all = ["from_type", "batch"])]
39    pub name: Option<String>,
40    /// New entity type for single mode.
41    #[arg(long, value_enum, value_name = "TYPE", visible_alias = "entity-type")]
42    pub new_type: Option<EntityType>,
43    /// New description for the entity (single mode only). Ignored in batch mode.
44    #[arg(long, value_name = "TEXT")]
45    pub description: Option<String>,
46    /// Current entity type to match in batch mode. Requires --to-type and --batch.
47    #[arg(
48        long,
49        value_enum,
50        value_name = "TYPE",
51        requires = "to_type",
52        requires = "batch"
53    )]
54    pub from_type: Option<EntityType>,
55    /// New entity type to assign in batch mode. Requires --from-type and --batch.
56    #[arg(long, value_enum, value_name = "TYPE", requires = "from_type")]
57    pub to_type: Option<EntityType>,
58    /// Enable batch reclassification (--from-type to --to-type). Requires --from-type and --to-type.
59    #[arg(long, default_value_t = false, requires = "from_type")]
60    pub batch: bool,
61    /// Namespace scope.
62    #[arg(long)]
63    pub namespace: Option<String>,
64    /// Output format.
65    #[arg(long, value_enum, default_value = "json")]
66    pub format: OutputFormat,
67    /// Emit machine-readable JSON on stdout.
68    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
69    pub json: bool,
70    /// Path to the SQLite database file.
71    #[arg(long)]
72    pub db: Option<String>,
73}
74
75#[derive(Serialize)]
76struct ReclassifyResponse {
77    action: String,
78    count: usize,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    description_updated: Option<bool>,
81    namespace: String,
82    /// Total execution time in milliseconds from handler start to serialisation.
83    elapsed_ms: u64,
84}
85
86/// Run.
87pub fn run(args: ReclassifyArgs) -> Result<(), AppError> {
88    let inicio = std::time::Instant::now();
89    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
90    let paths = AppPaths::resolve(args.db.as_deref())?;
91
92    crate::storage::connection::ensure_db_ready(&paths)?;
93
94    let mut conn = open_rw(&paths.db)?;
95
96    let count = if args.batch {
97        // Batch mode: --from-type + --to-type + --batch
98        let from_type = args.from_type.ok_or_else(|| {
99            AppError::Validation(crate::i18n::validation::from_type_required_batch())
100        })?;
101        let to_type = args.to_type.ok_or_else(|| {
102            AppError::Validation(crate::i18n::validation::to_type_required_batch())
103        })?;
104
105        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
106        let affected = tx.execute(
107            "UPDATE entities SET type = ?1, updated_at = unixepoch()
108             WHERE type = ?2 AND namespace = ?3",
109            params![to_type.as_str(), from_type.as_str(), namespace],
110        )?;
111        tx.commit()?;
112        if affected == 0 {
113            tracing::warn!(target: "reclassify",
114                from_type = from_type.as_str(),
115                namespace = %namespace,
116                "reclassify batch matched zero entities — verify --from-type value exists"
117            );
118        }
119        affected
120    } else {
121        // Single mode: --name + --new-type
122        let entity_name = args
123            .name
124            .as_deref()
125            .ok_or_else(|| AppError::Validation(crate::i18n::validation::name_required_single_mode()))?;
126        if args.new_type.is_none() && args.description.is_none() {
127            return Err(AppError::Validation(
128                "at least one of --new-type or --description is required in single mode"
129                    .to_string(),
130            ));
131        }
132
133        // Verify entity exists.
134        entities::find_entity_id(&conn, &namespace, entity_name)?.ok_or_else(|| {
135            AppError::NotFound(errors_msg::entity_not_found(entity_name, &namespace))
136        })?;
137
138        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
139        let mut affected = 0;
140        if let Some(new_type) = args.new_type {
141            affected = tx.execute(
142                "UPDATE entities SET type = ?1, updated_at = unixepoch()
143                 WHERE name = ?2 AND namespace = ?3",
144                params![new_type.as_str(), entity_name, namespace],
145            )?;
146        }
147        if let Some(ref desc) = args.description {
148            let rows = tx.execute(
149                "UPDATE entities SET description = ?1, updated_at = unixepoch()
150                 WHERE name = ?2 AND namespace = ?3",
151                params![desc, entity_name, namespace],
152            )?;
153            if affected == 0 {
154                affected = rows;
155            }
156        }
157        tx.commit()?;
158        affected
159    };
160
161    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
162
163    let response = ReclassifyResponse {
164        action: "reclassified".to_string(),
165        count,
166        description_updated: if args.description.is_some() {
167            Some(true)
168        } else {
169            None
170        },
171        namespace: namespace.clone(),
172        elapsed_ms: inicio.elapsed().as_millis() as u64,
173    };
174
175    match args.format {
176        OutputFormat::Json => output::emit_json(&response)?,
177        OutputFormat::Text | OutputFormat::Markdown => {
178            output::emit_text(&format!(
179                "reclassified: {} entities [{}]",
180                response.count, response.namespace
181            ));
182        }
183    }
184
185    Ok(())
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[derive(clap::Parser)]
193    struct TestCli {
194        #[command(flatten)]
195        args: ReclassifyArgs,
196    }
197
198    #[test]
199    fn entity_type_flag_is_a_visible_alias_of_new_type() {
200        // G47: llms-full.txt and external docs promise `--entity-type`; the
201        // flag was only reachable as --new-type, breaking the documented CLI.
202        use clap::Parser;
203        let cli = TestCli::try_parse_from(["reclassify", "--name", "e", "--entity-type", "tool"])
204            .expect("--entity-type must parse as an alias of --new-type");
205        assert!(cli.args.new_type.is_some());
206    }
207
208    #[test]
209    fn reclassify_response_serializes_all_fields() {
210        let resp = ReclassifyResponse {
211            action: "reclassified".to_string(),
212            count: 5,
213            description_updated: None,
214            namespace: "global".to_string(),
215            elapsed_ms: 12,
216        };
217        let json = serde_json::to_value(&resp).expect("serialization failed");
218        assert_eq!(json["action"], "reclassified");
219        assert_eq!(json["count"], 5);
220        assert_eq!(json["namespace"], "global");
221        assert!(json["elapsed_ms"].is_number());
222        assert!(json.get("description_updated").is_none());
223    }
224
225    #[test]
226    fn reclassify_response_count_zero_is_valid() {
227        let resp = ReclassifyResponse {
228            action: "reclassified".to_string(),
229            count: 0,
230            description_updated: None,
231            namespace: "my-project".to_string(),
232            elapsed_ms: 3,
233        };
234        let json = serde_json::to_value(&resp).expect("serialization failed");
235        assert_eq!(json["count"], 0);
236        assert_eq!(json["action"], "reclassified");
237    }
238
239    #[test]
240    fn reclassify_response_action_is_reclassified() {
241        let resp = ReclassifyResponse {
242            action: "reclassified".to_string(),
243            count: 1,
244            description_updated: None,
245            namespace: "ns".to_string(),
246            elapsed_ms: 1,
247        };
248        assert_eq!(resp.action, "reclassified");
249    }
250
251    #[test]
252    fn reclassify_response_description_updated_present_when_set() {
253        let resp = ReclassifyResponse {
254            action: "reclassified".to_string(),
255            count: 1,
256            description_updated: Some(true),
257            namespace: "global".to_string(),
258            elapsed_ms: 2,
259        };
260        let json = serde_json::to_value(&resp).expect("serialization failed");
261        assert_eq!(json["description_updated"], true);
262    }
263}