1use 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 #[arg(long, conflicts_with = "batch", value_name = "ENTITY")]
48 pub source: Option<String>,
49 #[arg(long, conflicts_with = "batch", value_name = "ENTITY")]
51 pub target: Option<String>,
52 #[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 #[arg(long, value_name = "RELATION")]
68 pub literal_from: Option<String>,
69 #[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 #[arg(long, value_name = "RELATION")]
83 pub literal_to: Option<String>,
84 #[arg(long, default_value_t = false)]
86 pub batch: bool,
87 #[arg(long, value_enum, value_name = "TYPE", requires = "batch")]
89 pub filter_source_type: Option<EntityType>,
90 #[arg(long, value_enum, value_name = "TYPE", requires = "batch")]
92 pub filter_target_type: Option<EntityType>,
93 #[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 count: usize,
113 merged_duplicates: usize,
116 namespace: String,
117 elapsed_ms: u64,
118}
119
120impl ReclassifyRelationArgs {
121 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 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 crate::parsers::warn_if_non_canonical(args.effective_from());
159 crate::parsers::warn_if_non_canonical(args.effective_to());
160
161 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
183fn 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 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 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 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
288fn run_batch(
293 args: ReclassifyRelationArgs,
294 inicio: std::time::Instant,
295 namespace: String,
296 conn: &mut rusqlite::Connection,
297) -> Result<(), AppError> {
298 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 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 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 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
417fn 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 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 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 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 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 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 #[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 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 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 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}