sqlite_graphrag/commands/
reclassify.rs1use 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")]
37pub struct ReclassifyArgs {
39 #[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 #[arg(long, conflicts_with_all = ["from_type", "batch"])]
53 pub name: Option<String>,
54 #[arg(long, value_name = "TYPE", visible_alias = "entity-type")]
57 pub new_type: Option<String>,
58 #[arg(long, value_name = "TEXT")]
60 pub description: Option<String>,
61 #[arg(long, value_name = "TYPE", requires = "to_type", requires = "batch")]
66 pub from_type: Option<String>,
67 #[arg(long, value_name = "TYPE", requires = "from_type")]
69 pub to_type: Option<String>,
70 #[arg(long, default_value_t = false, requires = "from_type")]
72 pub batch: bool,
73 #[arg(long)]
75 pub namespace: Option<String>,
76 #[arg(long, value_enum, default_value = "json")]
78 pub format: OutputFormat,
79 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
81 pub json: bool,
82 #[arg(long)]
84 pub db: Option<String>,
85}
86
87#[derive(Serialize)]
88struct ReclassifyResponse {
89 action: String,
90 count: usize,
91 #[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 elapsed_ms: u64,
102}
103
104pub 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 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 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 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 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 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}