From cd8dfa72c262e44568c4b76f164be6f0784f950d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:05:25 -0400 Subject: [PATCH] LRC-LIB-KEYWORDS: renaming keeps the levels below whatever the case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renaming a keyword cut the keywords below it at the byte length of the name as typed. Names match whatever their case, but some letters change length with it (ẞ is three bytes, ß two): `STRAẞE` → `Road` turned `straße|nord` into `Roadnord`, and a cut inside a letter would panic. The levels below are now kept whole. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index 43282192..2b4a878c 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -30,10 +30,12 @@ pub fn is_under(k: &str, parent: &str) -> bool { k == p || k.strip_prefix(&p).is_some_and(|rest| rest.starts_with(SEP)) } -/// Replace the `from` prefix of `k` (which [`is_under`] `from`) with `to`. +/// Replace the `from` prefix of `k` (which [`is_under`] `from`) with `to`. By levels, not bytes: +/// `from` may be written in another case, which can change a letter's length (ẞ / ß). fn reparent(k: &str, from: &str, to: &str) -> String { - let rest = &k[from.len().min(k.len())..]; - if to.is_empty() { rest.trim_start_matches(SEP).to_string() } else { format!("{to}{rest}") } + let skip = from.split(SEP).filter(|s| !s.trim().is_empty()).count(); + let rest: Vec<&str> = k.split(SEP).filter(|s| !s.trim().is_empty()).skip(skip).collect(); + std::iter::once(to).filter(|t| !t.is_empty()).chain(rest).collect::>().join("|") } /// Keep the first of case-insensitively equal keywords. @@ -478,6 +480,19 @@ mod tests { assert_eq!(c.to_snapshot(), before); } + /// Renaming matches names whatever their case, also where a letter's case changes its length + /// (ẞ is three bytes, ß two): the levels below are kept whole, never cut mid-letter. + #[test] + fn renaming_keeps_the_levels_below_whatever_the_case() { + let (mut c, ids) = lib(&[&["straße|nord"], &["ßa|ü"]]); + let op = c.rename_keyword_ops("STRAẞE", "Road").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Road|nord"]); + let op = c.rename_keyword_ops("ẞA", "Weg").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[1]), ["Weg|ü"]); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]);