Skip to main content

sqlite_graphrag/commands/
reclassify_relation.rs

1//! Handler for the `reclassify-relation` CLI subcommand (GAP-13).
2//!
3//! Renames a relation type in the `relationships` table — either a single
4//! directed edge (`--source`, `--target`, `--from-relation`) or every edge of
5//! a given type in the namespace (`--batch`).
6//!
7//! When the rename would produce a duplicate `(source_id, target_id, relation)`
8//! triple, `UPDATE OR IGNORE` skips the conflicting row and the subsequent
9//! `DELETE` removes it; the count of such skipped rows is reported as
10//! `merged_duplicates`.
11
12use crate::entity_type::EntityType;
13use crate::errors::AppError;
14use crate::output::{self, OutputFormat};
15use crate::paths::AppPaths;
16use crate::storage::connection::open_rw;
17use rusqlite::params;
18use serde::Serialize;
19
20#[derive(clap::Args)]
21#[command(after_long_help = "EXAMPLES:\n  \
22    # Rename a single edge from 'mentions' to 'related'\n  \
23    sqlite-graphrag reclassify-relation --source tokio --target axum \\\n  \
24        --from-relation mentions --to-relation related\n\n  \
25    # Rename every 'mentions' edge in the namespace to 'related'\n  \
26    sqlite-graphrag reclassify-relation \\\n  \
27        --from-relation mentions --to-relation related --batch\n\n  \
28    # Dry-run to preview what would change\n  \
29    sqlite-graphrag reclassify-relation \\\n  \
30        --from-relation mentions --to-relation related --batch --dry-run\n\n  \
31    # Batch rename only edges whose source is a 'tool' entity\n  \
32    sqlite-graphrag reclassify-relation \\\n  \
33        --from-relation uses --to-relation depends_on --batch \\\n  \
34        --filter-source-type tool\n\n  \
35    # Migrate edges stored with a LITERAL hyphenated relation (P4):\n  \
36    # --from-relation normalizes 'applies-to' to 'applies_to' and never\n  \
37    # matches the raw stored value; --literal-from matches it verbatim.\n  \
38    sqlite-graphrag reclassify-relation \\\n  \
39        --literal-from applies-to --to-relation applies_to --batch\n\n\
40NOTE:\n  \
41    Single mode requires --source, --target and --from-relation (or --literal-from).\n  \
42    Batch mode requires --from-relation (or --literal-from), --to-relation and --batch.\n  \
43    --from-relation and --literal-from are mutually exclusive; exactly one is required.\n  \
44    --filter-source-type and --filter-target-type are only effective in batch mode.")]
45pub struct ReclassifyRelationArgs {
46    /// Source entity name (single mode). Mutually exclusive with --batch.
47    #[arg(long, conflicts_with = "batch", value_name = "ENTITY")]
48    pub source: Option<String>,
49    /// Target entity name (single mode). Mutually exclusive with --batch.
50    #[arg(long, conflicts_with = "batch", value_name = "ENTITY")]
51    pub target: Option<String>,
52    /// Current relation type to rename (normalized: hyphens become
53    /// underscores at the CLI boundary). Required in both single and batch
54    /// modes unless --literal-from is given.
55    #[arg(
56        long,
57        value_parser = crate::parsers::parse_relation,
58        value_name = "RELATION",
59        required_unless_present = "literal_from",
60        conflicts_with = "literal_from"
61    )]
62    pub from_relation: Option<String>,
63    /// v1.1.1 (P4): current relation type to rename, matched LITERALLY —
64    /// no normalization is applied, so edges stored with hyphenated values
65    /// (e.g. `applies-to`) become reachable. Mutually exclusive with
66    /// --from-relation.
67    #[arg(long, value_name = "RELATION")]
68    pub literal_from: Option<String>,
69    /// New relation type to assign (normalized: hyphens become underscores at
70    /// the CLI boundary). Required in both single and batch modes unless
71    /// --literal-to is given.
72    #[arg(
73        long,
74        value_parser = crate::parsers::parse_relation,
75        value_name = "RELATION",
76        required_unless_present = "literal_to"
77    )]
78    pub to_relation: Option<String>,
79    /// v1.1.03: novo relation value to assign, matched LITERALLY (no
80    /// normalization). When present, wins over --to-relation. Allows
81    /// migrating legacy underscore relations to canonical hyphen.
82    #[arg(long, value_name = "RELATION")]
83    pub literal_to: Option<String>,
84    /// Enable batch reclassification of all edges with --from-relation. Requires --from-relation and --to-relation.
85    #[arg(long, default_value_t = false)]
86    pub batch: bool,
87    /// Filter batch: only rename edges whose source entity has this type.
88    #[arg(long, value_enum, value_name = "TYPE", requires = "batch")]
89    pub filter_source_type: Option<EntityType>,
90    /// Filter batch: only rename edges whose target entity has this type.
91    #[arg(long, value_enum, value_name = "TYPE", requires = "batch")]
92    pub filter_target_type: Option<EntityType>,
93    /// Preview count without committing changes.
94    #[arg(long, default_value_t = false)]
95    pub dry_run: bool,
96    #[arg(long)]
97    pub namespace: Option<String>,
98    #[arg(long, value_enum, default_value = "json")]
99    pub format: OutputFormat,
100    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
101    pub json: bool,
102    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
103    pub db: Option<String>,
104}
105
106#[derive(Serialize)]
107struct ReclassifyRelationResponse {
108    action: String,
109    from_relation: String,
110    to_relation: String,
111    /// Number of edges successfully renamed.
112    count: usize,
113    /// Edges that collided with an existing (source, target, to_relation) triple
114    /// and were removed rather than renamed (UPDATE OR IGNORE + DELETE pattern).
115    merged_duplicates: usize,
116    namespace: String,
117    elapsed_ms: u64,
118}
119
120impl ReclassifyRelationArgs {
121    /// v1.1.1 (P4): the relation value used in every WHERE clause.
122    ///
123    /// `--literal-from` wins and is matched VERBATIM (no normalization);
124    /// otherwise the clap-normalized `--from-relation` applies. Clap
125    /// guarantees exactly one of the two is present
126    /// (`required_unless_present` + `conflicts_with`).
127    fn effective_from(&self) -> &str {
128        self.literal_from
129            .as_deref()
130            .or(self.from_relation.as_deref())
131            .unwrap_or_default()
132    }
133
134    /// v1.1.03: the relation value written into every UPDATE and emitted in
135    /// the response.
136    ///
137    /// `--literal-to` wins and is stored VERBATIM (no normalization), enabling
138    /// migration of legacy underscore relations to canonical hyphen (e.g.
139    /// `--literal-from applies_to --literal-to applies-to`). Otherwise the
140    /// clap-normalized `--to-relation` applies. Clap guarantees exactly one of
141    /// the two is present (`required_unless_present` on `to_relation`).
142    fn effective_to(&self) -> &str {
143        self.literal_to
144            .as_deref()
145            .or(self.to_relation.as_deref())
146            .unwrap_or_default()
147    }
148}
149
150pub fn run(args: ReclassifyRelationArgs) -> Result<(), AppError> {
151    let inicio = std::time::Instant::now();
152    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
153    let paths = AppPaths::resolve(args.db.as_deref())?;
154
155    crate::storage::connection::ensure_db_ready(&paths)?;
156
157    // Emit warnings for non-canonical relation values.
158    crate::parsers::warn_if_non_canonical(args.effective_from());
159    crate::parsers::warn_if_non_canonical(args.effective_to());
160
161    // Reject same-value renames: nothing to do and would silently remove
162    // duplicates. The comparison uses the EFFECTIVE from AND to values, so
163    // migrating a literal hyphenated relation onto its normalized form (e.g.
164    // `--literal-from applies-to --to-relation applies_to`) is a VALID
165    // migration, not an equality. Likewise `--literal-from applies_to
166    // --literal-to applies-to` migrates underscore→hyphen.
167    if args.effective_from() == args.effective_to() {
168        return Err(AppError::Validation(
169            "--from-relation/--literal-from and --to-relation/--literal-to must be different"
170                .to_string(),
171        ));
172    }
173
174    let mut conn = open_rw(&paths.db)?;
175
176    if args.batch {
177        run_batch(args, inicio, namespace, &mut conn)
178    } else {
179        run_single(args, inicio, namespace, &mut conn)
180    }
181}
182
183// ---------------------------------------------------------------------------
184// Single mode
185// ---------------------------------------------------------------------------
186
187fn run_single(
188    args: ReclassifyRelationArgs,
189    inicio: std::time::Instant,
190    namespace: String,
191    conn: &mut rusqlite::Connection,
192) -> Result<(), AppError> {
193    let source_name = args.source.as_deref().ok_or_else(|| {
194        AppError::Validation(
195            "--source is required in single mode (omit --batch for single-edge rename)".to_string(),
196        )
197    })?;
198    let target_name = args
199        .target
200        .as_deref()
201        .ok_or_else(|| AppError::Validation("--target is required in single mode".to_string()))?;
202
203    // Resolve entity IDs — fail fast if either side does not exist.
204    // Normalize names to match the normalized stored entity names.
205    let source_name_norm = crate::parsers::normalize_entity_name(source_name);
206    let target_name_norm = crate::parsers::normalize_entity_name(target_name);
207    let source_id: i64 = conn
208        .query_row(
209            "SELECT id FROM entities WHERE name = ?1 AND namespace = ?2",
210            params![source_name_norm, namespace],
211            |r| r.get(0),
212        )
213        .map_err(|_| {
214            AppError::NotFound(format!(
215                "source entity '{source_name}' not found in namespace '{namespace}'"
216            ))
217        })?;
218
219    let target_id: i64 = conn
220        .query_row(
221            "SELECT id FROM entities WHERE name = ?1 AND namespace = ?2",
222            params![target_name_norm, namespace],
223            |r| r.get(0),
224        )
225        .map_err(|_| {
226            AppError::NotFound(format!(
227                "target entity '{target_name}' not found in namespace '{namespace}'"
228            ))
229        })?;
230
231    // Verify the edge to rename exists.
232    let original_count: i64 = conn.query_row(
233        "SELECT COUNT(*) FROM relationships
234         WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3 AND namespace = ?4",
235        params![source_id, target_id, args.effective_from(), namespace],
236        |r| r.get(0),
237    )?;
238
239    if original_count == 0 {
240        return Err(AppError::NotFound(format!(
241            "edge '{source_name}' --[{}]--> '{target_name}' not found in namespace '{namespace}'",
242            args.effective_from()
243        )));
244    }
245
246    if args.dry_run {
247        emit_response(
248            &args,
249            "dry_run",
250            original_count as usize,
251            0,
252            namespace,
253            inicio,
254        )?;
255        return Ok(());
256    }
257
258    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
259
260    let updated = tx.execute(
261        "UPDATE OR IGNORE relationships
262         SET relation = ?1
263         WHERE source_id = ?2 AND target_id = ?3 AND relation = ?4 AND namespace = ?5",
264        params![
265            args.effective_to(),
266            source_id,
267            target_id,
268            args.effective_from(),
269            namespace
270        ],
271    )?;
272
273    // Remove rows that UPDATE OR IGNORE silently skipped due to UNIQUE collision.
274    let deleted = tx.execute(
275        "DELETE FROM relationships
276         WHERE source_id = ?1 AND target_id = ?2 AND relation = ?3 AND namespace = ?4",
277        params![source_id, target_id, args.effective_from(), namespace],
278    )?;
279
280    tx.commit()?;
281
282    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
283
284    let merged = (original_count as usize).saturating_sub(updated + deleted);
285    emit_response(&args, "reclassified", updated, merged, namespace, inicio)
286}
287
288// ---------------------------------------------------------------------------
289// Batch mode
290// ---------------------------------------------------------------------------
291
292fn run_batch(
293    args: ReclassifyRelationArgs,
294    inicio: std::time::Instant,
295    namespace: String,
296    conn: &mut rusqlite::Connection,
297) -> Result<(), AppError> {
298    // Build WHERE clause extensions for optional entity-type filters.
299    // The base query joins relationships with source/target entities.
300    let source_filter = args
301        .filter_source_type
302        .map(|t| format!(" AND src.type = '{}'", t.as_str()))
303        .unwrap_or_default();
304    let target_filter = args
305        .filter_target_type
306        .map(|t| format!(" AND tgt.type = '{}'", t.as_str()))
307        .unwrap_or_default();
308    let has_filters = !source_filter.is_empty() || !target_filter.is_empty();
309
310    // Count edges that would be affected (used for both dry-run and confirmation).
311    let original_count: i64 = if has_filters {
312        conn.query_row(
313            &format!(
314                "SELECT COUNT(*) FROM relationships r
315                 JOIN entities src ON src.id = r.source_id
316                 JOIN entities tgt ON tgt.id = r.target_id
317                 WHERE r.relation = ?1 AND r.namespace = ?2{source_filter}{target_filter}"
318            ),
319            params![args.effective_from(), namespace],
320            |r| r.get(0),
321        )?
322    } else {
323        conn.query_row(
324            "SELECT COUNT(*) FROM relationships
325             WHERE relation = ?1 AND namespace = ?2",
326            params![args.effective_from(), namespace],
327            |r| r.get(0),
328        )?
329    };
330
331    if original_count == 0 {
332        tracing::warn!(target: "reclassify_relation",
333            from_relation = %args.effective_from(),
334            namespace = %namespace,
335            "reclassify-relation batch matched zero edges — verify --from-relation value"
336        );
337    }
338
339    if args.dry_run {
340        emit_response(
341            &args,
342            "dry_run",
343            original_count as usize,
344            0,
345            namespace,
346            inicio,
347        )?;
348        return Ok(());
349    }
350
351    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
352
353    let updated = if has_filters {
354        // For filtered batch we need to collect IDs first, then update.
355        let ids: Vec<i64> = {
356            let mut stmt = tx.prepare(&format!(
357                "SELECT r.id FROM relationships r
358                 JOIN entities src ON src.id = r.source_id
359                 JOIN entities tgt ON tgt.id = r.target_id
360                 WHERE r.relation = ?1 AND r.namespace = ?2{source_filter}{target_filter}"
361            ))?;
362            let collected: Vec<i64> = stmt
363                .query_map(params![args.effective_from(), namespace], |r| r.get(0))?
364                .collect::<Result<Vec<_>, _>>()?;
365            collected
366        };
367
368        let mut moved: usize = 0;
369        for id in &ids {
370            let n = tx.execute(
371                "UPDATE OR IGNORE relationships
372                 SET relation = ?1
373                 WHERE id = ?2",
374                params![args.effective_to(), id],
375            )?;
376            moved += n;
377        }
378        moved
379    } else {
380        tx.execute(
381            "UPDATE OR IGNORE relationships
382             SET relation = ?1
383             WHERE relation = ?2 AND namespace = ?3",
384            params![args.effective_to(), args.effective_from(), namespace],
385        )?
386    };
387
388    // Remove rows the UPDATE OR IGNORE left behind (UNIQUE collision survivors).
389    let deleted = if has_filters {
390        tx.execute(
391            &format!(
392                "DELETE FROM relationships WHERE id IN (
393                     SELECT r.id FROM relationships r
394                     JOIN entities src ON src.id = r.source_id
395                     JOIN entities tgt ON tgt.id = r.target_id
396                     WHERE r.relation = ?1 AND r.namespace = ?2{source_filter}{target_filter}
397                 )"
398            ),
399            params![args.effective_from(), namespace],
400        )?
401    } else {
402        tx.execute(
403            "DELETE FROM relationships WHERE relation = ?1 AND namespace = ?2",
404            params![args.effective_from(), namespace],
405        )?
406    };
407
408    tx.commit()?;
409
410    conn.execute_batch("ANALYZE relationships;")?;
411    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
412
413    let merged = (original_count as usize).saturating_sub(updated + deleted);
414    emit_response(&args, "reclassified", updated, merged, namespace, inicio)
415}
416
417// ---------------------------------------------------------------------------
418// Shared response emitter
419// ---------------------------------------------------------------------------
420
421fn emit_response(
422    args: &ReclassifyRelationArgs,
423    action: &str,
424    count: usize,
425    merged_duplicates: usize,
426    namespace: String,
427    inicio: std::time::Instant,
428) -> Result<(), AppError> {
429    let response = ReclassifyRelationResponse {
430        action: action.to_string(),
431        from_relation: args.effective_from().to_string(),
432        to_relation: args.effective_to().to_string(),
433        count,
434        merged_duplicates,
435        namespace: namespace.clone(),
436        elapsed_ms: inicio.elapsed().as_millis() as u64,
437    };
438
439    match args.format {
440        OutputFormat::Json => output::emit_json(&response)?,
441        OutputFormat::Text | OutputFormat::Markdown => {
442            output::emit_text(&format!(
443                "{action}: {count} edges '{}' → '{}' [{namespace}] (duplicates merged: {merged_duplicates})",
444                args.effective_from(), args.effective_to()
445            ));
446        }
447    }
448    Ok(())
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    fn make_response(action: &str, count: usize, merged: usize) -> ReclassifyRelationResponse {
456        ReclassifyRelationResponse {
457            action: action.to_string(),
458            from_relation: "mentions".to_string(),
459            to_relation: "related".to_string(),
460            count,
461            merged_duplicates: merged,
462            namespace: "global".to_string(),
463            elapsed_ms: 1,
464        }
465    }
466
467    #[test]
468    fn response_serializes_all_fields() {
469        let resp = make_response("reclassified", 5, 0);
470        let json = serde_json::to_value(&resp).expect("serialization failed");
471        assert_eq!(json["action"], "reclassified");
472        assert_eq!(json["from_relation"], "mentions");
473        assert_eq!(json["to_relation"], "related");
474        assert_eq!(json["count"], 5);
475        assert_eq!(json["merged_duplicates"], 0);
476        assert_eq!(json["namespace"], "global");
477        assert!(json["elapsed_ms"].is_number());
478    }
479
480    #[test]
481    fn response_action_dry_run() {
482        let resp = make_response("dry_run", 10, 0);
483        let json = serde_json::to_value(&resp).expect("serialization failed");
484        assert_eq!(json["action"], "dry_run");
485        assert_eq!(json["count"], 10);
486        assert_eq!(json["merged_duplicates"], 0);
487    }
488
489    #[test]
490    fn response_merged_duplicates_nonzero() {
491        // Simulates a case where 3 out of 10 edges collided with existing rows.
492        let resp = make_response("reclassified", 7, 3);
493        let json = serde_json::to_value(&resp).expect("serialization failed");
494        assert_eq!(json["count"], 7);
495        assert_eq!(json["merged_duplicates"], 3);
496    }
497
498    #[test]
499    fn response_count_zero_when_nothing_matched() {
500        let resp = make_response("reclassified", 0, 0);
501        let json = serde_json::to_value(&resp).expect("serialization failed");
502        assert_eq!(json["count"], 0);
503        assert_eq!(json["merged_duplicates"], 0);
504    }
505
506    #[test]
507    fn response_action_values_exhaustive() {
508        for action in &["reclassified", "dry_run"] {
509            let resp = make_response(action, 1, 0);
510            let json = serde_json::to_value(&resp).expect("serialization");
511            assert_eq!(json["action"], *action);
512        }
513    }
514
515    #[test]
516    fn response_from_and_to_relation_present() {
517        let resp = ReclassifyRelationResponse {
518            action: "reclassified".to_string(),
519            from_relation: "uses".to_string(),
520            to_relation: "depends_on".to_string(),
521            count: 3,
522            merged_duplicates: 1,
523            namespace: "my-project".to_string(),
524            elapsed_ms: 5,
525        };
526        let json = serde_json::to_value(&resp).expect("serialization failed");
527        assert_eq!(json["from_relation"], "uses");
528        assert_eq!(json["to_relation"], "depends_on");
529    }
530
531    #[test]
532    fn same_relation_value_rejected_at_logic_level() {
533        // Validates that the guard in run() would catch from == to.
534        // We test the condition directly since we cannot call run() without a DB.
535        let from = "mentions".to_string();
536        let to = "mentions".to_string();
537        assert!(
538            from == to,
539            "same-value rename must be caught before DB access"
540        );
541    }
542
543    // -----------------------------------------------------------------------
544    // v1.1.1 (P4): --literal-from — filtro sem normalização
545    // -----------------------------------------------------------------------
546
547    fn base_args() -> ReclassifyRelationArgs {
548        ReclassifyRelationArgs {
549            source: None,
550            target: None,
551            from_relation: None,
552            literal_from: None,
553            to_relation: Some("applies_to".to_string()),
554            literal_to: None,
555            batch: true,
556            filter_source_type: None,
557            filter_target_type: None,
558            dry_run: false,
559            namespace: Some("global".to_string()),
560            format: OutputFormat::Json,
561            json: true,
562            db: None,
563        }
564    }
565
566    #[test]
567    fn effective_from_prefers_literal_and_falls_back_to_normalized() {
568        let mut args = base_args();
569        args.from_relation = Some("applies_to".to_string());
570        assert_eq!(args.effective_from(), "applies_to");
571
572        args.literal_from = Some("applies-to".to_string());
573        assert_eq!(
574            args.effective_from(),
575            "applies-to",
576            "literal value must win and stay verbatim"
577        );
578
579        // Migração literal→normalizado é VÁLIDA (não é igualdade).
580        assert_ne!(args.effective_from(), args.effective_to());
581    }
582
583    fn setup_migrated_db() -> (tempfile::TempDir, rusqlite::Connection) {
584        crate::storage::connection::register_vec_extension();
585        let tmp = tempfile::TempDir::new().expect("tempdir");
586        let db_path = tmp.path().join("test.db");
587        let mut conn = rusqlite::Connection::open(&db_path).expect("open");
588        crate::migrations::runner().run(&mut conn).expect("migrate");
589        (tmp, conn)
590    }
591
592    #[test]
593    fn literal_from_migrates_hyphenated_edge_unreachable_by_normalized_filter() {
594        let (_tmp, mut conn) = setup_migrated_db();
595        conn.execute(
596            "INSERT INTO entities (namespace, name, type) VALUES ('global','ent-a','concept')",
597            [],
598        )
599        .unwrap();
600        let a = conn.last_insert_rowid();
601        conn.execute(
602            "INSERT INTO entities (namespace, name, type) VALUES ('global','ent-b','concept')",
603            [],
604        )
605        .unwrap();
606        let b = conn.last_insert_rowid();
607        // Aresta gravada com o valor LITERAL com hífen — inalcançável pelo
608        // --from-relation (que normaliza para 'applies_to' na borda clap).
609        conn.execute(
610            "INSERT INTO relationships (namespace, source_id, target_id, relation, weight) \
611             VALUES ('global', ?1, ?2, 'applies-to', 0.5)",
612            params![a, b],
613        )
614        .unwrap();
615
616        let mut args = base_args();
617        args.literal_from = Some("applies-to".to_string());
618        run_batch(
619            args,
620            std::time::Instant::now(),
621            "global".to_string(),
622            &mut conn,
623        )
624        .expect("batch literal migration");
625
626        let migrated: i64 = conn
627            .query_row(
628                "SELECT COUNT(*) FROM relationships WHERE relation = 'applies_to'",
629                [],
630                |r| r.get(0),
631            )
632            .unwrap();
633        assert_eq!(migrated, 1, "hyphenated edge must be migrated");
634        let leftover: i64 = conn
635            .query_row(
636                "SELECT COUNT(*) FROM relationships WHERE relation = 'applies-to'",
637                [],
638                |r| r.get(0),
639            )
640            .unwrap();
641        assert_eq!(leftover, 0, "no literal edge may remain");
642    }
643
644    #[test]
645    fn cli_rejects_literal_from_combined_with_from_relation() {
646        use clap::Parser;
647        let err = match crate::cli::Cli::try_parse_from([
648            "sqlite-graphrag",
649            "reclassify-relation",
650            "--from-relation",
651            "mentions",
652            "--literal-from",
653            "applies-to",
654            "--to-relation",
655            "related",
656            "--batch",
657        ]) {
658            Err(e) => e,
659            Ok(_) => panic!("mutually exclusive flags must fail to parse"),
660        };
661        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
662    }
663
664    #[test]
665    fn cli_requires_one_of_from_relation_or_literal_from() {
666        use clap::Parser;
667        let err = match crate::cli::Cli::try_parse_from([
668            "sqlite-graphrag",
669            "reclassify-relation",
670            "--to-relation",
671            "related",
672            "--batch",
673        ]) {
674            Err(e) => e,
675            Ok(_) => panic!("one of the from flags is required"),
676        };
677        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
678    }
679
680    #[test]
681    fn cli_accepts_literal_from_alone_and_keeps_it_verbatim() {
682        use clap::Parser;
683        let parsed = crate::cli::Cli::try_parse_from([
684            "sqlite-graphrag",
685            "reclassify-relation",
686            "--literal-from",
687            "applies-to",
688            "--to-relation",
689            "applies_to",
690            "--batch",
691        ])
692        .expect("literal-from alone must parse");
693        match parsed.command {
694            Some(crate::cli::Commands::ReclassifyRelation(a)) => {
695                assert_eq!(a.literal_from.as_deref(), Some("applies-to"));
696                assert!(a.from_relation.is_none());
697                assert_eq!(a.effective_from(), "applies-to");
698            }
699            _ => unreachable!("unexpected command"),
700        }
701    }
702
703    // -----------------------------------------------------------------------
704    // v1.1.03: --literal-to — grava valor canonical hífen verbatim
705    // -----------------------------------------------------------------------
706
707    #[test]
708    fn literal_to_writes_hyphenated_target() {
709        let (_tmp, mut conn) = setup_migrated_db();
710        conn.execute(
711            "INSERT INTO entities (namespace, name, type) VALUES ('global','ent-a','concept')",
712            [],
713        )
714        .unwrap();
715        let a = conn.last_insert_rowid();
716        conn.execute(
717            "INSERT INTO entities (namespace, name, type) VALUES ('global','ent-b','concept')",
718            [],
719        )
720        .unwrap();
721        let b = conn.last_insert_rowid();
722        // Aresta legacy armazenada com underscore (61357 casos reais).
723        conn.execute(
724            "INSERT INTO relationships (namespace, source_id, target_id, relation, weight) \
725             VALUES ('global', ?1, ?2, 'applies_to', 0.5)",
726            params![a, b],
727        )
728        .unwrap();
729
730        let mut args = base_args();
731        args.from_relation = Some("applies_to".to_string());
732        args.to_relation = None;
733        args.literal_to = Some("applies-to".to_string());
734        run_batch(
735            args,
736            std::time::Instant::now(),
737            "global".to_string(),
738            &mut conn,
739        )
740        .expect("batch literal-to migration");
741
742        let migrated: i64 = conn
743            .query_row(
744                "SELECT COUNT(*) FROM relationships WHERE relation = 'applies-to'",
745                [],
746                |r| r.get(0),
747            )
748            .unwrap();
749        assert_eq!(migrated, 1, "underscore edge must become hyphenated");
750        let leftover: i64 = conn
751            .query_row(
752                "SELECT COUNT(*) FROM relationships WHERE relation = 'applies_to'",
753                [],
754                |r| r.get(0),
755            )
756            .unwrap();
757        assert_eq!(leftover, 0, "no underscore edge may remain");
758    }
759
760    #[test]
761    fn literal_from_applies_to_literal_to_applies_to_hyphen_migrates() {
762        // Reproduz o bug 2: --literal-from applies_to --literal-to applies-to
763        // --batch --dry-run deve retornar count > 0 (antes: erro "must be
764        // different" porque to_relation normalizava para applies_to).
765        let (_tmp, mut conn) = setup_migrated_db();
766        conn.execute(
767            "INSERT INTO entities (namespace, name, type) VALUES ('global','ent-a','concept')",
768            [],
769        )
770        .unwrap();
771        let a = conn.last_insert_rowid();
772        conn.execute(
773            "INSERT INTO entities (namespace, name, type) VALUES ('global','ent-b','concept')",
774            [],
775        )
776        .unwrap();
777        let b = conn.last_insert_rowid();
778        conn.execute(
779            "INSERT INTO relationships (namespace, source_id, target_id, relation, weight) \
780             VALUES ('global', ?1, ?2, 'applies_to', 0.5)",
781            params![a, b],
782        )
783        .unwrap();
784
785        let mut args = base_args();
786        args.from_relation = None;
787        args.literal_from = Some("applies_to".to_string());
788        args.to_relation = None;
789        args.literal_to = Some("applies-to".to_string());
790        args.dry_run = true;
791        // Migração agora passa: effective_from()="applies_to" !=
792        // effective_to()="applies-to".
793        assert_ne!(
794            args.effective_from(),
795            args.effective_to(),
796            "literal underscore→hyphen migration must NOT be treated as equality"
797        );
798        run_batch(
799            args,
800            std::time::Instant::now(),
801            "global".to_string(),
802            &mut conn,
803        )
804        .expect("dry-run must succeed and report the matched edge");
805    }
806
807    #[test]
808    fn literal_to_alone_keeps_verbatim() {
809        use clap::Parser;
810        let parsed = crate::cli::Cli::try_parse_from([
811            "sqlite-graphrag",
812            "reclassify-relation",
813            "--from-relation",
814            "mentions",
815            "--literal-to",
816            "applies-to",
817            "--batch",
818        ])
819        .expect("literal-to alone (no --to-relation) must parse");
820        match parsed.command {
821            Some(crate::cli::Commands::ReclassifyRelation(a)) => {
822                assert_eq!(a.literal_to.as_deref(), Some("applies-to"));
823                assert!(a.to_relation.is_none());
824                assert_eq!(
825                    a.effective_to(),
826                    "applies-to",
827                    "literal_to must win and stay verbatim"
828                );
829            }
830            _ => unreachable!("unexpected command"),
831        }
832    }
833}