Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions crates/buzz-cli/src/commands/channel_templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use std::path::{Path, PathBuf};
use serde::Deserialize;

use crate::error::CliError;
use crate::validate::fold_name;

/// Tauri bundle identifier for the production desktop app. `dirs::data_dir()`
/// joined with this segment matches `app.path().app_data_dir()` exactly
Expand Down Expand Up @@ -98,11 +99,8 @@ fn load_templates(path: &Path) -> Result<Vec<ChannelTemplateRecord>, CliError> {
/// (case-insensitive, exact match). Errors list available names if not found.
pub fn find_template(path: &Path, name: &str) -> Result<ChannelTemplateRecord, CliError> {
let templates = load_templates(path)?;
let needle = name.to_ascii_lowercase();
if let Some(t) = templates
.into_iter()
.find(|t| t.name.to_ascii_lowercase() == needle)
{
let needle = fold_name(name);
if let Some(t) = templates.into_iter().find(|t| fold_name(&t.name) == needle) {
return Ok(t);
}
Err(CliError::NotFound(format!(
Expand Down
21 changes: 18 additions & 3 deletions crates/buzz-cli/src/commands/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::client::{
use crate::commands::agents::fetch_archived_snapshot;
use crate::commands::channel_templates::{self, ChannelTemplateRecord, TemplateAgentRoster};
use crate::error::CliError;
use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid};
use crate::validate::{fold_name, parse_uuid, read_or_stdin, validate_hex64, validate_uuid};

fn extract_channel_metadata(e: &serde_json::Value) -> serde_json::Value {
serde_json::json!({
Expand Down Expand Up @@ -132,7 +132,7 @@ pub async fn cmd_search_channels(
});
let arr = client.query_paginated(filter, limit).await?;

let needle = query.to_ascii_lowercase();
let needle = fold_name(query);
let mut matches: Vec<ChannelSummary> = arr
.iter()
.filter_map(ChannelSummary::from_event)
Expand Down Expand Up @@ -213,7 +213,7 @@ impl ChannelSummary {
}

fn name_matches(name: &str, needle_lower: &str, exact: bool) -> bool {
let hay = name.to_ascii_lowercase();
let hay = fold_name(name);
if exact {
hay == needle_lower
} else {
Expand Down Expand Up @@ -1198,6 +1198,7 @@ mod tests {
ResolvedAgent, RosterResolution, SkippedSlug,
};
use crate::client::BuzzClient;
use crate::validate::fold_name;
use crate::CliError;
use serde_json::json;

Expand Down Expand Up @@ -1283,6 +1284,20 @@ mod tests {
assert!(!name_matches("design", "composer", false));
}

#[test]
fn name_matches_folds_non_ascii_case() {
// `to_ascii_lowercase` left these untouched, so a channel named in any
// language with cased non-ASCII letters could not be found by the
// obvious query — while Desktop, on JavaScript's `toLowerCase`, found
// it. The needle is pre-folded by the caller, as the comment above says.
assert!(name_matches("ÉQUIPE", &fold_name("équipe"), true));
assert!(name_matches("ÉQUIPE Produit", &fold_name("équipe"), false));
assert!(name_matches("ОБЩИЙ", &fold_name("общий"), true));
assert!(name_matches("Ünnepek", &fold_name("ünnepek"), true));
// Still a non-match, so the fold has not made matching sloppy.
assert!(!name_matches("ÉQUIPE", &fold_name("equipe"), true));
}

#[test]
fn name_matches_exact_case_insensitive() {
assert!(name_matches("Buzz", "buzz", true));
Expand Down
28 changes: 24 additions & 4 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use uuid::Uuid;
use crate::client::{normalize_events, normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::{
infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff,
fold_name, infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff,
validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES,
};
use buzz_sdk::mentions::{
Expand Down Expand Up @@ -213,7 +213,9 @@ async fn resolve_content_mentions(
continue;
};
name_to_pubkeys
.entry(name.to_ascii_lowercase())
// Folded exactly as `extract_at_mentions_with_known` folds the
// names it returns — these keys are looked up with those.
.entry(fold_name(name))
.or_default()
.push(pubkey.to_string());
display_names.push(name.to_string());
Expand Down Expand Up @@ -529,7 +531,7 @@ async fn resolve_author(client: &BuzzClient, author: &str) -> Result<String, Cli
/// kind:0 events. Returns deduped `(pubkey, shown name)` pairs. Pure so the
/// name-resolution semantics are unit-testable without a relay.
fn match_profiles_by_name(events: &[serde_json::Value], name: &str) -> Vec<(String, String)> {
let lower = name.to_ascii_lowercase();
let lower = fold_name(name);
let mut matches: Vec<(String, String)> = Vec::new();
for e in events {
let Some(pubkey) = e.get("pubkey").and_then(|v| v.as_str()) else {
Expand All @@ -547,7 +549,7 @@ fn match_profiles_by_name(events: &[serde_json::Value], name: &str) -> Vec<(Stri
.and_then(|v| v.as_str())
.unwrap_or("");
let plain_name = content.get("name").and_then(|v| v.as_str()).unwrap_or("");
if display_name.to_ascii_lowercase() == lower || plain_name.to_ascii_lowercase() == lower {
if fold_name(display_name) == lower || fold_name(plain_name) == lower {
let shown = if display_name.is_empty() {
plain_name
} else {
Expand Down Expand Up @@ -1342,6 +1344,24 @@ mod tests {
assert_eq!(matches, vec![(PK_VALID_A.to_string(), "Aaron".to_string())]);
}

/// `messages search --author` folded with `to_ascii_lowercase`, so a
/// non-ASCII display name was only reachable by typing its exact case.
#[test]
fn author_name_match_folds_non_ascii_case() {
let events = vec![
profile_event(PK_VALID_A, Some("ÉQUIPE"), None),
profile_event(PK_VALID_B, None, Some("Общий")),
];
assert_eq!(
match_profiles_by_name(&events, "équipe"),
vec![(PK_VALID_A.to_string(), "ÉQUIPE".to_string())]
);
assert_eq!(
match_profiles_by_name(&events, "ОБЩИЙ"),
vec![(PK_VALID_B.to_string(), "Общий".to_string())]
);
}

#[test]
fn author_name_ambiguity_returns_all_candidates() {
let events = vec![
Expand Down
6 changes: 3 additions & 3 deletions crates/buzz-cli/src/commands/notes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ use nostr::{Event, EventBuilder, Kind, PublicKey, Tag, Timestamp, ToBech32};

use crate::client::BuzzClient;
use crate::error::CliError;
use crate::validate::validate_hex64;
use crate::validate::{fold_name, validate_hex64};

/// NIP-23 long-form content kind.
pub const KIND_LONG_FORM: u16 = 30023;
Expand Down Expand Up @@ -217,7 +217,7 @@ pub async fn resolve_author(client: &BuzzClient, author_flag: &str) -> Result<Pu
});
let raw = client.query(&filter).await?;
let events = parse_events(&raw)?;
let lower = author_flag.to_ascii_lowercase();
let lower = fold_name(author_flag);
let matches: Vec<&Event> = events
.iter()
.filter(|e| {
Expand All @@ -229,7 +229,7 @@ pub async fn resolve_author(client: &BuzzClient, author_flag: &str) -> Result<Pu
.or_else(|| meta.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("");
name.to_ascii_lowercase() == lower
fold_name(name) == lower
})
.collect();
match matches.len() {
Expand Down
28 changes: 23 additions & 5 deletions crates/buzz-cli/src/commands/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use nostr::PublicKey;

use crate::client::{extract_d_tag, normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::validate_hex64;
use crate::validate::{fold_name, validate_hex64};

// TODO(phase-4): Replace raw nostr::EventBuilder usage in cmd_set_presence with buzz-sdk builder

Expand Down Expand Up @@ -113,7 +113,9 @@ fn owned_agent_pubkeys_from_events(events: &[serde_json::Value], query: &str) ->
let content: serde_json::Value =
serde_json::from_str(event.get("content")?.as_str()?).ok()?;
let name = content.get("name")?.as_str()?;
if !name.eq_ignore_ascii_case(query) {
// `eq_ignore_ascii_case` compares only ASCII letters case-blind, so
// an agent named `Équipe` was unreachable as `équipe`.
if fold_name(name) != fold_name(query) {
return None;
}
let pubkey = extract_d_tag(event);
Expand Down Expand Up @@ -148,7 +150,7 @@ fn profile_content(event: &serde_json::Value) -> serde_json::Map<String, serde_j
}

fn name_search_profiles(events: &[serde_json::Value], query: &str) -> Vec<serde_json::Value> {
let lower_query = query.to_ascii_lowercase();
let lower_query = fold_name(query);
events
.iter()
.filter_map(|event| {
Expand All @@ -161,8 +163,8 @@ fn name_search_profiles(events: &[serde_json::Value], query: &str) -> Vec<serde_
.get("name")
.and_then(|value| value.as_str())
.unwrap_or("");
if !display_name.to_ascii_lowercase().contains(&lower_query)
&& !name.to_ascii_lowercase().contains(&lower_query)
if !fold_name(display_name).contains(&lower_query)
&& !fold_name(name).contains(&lower_query)
{
return None;
}
Expand Down Expand Up @@ -593,6 +595,22 @@ mod tests {
);
}

/// The lookup used `eq_ignore_ascii_case`, which is case-blind for ASCII
/// letters only, so a non-ASCII agent name could only be reached by
/// reproducing its exact case.
#[test]
fn owned_agent_lookup_matches_non_ascii_names_across_case() {
let events = vec![
json!({"content": r#"{"name":"ÉQUIPE"}"#, "tags": [["d", "a"]]}),
json!({"content": r#"{"name":"Общий"}"#, "tags": [["d", "b"]]}),
];
assert_eq!(
owned_agent_pubkeys_from_events(&events, "équipe"),
vec!["a"]
);
assert_eq!(owned_agent_pubkeys_from_events(&events, "ОБЩИЙ"), vec!["b"]);
}

#[test]
fn owned_agent_lookup_ignores_malformed_events() {
let events = vec![
Expand Down
37 changes: 37 additions & 0 deletions crates/buzz-cli/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ pub fn validate_hex64(s: &str) -> Result<(), CliError> {
Ok(())
}

/// Case-fold a user-facing name for matching.
///
/// Re-exported from buzz-sdk, which owns the rule: `extract_at_mentions_with_known`
/// returns keys folded with it, so the CLI's `name → pubkey` maps have to be
/// built with the very same function, not a second copy of it.
pub use buzz_sdk::mentions::fold_name;

/// Validate a git repo identifier: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`.
pub fn validate_repo_id(s: &str) -> Result<(), CliError> {
if s.is_empty() || s.len() > 64 {
Expand Down Expand Up @@ -504,3 +511,33 @@ mod tests {
assert!(matches!(err, CliError::Usage(_)));
}
}

#[cfg(test)]
mod fold_name_tests {
use super::fold_name;

/// Every expectation is JavaScript's `toLowerCase` output for the same
/// input, taken from node, because Desktop is the other implementation of
/// this rule and the two have to agree about what a query finds.
#[test]
fn folds_the_same_way_javascript_does() {
for (input, expected) in [
("ÉQUIPE", "équipe"),
("ОБЩИЙ", "общий"),
("Ünnepek", "ünnepek"),
("JOSÉ", "josé"),
("Straße", "straße"),
("Džungla", "džungla"),
("İstanbul", "i̇stanbul"),
("ÅNGSTRÖM", "ångström"),
] {
assert_eq!(fold_name(input), expected, "folding {input}");
}
}

#[test]
fn leaves_ascii_alone_as_before() {
assert_eq!(fold_name("Buzz-Chat-Composer"), "buzz-chat-composer");
assert_eq!(fold_name(""), "");
}
}
Loading