| 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 |
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 |
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 |
|
}
|