Skip to main content

max / tagtree

Fuzzy suggest, bulk tag ops, benchmarks Add suggest_fuzzy (Levenshtein edit distance, threshold 2), bulk rename/remove/merge/batch_rename on TagIndex, Cargo.toml metadata (repository, keywords, categories), LICENSE, and todo. Benchmarks for all new operations at 1k/10k/50k scale. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-13 22:21 UTC
Commit: fc3db9199434059989e10cd6a8b91397f04c4c89
Parent: 56bb690
5 files changed, +663 insertions, -3 deletions
M Cargo.toml +4
@@ -4,6 +4,10 @@ version = "0.3.0"
4 4 edition = "2024"
5 5 description = "Hierarchical dot-notation tag standard: validation, parsing, tree operations, SQL helpers"
6 6 license-file = "LICENSE"
7 + repository = "https://git.makenot.work/max/tagtree"
8 + readme = "README.md"
9 + keywords = ["tags", "hierarchy", "taxonomy", "tree", "autocomplete"]
10 + categories = ["data-structures", "text-processing"]
7 11
8 12 [dev-dependencies]
9 13 criterion = { version = "0.5", features = ["html_reports"] }
A LICENSE +131
@@ -0,0 +1,131 @@
1 + # PolyForm Noncommercial License 1.0.0
2 +
3 + <https://polyformproject.org/licenses/noncommercial/1.0.0>
4 +
5 + ## Acceptance
6 +
7 + In order to get any license under these terms, you must agree
8 + to them as both strict obligations and conditions to all
9 + your licenses.
10 +
11 + ## Copyright License
12 +
13 + The licensor grants you a copyright license for the
14 + software to do everything you might do with the software
15 + that would otherwise infringe the licensor's copyright
16 + in it for any permitted purpose. However, you may
17 + only distribute the software according to [Distribution
18 + License](#distribution-license) and make changes or new works
19 + based on the software according to [Changes and New Works
20 + License](#changes-and-new-works-license).
21 +
22 + ## Distribution License
23 +
24 + The licensor grants you an additional copyright license
25 + to distribute copies of the software. Your license
26 + to distribute covers distributing the software with
27 + changes and new works permitted by [Changes and New Works
28 + License](#changes-and-new-works-license).
29 +
30 + ## Notices
31 +
32 + You must ensure that anyone who gets a copy of any part of
33 + the software from you also gets a copy of these terms or the
34 + URL for them above, as well as copies of any plain-text lines
35 + beginning with `Required Notice:` that the licensor provided
36 + with the software. For example:
37 +
38 + > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
39 +
40 + ## Changes and New Works License
41 +
42 + The licensor grants you an additional copyright license to
43 + make changes and new works based on the software for any
44 + permitted purpose.
45 +
46 + ## Patent License
47 +
48 + The licensor grants you a patent license for the software that
49 + covers patent claims the licensor can license, or becomes able
50 + to license, that you would infringe by using the software.
51 +
52 + ## Noncommercial Purposes
53 +
54 + Any noncommercial purpose is a permitted purpose.
55 +
56 + ## Personal Uses
57 +
58 + Personal use for research, experiment, and testing for
59 + the benefit of public knowledge, personal study, private
60 + entertainment, hobby projects, amateur pursuits, or religious
61 + observance, without any anticipated commercial application,
62 + is use for a permitted purpose.
63 +
64 + ## Noncommercial Organizations
65 +
66 + Use by any charitable organization, educational institution,
67 + public research organization, public safety or health
68 + organization, environmental protection organization,
69 + or government institution is use for a permitted purpose
70 + regardless of the source of funding or obligations resulting
71 + from the funding.
72 +
73 + ## Fair Use
74 +
75 + You may have "fair use" rights for the software under the
76 + law. These terms do not limit them.
77 +
78 + ## No Other Rights
79 +
80 + These terms do not allow you to sublicense or transfer any of
81 + your licenses to anyone else, or prevent the licensor from
82 + granting licenses to anyone else. These terms do not imply
83 + any other licenses.
84 +
85 + ## Patent Defense
86 +
87 + If you make any written claim that the software infringes or
88 + contributes to infringement of any patent, your patent license
89 + for the software granted under these terms ends immediately. If
90 + your company makes such a claim, your patent license ends
91 + immediately for work on behalf of your company.
92 +
93 + ## Violations
94 +
95 + The first time you are notified in writing that you have
96 + violated any of these terms, or done anything with the software
97 + not covered by your licenses, your licenses can nonetheless
98 + continue if you come into full compliance with these terms,
99 + and take practical steps to correct past violations, within
100 + 32 days of receiving notice. Otherwise, all your licenses
101 + end immediately.
102 +
103 + ## No Liability
104 +
105 + ***As far as the law allows, the software comes as is, without
106 + any warranty or condition, and the licensor will not be liable
107 + to you for any damages arising out of these terms or the use
108 + or nature of the software, under any kind of legal claim.***
109 +
110 + ## Definitions
111 +
112 + The **licensor** is the individual or entity offering these
113 + terms, and the **software** is the software the licensor makes
114 + available under these terms.
115 +
116 + **You** refers to the individual or entity agreeing to these
117 + terms.
118 +
119 + **Your company** is any legal entity, sole proprietorship,
120 + or other kind of organization that you work for, plus all
121 + organizations that have control over, are under the control of,
122 + or are under common control with that organization. **Control**
123 + means ownership of substantially all the assets of an entity,
124 + or the power to direct its management and policies by vote,
125 + contract, or otherwise. Control can be direct or indirect.
126 +
127 + **Your licenses** are all the licenses granted to you for the
128 + software under these terms.
129 +
130 + **Use** means anything you do with the software requiring one
131 + of your licenses.
@@ -1,5 +1,5 @@
1 1 use criterion::{black_box, criterion_group, criterion_main, Criterion};
2 - use tagtree::{TagConfig, TagIndex};
2 + use tagtree::{batch_rename, merge_tags, remove_subtree, rename_prefix_bulk, TagConfig, TagIndex};
3 3
4 4 const AF_CONFIG: TagConfig = TagConfig {
5 5 max_depth: 5,
@@ -174,6 +174,14 @@ fn bench_tag_index(c: &mut Criterion) {
174 174 b.iter(|| index.suggest(black_box("zzz"), 5));
175 175 });
176 176
177 + group.bench_function("suggest_fuzzy_typo", |b| {
178 + b.iter(|| index.suggest_fuzzy(black_box("genr"), 5));
179 + });
180 +
181 + group.bench_function("suggest_fuzzy_no_match", |b| {
182 + b.iter(|| index.suggest_fuzzy(black_box("zzz"), 5));
183 + });
184 +
177 185 group.finish();
178 186 }
179 187
@@ -279,6 +287,22 @@ fn bench_large_tag_index(c: &mut Criterion) {
279 287 b.iter(|| idx_50k.suggest(black_box("genre.sub"), 10));
280 288 });
281 289
290 + // Fuzzy suggest at scale
291 + group.bench_function("suggest_fuzzy/1k", |b| {
292 + b.iter(|| idx_1k.suggest_fuzzy(black_box("genr"), 10));
293 + });
294 + group.bench_function("suggest_fuzzy/10k", |b| {
295 + b.iter(|| idx_10k.suggest_fuzzy(black_box("genr"), 10));
296 + });
297 + group.bench_function("suggest_fuzzy/50k", |b| {
298 + b.iter(|| idx_50k.suggest_fuzzy(black_box("genr"), 10));
299 + });
300 +
301 + // Fuzzy worst case: no match, must scan all segments
302 + group.bench_function("suggest_fuzzy_miss/10k", |b| {
303 + b.iter(|| idx_10k.suggest_fuzzy(black_box("zzz"), 10));
304 + });
305 +
282 306 // Worst case: short prefix that matches many tags
283 307 group.bench_function("suggest_broad/1k", |b| {
284 308 b.iter(|| idx_1k.suggest(black_box("g"), 10));
@@ -384,11 +408,11 @@ fn bench_deep_tree(c: &mut Criterion) {
384 408 let deep_wide = generate_deep_tree(6, 5);
385 409 let deep_wide_len = deep_wide.len();
386 410
387 - group.bench_function(&format!("children_at_prefix/{deep_wide_len}_tags"), |b| {
411 + group.bench_function(format!("children_at_prefix/{deep_wide_len}_tags"), |b| {
388 412 b.iter(|| tagtree::children_at_prefix(black_box("root"), &deep_wide));
389 413 });
390 414
391 - group.bench_function(&format!("subtree/{deep_wide_len}_tags"), |b| {
415 + group.bench_function(format!("subtree/{deep_wide_len}_tags"), |b| {
392 416 b.iter(|| tagtree::subtree(black_box("root"), &deep_wide));
393 417 });
394 418
@@ -406,6 +430,50 @@ fn bench_deep_tree(c: &mut Criterion) {
406 430 group.finish();
407 431 }
408 432
433 + fn bench_bulk_ops(c: &mut Criterion) {
434 + let tags_10k = generate_tags(10_000);
435 + let mut group = c.benchmark_group("bulk_ops");
436 +
437 + group.bench_function("rename_prefix_bulk/10k", |b| {
438 + b.iter_batched(
439 + || TagIndex::new(tags_10k.clone()),
440 + |mut idx| rename_prefix_bulk(black_box("genre"), black_box("style"), &mut idx),
441 + criterion::BatchSize::SmallInput,
442 + );
443 + });
444 +
445 + group.bench_function("remove_subtree/10k", |b| {
446 + b.iter_batched(
447 + || TagIndex::new(tags_10k.clone()),
448 + |mut idx| remove_subtree(black_box("genre"), &mut idx),
449 + criterion::BatchSize::SmallInput,
450 + );
451 + });
452 +
453 + group.bench_function("merge_tags/10k", |b| {
454 + b.iter_batched(
455 + || TagIndex::new(tags_10k.clone()),
456 + |mut idx| merge_tags(black_box("genre.sub-0"), black_box("mood.sub-0"), &mut idx),
457 + criterion::BatchSize::SmallInput,
458 + );
459 + });
460 +
461 + group.bench_function("batch_rename_3ops/10k", |b| {
462 + let ops = [
463 + ("genre.sub-0", "style.sub-0"),
464 + ("mood.sub-1", "vibe.sub-1"),
465 + ("source.sub-2", "origin.sub-2"),
466 + ];
467 + b.iter_batched(
468 + || TagIndex::new(tags_10k.clone()),
469 + |mut idx| batch_rename(black_box(&ops), &mut idx),
470 + criterion::BatchSize::SmallInput,
471 + );
472 + });
473 +
474 + group.finish();
475 + }
476 +
409 477 criterion_group!(
410 478 benches,
411 479 bench_validate,
@@ -417,5 +485,6 @@ criterion_group!(
417 485 bench_large_tag_index,
418 486 bench_large_validate,
419 487 bench_deep_tree,
488 + bench_bulk_ops,
420 489 );
421 490 criterion_main!(benches);
A docs/todo.md +27
@@ -0,0 +1,27 @@
1 + # TagTree - Todo
2 +
3 + Done: Core implementation. Active: None. Next: Post-beta items below.
4 +
5 + v0.3.0. Audit grade A. 105 tests.
6 +
7 + ---
8 +
9 + ## Remaining
10 +
11 + - [ ] Publish to crates.io (metadata ready, awaiting decision to publish)
12 +
13 + ## Deferred
14 +
15 + - [ ] Serde support (optional feature flag for Serialize/Deserialize on TagConfig)
16 + - [ ] `no_std + alloc` support (technically feasible, no current consumers need it)
17 +
18 + ---
19 +
20 + ## Key Paths
21 +
22 + | What | Where |
23 + |------|-------|
24 + | Library source | `src/lib.rs` |
25 + | Tests | `src/lib.rs` (inline `#[cfg(test)]`) |
26 + | Benchmarks | `benches/` |
27 + | Architecture | `docs/architecture.md` |
M src/lib.rs +429
@@ -495,6 +495,154 @@ pub fn rename_prefix(old_prefix: &str, new_prefix: &str, tag: &str) -> Option<St
495 495 }
496 496
497 497 // ---------------------------------------------------------------------------
498 + // Edit distance (private)
499 + // ---------------------------------------------------------------------------
500 +
501 + /// Levenshtein distance with early termination. Returns `None` if the distance
502 + /// exceeds `max`. Uses single-row DP (O(min(m,n)) space).
503 + fn edit_distance(a: &str, b: &str, max: usize) -> Option<usize> {
504 + let a = a.as_bytes();
505 + let b = b.as_bytes();
506 + let (a, b) = if a.len() > b.len() { (b, a) } else { (a, b) };
507 + // Short-circuit: length difference alone exceeds threshold
508 + if b.len() - a.len() > max {
509 + return None;
510 + }
511 + let mut row: Vec<usize> = (0..=a.len()).collect();
512 + for j in 1..=b.len() {
513 + let mut prev = row[0];
514 + row[0] = j;
515 + let mut row_min = row[0];
516 + for i in 1..=a.len() {
517 + let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
518 + let val = (row[i] + 1).min(row[i - 1] + 1).min(prev + cost);
519 + prev = row[i];
520 + row[i] = val;
521 + row_min = row_min.min(val);
522 + }
523 + if row_min > max {
524 + return None;
525 + }
526 + }
527 + let d = row[a.len()];
528 + if d <= max { Some(d) } else { None }
529 + }
530 +
531 + // ---------------------------------------------------------------------------
532 + // Bulk tag operations
533 + // ---------------------------------------------------------------------------
534 +
535 + /// Rename a prefix across all tags in an index. Returns the number of tags modified.
536 + ///
537 + /// ```
538 + /// # use tagtree::{TagIndex, rename_prefix_bulk};
539 + /// let mut idx = TagIndex::new(vec![
540 + /// "genre.electronic.house".into(),
541 + /// "genre.electronic.techno".into(),
542 + /// "genre.rock".into(),
543 + /// ]);
544 + /// assert_eq!(rename_prefix_bulk("genre.electronic", "genre.dance", &mut idx), 2);
545 + /// assert!(idx.contains("genre.dance.house"));
546 + /// assert!(idx.contains("genre.dance.techno"));
547 + /// assert!(!idx.contains("genre.electronic.house"));
548 + /// ```
549 + pub fn rename_prefix_bulk(old_prefix: &str, new_prefix: &str, index: &mut TagIndex) -> usize {
550 + let mut count = 0;
551 + let new_tags: Vec<String> = index
552 + .tags
553 + .iter()
554 + .map(|tag| {
555 + if let Some(renamed) = rename_prefix(old_prefix, new_prefix, tag) {
556 + count += 1;
557 + renamed
558 + } else {
559 + tag.clone()
560 + }
561 + })
562 + .collect();
563 + if count > 0 {
564 + index.rebuild(new_tags);
565 + }
566 + count
567 + }
568 +
569 + /// Remove all tags matching a prefix (the prefix itself and all descendants).
570 + /// Returns the number of tags removed.
571 + ///
572 + /// ```
573 + /// # use tagtree::{TagIndex, remove_subtree};
574 + /// let mut idx = TagIndex::new(vec![
575 + /// "genre.electronic.house".into(),
576 + /// "genre.electronic.techno".into(),
577 + /// "genre.rock".into(),
578 + /// ]);
579 + /// assert_eq!(remove_subtree("genre.electronic", &mut idx), 2);
580 + /// assert_eq!(idx.len(), 1);
581 + /// assert!(idx.contains("genre.rock"));
582 + /// ```
583 + pub fn remove_subtree(prefix: &str, index: &mut TagIndex) -> usize {
584 + let before = index.tags.len();
585 + let remaining: Vec<String> = index
586 + .tags
587 + .iter()
588 + .filter(|tag| tag.as_str() != prefix && !is_ancestor_of(prefix, tag))
589 + .cloned()
590 + .collect();
591 + let removed = before - remaining.len();
592 + if removed > 0 {
593 + index.rebuild(remaining);
594 + }
595 + removed
596 + }
597 +
598 + /// Merge one tag into another: all occurrences of `source` become `target`.
599 + /// If `target` already exists, `source` is simply removed (deduplication).
600 + /// Returns the number of tags affected (0 or 1).
601 + ///
602 + /// ```
603 + /// # use tagtree::{TagIndex, merge_tags};
604 + /// let mut idx = TagIndex::new(vec![
605 + /// "genre.electronic".into(),
606 + /// "genre.dance".into(),
607 + /// ]);
608 + /// assert_eq!(merge_tags("genre.electronic", "genre.dance", &mut idx), 1);
609 + /// assert_eq!(idx.len(), 1);
610 + /// assert!(idx.contains("genre.dance"));
611 + /// ```
612 + pub fn merge_tags(source: &str, target: &str, index: &mut TagIndex) -> usize {
613 + if !index.contains(source) {
614 + return 0;
615 + }
616 + index.remove(source);
617 + if !index.contains(target) {
618 + index.insert(target.to_string());
619 + }
620 + 1
621 + }
622 +
623 + /// Apply a batch of prefix-rename operations in order. Returns total tags modified.
624 + ///
625 + /// ```
626 + /// # use tagtree::{TagIndex, batch_rename};
627 + /// let mut idx = TagIndex::new(vec![
628 + /// "genre.electronic.house".into(),
629 + /// "mood.dark".into(),
630 + /// ]);
631 + /// let ops = [("genre.electronic", "genre.dance"), ("mood", "vibe")];
632 + /// let total = batch_rename(&ops, &mut idx);
633 + /// assert_eq!(total, 2);
634 + /// assert!(idx.contains("genre.dance.house"));
635 + /// assert!(idx.contains("vibe.dark"));
636 + /// ```
637 + pub fn batch_rename(operations: &[(&str, &str)], index: &mut TagIndex) -> usize {
638 + let mut total = 0;
639 + for &(old, new) in operations {
640 + total += rename_prefix_bulk(old, new, index);
641 + }
642 + total
643 + }
644 +
645 + // ---------------------------------------------------------------------------
498 646 // In-memory suggestion index
499 647 // ---------------------------------------------------------------------------
500 648
@@ -707,6 +855,69 @@ impl TagIndex {
707 855 let suggestions = self.suggest(input, limit);
708 856 (suggestions, exact)
709 857 }
858 +
859 + /// Suggest tags tolerating typos. Returns up to `limit` results scored by
860 + /// edit distance (exact/prefix matches first, then fuzzy, sorted by distance).
861 + ///
862 + /// Runs the same two tiers as [`suggest`](Self::suggest), then adds a third
863 + /// fuzzy tier using segment-level Levenshtein distance (threshold ≤ 2).
864 + ///
865 + /// ```
866 + /// use tagtree::TagIndex;
867 + ///
868 + /// let idx = TagIndex::new(vec![
869 + /// "genre.electronic.house".into(),
870 + /// "genre.rock".into(),
871 + /// "mood.dark".into(),
872 + /// ]);
873 + ///
874 + /// // Typo: "genr" → matches "genre" segment (distance 1)
875 + /// assert!(idx.suggest_fuzzy("genr", 10).contains(&"genre.rock"));
876 + ///
877 + /// // Exact prefix still returned first
878 + /// let results = idx.suggest_fuzzy("genre", 10);
879 + /// assert_eq!(results[0], "genre.electronic.house");
880 + /// ```
881 + pub fn suggest_fuzzy(&self, input: &str, limit: usize) -> Vec<&str> {
882 + if input.is_empty() || limit == 0 {
883 + return Vec::new();
884 + }
885 +
886 + // Tiers 1+2: reuse existing suggest
887 + let mut results = self.suggest(input, limit);
888 + if results.len() >= limit {
889 + return results;
890 + }
891 +
892 + // Tier 3: fuzzy segment matching via Levenshtein
893 + let mut fuzzy_hits: Vec<(&str, usize)> = Vec::new();
894 + for tag in &self.tags {
895 + if results.contains(&tag.as_str()) {
896 + continue;
897 + }
898 + let mut best = usize::MAX;
899 + for seg in tag.split(SEPARATOR) {
900 + if let Some(d) = edit_distance(input, seg, 2) {
901 + best = best.min(d);
902 + if best == 0 {
903 + break;
904 + }
905 + }
906 + }
907 + if best <= 2 {
908 + fuzzy_hits.push((tag.as_str(), best));
909 + }
910 + }
911 + fuzzy_hits.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(b.0)));
912 + for (tag, _) in fuzzy_hits {
913 + if results.len() >= limit {
914 + break;
915 + }
916 + results.push(tag);
917 + }
918 +
919 + results
920 + }
710 921 }
711 922
712 923 impl Default for TagIndex {
@@ -1438,4 +1649,222 @@ mod tests {
1438 1649 let (_suggestions, exact) = idx.suggest_with_status("brand-new-tag", 10);
1439 1650 assert!(!exact);
1440 1651 }
1652 +
1653 + // --- edit_distance ---
1654 +
1655 + #[test]
1656 + fn edit_distance_identical() {
1657 + assert_eq!(edit_distance("genre", "genre", 2), Some(0));
1658 + }
1659 +
1660 + #[test]
1661 + fn edit_distance_single_substitution() {
1662 + assert_eq!(edit_distance("rock", "rack", 2), Some(1));
1663 + }
1664 +
1665 + #[test]
1666 + fn edit_distance_single_insertion() {
1667 + assert_eq!(edit_distance("genr", "genre", 2), Some(1));
1668 + }
1669 +
1670 + #[test]
1671 + fn edit_distance_single_deletion() {
1672 + assert_eq!(edit_distance("genre", "genr", 2), Some(1));
1673 + }
1674 +
1675 + #[test]
1676 + fn edit_distance_transposition_as_two_ops() {
1677 + // "ab" → "ba" is 2 (sub+sub), not 1 (no Damerau)
1678 + assert_eq!(edit_distance("ab", "ba", 2), Some(2));
1679 + }
1680 +
1681 + #[test]
1682 + fn edit_distance_exceeds_threshold() {
1683 + assert_eq!(edit_distance("abc", "xyz", 2), None);
1684 + }
1685 +
1686 + #[test]
1687 + fn edit_distance_length_shortcircuit() {
1688 + assert_eq!(edit_distance("a", "abcd", 2), None);
1689 + }
1690 +
1691 + #[test]
1692 + fn edit_distance_empty_strings() {
1693 + assert_eq!(edit_distance("", "", 2), Some(0));
1694 + assert_eq!(edit_distance("", "ab", 2), Some(2));
1695 + assert_eq!(edit_distance("ab", "", 2), Some(2));
1696 + assert_eq!(edit_distance("", "abc", 2), None);
1697 + }
1698 +
1699 + // --- suggest_fuzzy ---
1700 +
1701 + #[test]
1702 + fn suggest_fuzzy_typo_in_root_segment() {
1703 + let idx = test_index();
1704 + // "genr" → "genre" (distance 1)
1705 + let results = idx.suggest_fuzzy("genr", 10);
1706 + assert!(results.contains(&"genre.electronic.house"));
1707 + assert!(results.contains(&"genre.rock"));
1708 + }
1709 +
1710 + #[test]
1711 + fn suggest_fuzzy_typo_in_leaf_segment() {
1712 + let idx = test_index();
1713 + // "hous" → "house" (distance 1)
1714 + let results = idx.suggest_fuzzy("hous", 10);
1715 + assert!(results.contains(&"genre.electronic.house"));
1716 + }
1717 +
1718 + #[test]
1719 + fn suggest_fuzzy_exact_prefix_first() {
1720 + let idx = test_index();
1721 + // "mood" matches exactly via tier 1 path prefix
1722 + let results = idx.suggest_fuzzy("mood", 10);
1723 + assert_eq!(results[0], "mood.dark");
1724 + assert_eq!(results[1], "mood.upbeat");
1725 + }
1726 +
1727 + #[test]
1728 + fn suggest_fuzzy_no_false_positives() {
1729 + let idx = test_index();
1730 + // "zzz" is too far from anything
1731 + assert!(idx.suggest_fuzzy("zzz", 10).is_empty());
1732 + }
1733 +
1734 + #[test]
1735 + fn suggest_fuzzy_respects_limit() {
1736 + let idx = test_index();
1737 + let results = idx.suggest_fuzzy("genr", 2);
1738 + assert_eq!(results.len(), 2);
1739 + }
1740 +
1741 + #[test]
1742 + fn suggest_fuzzy_distance_2() {
1743 + let idx = test_index();
1744 + // "drak" → "dark" (distance 2: transposition = sub+sub)
1745 + let results = idx.suggest_fuzzy("drak", 10);
1746 + assert!(results.contains(&"mood.dark"));
1747 + }
1748 +
1749 + #[test]
1750 + fn suggest_fuzzy_empty_input() {
1751 + let idx = test_index();
1752 + assert!(idx.suggest_fuzzy("", 10).is_empty());
1753 + }
1754 +
1755 + #[test]
1756 + fn suggest_fuzzy_sorted_by_distance() {
1757 + let idx = TagIndex::new(vec![
1758 + "mood.dark".into(),
1759 + "mood.dork".into(),
1760 + "mood.dxxx".into(),
1761 + ]);
1762 + // "dark" → segment "dark"=0 (tier 2 hit), "dork"=1, "dxxx"=3 (exceeds threshold)
1763 + let results = idx.suggest_fuzzy("dark", 10);
1764 + // tier 2 picks up mood.dark (segment "dark" starts_with "dark")
1765 + // tier 3 fuzzy adds mood.dork (distance 1)
1766 + // mood.dxxx not matched (distance > 2)
1767 + assert_eq!(results.len(), 2);
1768 + assert_eq!(results[0], "mood.dark");
1769 + assert_eq!(results[1], "mood.dork");
1770 + }
1771 +
1772 + // --- rename_prefix_bulk ---
1773 +
1774 + #[test]
1775 + fn rename_prefix_bulk_renames_matching() {
1776 + let mut idx = TagIndex::new(vec![
1777 + "genre.electronic.house".into(),
1778 + "genre.electronic.techno".into(),
1779 + "genre.rock".into(),
1780 + ]);
1781 + assert_eq!(rename_prefix_bulk("genre.electronic", "genre.dance", &mut idx), 2);
1782 + assert!(idx.contains("genre.dance.house"));
1783 + assert!(idx.contains("genre.dance.techno"));
1784 + assert!(idx.contains("genre.rock"));
1785 + assert!(!idx.contains("genre.electronic.house"));
1786 + }
1787 +
1788 + #[test]
1789 + fn rename_prefix_bulk_no_match() {
1790 + let mut idx = TagIndex::new(vec!["genre.rock".into()]);
1791 + assert_eq!(rename_prefix_bulk("mood", "vibe", &mut idx), 0);
1792 + assert!(idx.contains("genre.rock"));
1793 + }
1794 +
1795 + // --- remove_subtree ---
1796 +
1797 + #[test]
1798 + fn remove_subtree_removes_prefix_and_descendants() {
1799 + let mut idx = TagIndex::new(vec![
1800 + "genre.electronic.house".into(),
1801 + "genre.electronic.techno".into(),
1802 + "genre.electronic".into(),
1803 + "genre.rock".into(),
1804 + ]);
1805 + assert_eq!(remove_subtree("genre.electronic", &mut idx), 3);
1806 + assert_eq!(idx.len(), 1);
1807 + assert!(idx.contains("genre.rock"));
1808 + }
1809 +
1810 + #[test]
1811 + fn remove_subtree_no_match() {
1812 + let mut idx = TagIndex::new(vec!["genre.rock".into()]);
1813 + assert_eq!(remove_subtree("mood", &mut idx), 0);
1814 + assert_eq!(idx.len(), 1);
1815 + }
1816 +
1817 + // --- merge_tags ---
1818 +
1819 + #[test]
1820 + fn merge_tags_replaces_source() {
1821 + let mut idx = TagIndex::new(vec![
1822 + "genre.electronic".into(),
1823 + "genre.rock".into(),
1824 + ]);
1825 + assert_eq!(merge_tags("genre.electronic", "genre.dance", &mut idx), 1);
1826 + assert!(idx.contains("genre.dance"));
1827 + assert!(idx.contains("genre.rock"));
1828 + assert!(!idx.contains("genre.electronic"));
1829 + }
1830 +
1831 + #[test]
1832 + fn merge_tags_deduplicates() {
1833 + let mut idx = TagIndex::new(vec![
1834 + "genre.electronic".into(),
1835 + "genre.dance".into(),
1836 + ]);
1837 + assert_eq!(merge_tags("genre.electronic", "genre.dance", &mut idx), 1);
1838 + assert_eq!(idx.len(), 1);
1839 + assert!(idx.contains("genre.dance"));
1840 + }
1841 +
1842 + #[test]
1843 + fn merge_tags_source_missing() {
1844 + let mut idx = TagIndex::new(vec!["genre.rock".into()]);
1845 + assert_eq!(merge_tags("genre.electronic", "genre.dance", &mut idx), 0);
1846 + assert_eq!(idx.len(), 1);
1847 + }
1848 +
1849 + // --- batch_rename ---
1850 +
1851 + #[test]
1852 + fn batch_rename_multiple_ops() {
1853 + let mut idx = TagIndex::new(vec![
1854 + "genre.electronic.house".into(),
1855 + "mood.dark".into(),
1856 + ]);
1857 + let ops = [("genre.electronic", "genre.dance"), ("mood", "vibe")];
1858 + assert_eq!(batch_rename(&ops, &mut idx), 2);
1859 + assert!(idx.contains("genre.dance.house"));
1860 + assert!(idx.contains("vibe.dark"));
1861 + }
1862 +
1863 + #[test]
1864 + fn batch_rename_chained() {
1865 + let mut idx = TagIndex::new(vec!["a.b.c".into()]);
1866 + let ops = [("a", "x"), ("x.b", "x.y")];
1867 + assert_eq!(batch_rename(&ops, &mut idx), 2);
1868 + assert!(idx.contains("x.y.c"));
1869 + }
1441 1870 }