sqlite_graphrag/commands/
reclassify.rs1use 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")]
35pub struct ReclassifyArgs {
37 #[arg(long, conflicts_with_all = ["from_type", "batch"])]
39 pub name: Option<String>,
40 #[arg(long, value_enum, value_name = "TYPE", visible_alias = "entity-type")]
42 pub new_type: Option<EntityType>,
43 #[arg(long, value_name = "TEXT")]
45 pub description: Option<String>,
46 #[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 #[arg(long, value_enum, value_name = "TYPE", requires = "from_type")]
57 pub to_type: Option<EntityType>,
58 #[arg(long, default_value_t = false, requires = "from_type")]
60 pub batch: bool,
61 #[arg(long)]
63 pub namespace: Option<String>,
64 #[arg(long, value_enum, default_value = "json")]
66 pub format: OutputFormat,
67 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
69 pub json: bool,
70 #[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 elapsed_ms: u64,
84}
85
86pub 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 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 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 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 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}