diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 8691590636..1ccc37a702 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -12,9 +12,37 @@ pub mod notes; pub mod pack; pub mod patches; pub mod pr; +pub mod projects; pub mod reactions; pub mod repos; pub mod social; pub mod upload; pub mod users; pub mod workflows; + +use crate::{client::normalize_write_response, error::CliError}; + +/// Parse a relay write-response JSON blob, mapping a duplicate (dominated) +/// write to [`CliError::Conflict`] with the caller-supplied message. +/// +/// Used by every command that publishes an NIP-33 addressable event and +/// needs to tell accepted from duplicate/dominated. +pub fn parse_write_response(raw: &str, conflict_msg: &str) -> Result { + let response: serde_json::Value = serde_json::from_str(raw) + .map_err(|e| CliError::Other(format!("relay response is not JSON: {e} ({raw})")))?; + let accepted = response + .get("accepted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let message = response + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + if !accepted { + return Err(CliError::Other(format!("relay rejected event: {message}"))); + } + if message == "duplicate" || message.starts_with("duplicate:") { + return Err(CliError::Conflict(conflict_msg.to_string())); + } + Ok(normalize_write_response(raw)) +} diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs new file mode 100644 index 0000000000..e6798dbfc4 --- /dev/null +++ b/crates/buzz-cli/src/commands/projects.rs @@ -0,0 +1,1198 @@ +//! `buzz projects` commands — NIP-MP kind:30621 write path. +//! +//! All mutations follow a read-modify-write pattern: +//! 1. Fetch the caller's own live head via `kinds:[30621] + authors:[self] + #d:[slug]`. +//! 2. Mutate the tag set (strip `auth`, apply change). +//! 3. Re-validate the full envelope through Layer A before submitting. +//! 4. Set `created_at = head.created_at + 1` (never wall-clock) to avoid +//! overwriting a concurrently advancing head. +//! +//! Limitations recorded in this phase: +//! - Relay hints are read-preserved but not authored (`--repo` carries +//! a coordinate only; existing hinted tags survive RMW unchanged). +//! - `delete` targets signer-self only (NIP-OA owner-delete path deferred). +//! - Deletion durability against later arrival (watermark follow-up) is +//! not in scope. + +use buzz_core::kind::KIND_PROJECT; +use buzz_sdk::{ + build_delete_addressable, build_project, build_project_with_tags, ProjectMemberCoord, + PROJECT_D_MAX_LEN, +}; +use nostr::{Event, EventBuilder, Tag, Timestamp}; + +use crate::client::BuzzClient; +use crate::commands::parse_write_response; +use crate::error::CliError; + +// ── Buzz repo-ID grammar (bare --repo shorthand) ───────────────────────────── + +/// Pattern for a Buzz-hosted repo identifier (bare `--repo` shorthand). +/// `[a-zA-Z0-9._-]{1,64}` — no colons, so guaranteed collision-free with +/// `30617::` full coordinates. +fn is_bare_repo_id(s: &str) -> bool { + !s.is_empty() + && s.len() <= 64 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') +} + +/// Expand a CLI `--repo` argument into a full `30617::` coordinate. +/// +/// Bare form (`[a-zA-Z0-9._-]{1,64}`): owner defaults to the caller's pubkey. +/// Full form (`30617::`): used verbatim. +fn expand_repo_coord(s: &str, caller_pubkey: &str) -> Result { + if is_bare_repo_id(s) { + // Bare form: expand to full coordinate with caller as owner. + let full = format!("30617:{caller_pubkey}:{s}"); + ProjectMemberCoord::parse_full(&full) + .map_err(|e| CliError::Usage(format!("invalid repo coordinate: {e}"))) + } else { + // Full form: must be parseable as a complete coordinate. + ProjectMemberCoord::parse_full(s) + .map_err(|e| CliError::Usage(format!("invalid repo coordinate: {e}"))) + } +} + +// ── Head-fetch helper ───────────────────────────────────────────────────────── + +fn parse_events(json: &str) -> Result, CliError> { + serde_json::from_str(json) + .map_err(|e| CliError::Other(format!("failed to parse relay response: {e}"))) +} + +/// Fetch the caller's own live kind:30621 head for `slug`. +async fn fetch_own_project(client: &BuzzClient, slug: &str) -> Result, CliError> { + fetch_project(client, slug, None).await +} + +/// Fetch a project head by slug and optional owner pubkey. +async fn fetch_project( + client: &BuzzClient, + slug: &str, + owner: Option<&str>, +) -> Result, CliError> { + let pubkey = match owner { + Some(pk) => { + crate::validate::validate_hex64(pk)?; + pk.to_string() + } + None => client.keys().public_key().to_hex(), + }; + let filter = serde_json::json!({ + "kinds": [KIND_PROJECT], + "authors": [pubkey], + "#d": [slug], + "limit": 1, + }); + let raw = client.query(&filter).await?; + let mut events = parse_events(&raw)?; + events.sort_by_key(|e| std::cmp::Reverse(e.created_at)); + Ok(events.into_iter().next()) +} + +// ── Tag helpers ─────────────────────────────────────────────────────────────── + +fn tag_name(tag: &Tag) -> Option<&str> { + tag.as_slice().first().map(String::as_str) +} + +fn tag_value(tag: &Tag) -> Option<&str> { + tag.as_slice().get(1).map(String::as_str) +} + +fn make_tag(parts: &[&str]) -> Result { + Tag::parse(parts.iter().copied()) + .map_err(|e| CliError::Other(format!("tag construction failed: {e}"))) +} + +// ── Submit helper ───────────────────────────────────────────────────────────── + +async fn submit_project(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> { + let event = client.sign_event(builder)?; + let raw = client.submit_event(event).await?; + println!( + "{}", + parse_write_response(&raw, "project changed concurrently; retry")? + ); + Ok(()) +} + +// ── Build helpers ───────────────────────────────────────────────────────────── + +/// Advance the `created_at` counter off an observed head. +fn next_timestamp(head: &Event) -> Result { + head.created_at + .as_secs() + .checked_add(1) + .map(Timestamp::from) + .ok_or_else(|| CliError::Other("project timestamp cannot be advanced".into())) +} + +/// Strip `auth` from a tag list and pass the resulting envelope through +/// Layer A validation. Returns a validated `EventBuilder` at `next_ts`. +fn rebuild_project( + content: &str, + tags: Vec, + next_ts: Timestamp, +) -> Result { + // Strip auth tags. + let clean_tags: Vec = tags + .into_iter() + .filter(|t| tag_name(t) != Some("auth")) + .collect(); + + build_project_with_tags(content, clean_tags) + .map_err(|e| CliError::Other(format!("envelope validation failed: {e}"))) + .map(|b| b.custom_created_at(next_ts)) +} + +// ── Command implementations ─────────────────────────────────────────────────── + +/// `buzz projects create` +pub async fn cmd_create( + client: &BuzzClient, + slug: &str, + repos: &[String], + name: Option<&str>, + description: Option<&str>, + channel: Option<&str>, + visibility: Option<&str>, +) -> Result<(), CliError> { + // ── Local validation (all checks before any .await) ─────────────────── + validate_project_slug(slug)?; + + let caller_pubkey = client.keys().public_key().to_hex(); + + // Expand and validate repo coordinates. + let members: Vec = repos + .iter() + .map(|r| expand_repo_coord(r, &caller_pubkey)) + .collect::, _>>()?; + + // Dedupe: preserve first occurrence, reject duplicates with Usage. + let mut seen = std::collections::HashSet::new(); + for m in &members { + if !seen.insert(m.coord.clone()) { + return Err(CliError::Usage(format!( + "duplicate --repo coordinate in this invocation: {:?}", + m.coord + ))); + } + } + + // Validate optional metadata (early, before any network call). + if let Some(ch) = channel { + crate::validate::validate_uuid(ch)?; + } + if let Some(vis) = visibility { + validate_visibility(vis)?; + } + if let Some(n) = name { + if n.len() > 256 { + return Err(CliError::Usage(format!( + "project name must not exceed 256 bytes (got {})", + n.len() + ))); + } + } + + // ── Network: collision preflight ────────────────────────────────────── + if fetch_own_project(client, slug).await?.is_some() { + return Err(CliError::Conflict(format!( + "project {slug:?} already exists; use 'buzz projects update' to modify it" + ))); + } + + // ── Build via Layer B (enforces all writer policy) ──────────────────── + let builder = build_project(slug, name, description, &members, channel, visibility) + .map_err(|e| CliError::Usage(e.to_string()))?; + submit_project(client, builder).await +} + +/// `buzz projects get` +pub async fn cmd_get(client: &BuzzClient, slug: &str, owner: Option<&str>) -> Result<(), CliError> { + validate_project_slug(slug)?; + let resp = match fetch_project(client, slug, owner).await? { + Some(event) => serde_json::json!({ + "event_id": event.id.to_hex(), + "pubkey": event.pubkey.to_hex(), + "created_at": event.created_at.as_secs(), + "kind": event.kind.as_u16(), + "tags": event.tags.iter().map(|t| t.as_slice().to_vec()).collect::>(), + "content": event.content, + }), + None => { + let owner_desc = owner.unwrap_or("current identity"); + return Err(CliError::NotFound(format!( + "project {slug:?} not found for {owner_desc}" + ))); + } + }; + println!("{resp}"); + Ok(()) +} + +/// `buzz projects list` +pub async fn cmd_list( + client: &BuzzClient, + owner: Option<&str>, + limit: Option, +) -> Result<(), CliError> { + let pubkey = match owner { + Some(pk) => { + crate::validate::validate_hex64(pk)?; + pk.to_string() + } + None => client.keys().public_key().to_hex(), + }; + let mut filter = serde_json::json!({ + "kinds": [KIND_PROJECT], + "authors": [pubkey], + }); + if let Some(n) = limit { + filter["limit"] = serde_json::json!(n); + } + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) +} + +/// `buzz projects add-repo` +pub async fn cmd_add_repo( + client: &BuzzClient, + slug: &str, + repos: &[String], +) -> Result<(), CliError> { + validate_project_slug(slug)?; + let caller_pubkey = client.keys().public_key().to_hex(); + + // ── Local validation before any .await ──────────────────────────────── + let new_members: Vec = repos + .iter() + .map(|r| expand_repo_coord(r, &caller_pubkey)) + .collect::, _>>()?; + + // Dedupe within this invocation: first occurrence wins, duplicate → Usage. + let mut seen = std::collections::HashSet::new(); + for m in &new_members { + if !seen.insert(m.coord.clone()) { + return Err(CliError::Usage(format!( + "duplicate --repo coordinate in this invocation: {:?}", + m.coord + ))); + } + } + + // ── Network: fetch head ─────────────────────────────────────────────── + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head)?; + + // Build the new tag set: keep existing tags (including hinted members), + // append new members only if not already present (by coordinate). + let mut tags: Vec = head.tags.iter().cloned().collect(); + let existing_coords: std::collections::HashSet = head + .tags + .iter() + .filter(|t| tag_name(t) == Some("a")) + .filter_map(|t| tag_value(t).map(String::from)) + .collect(); + let mut added = 0usize; + for m in &new_members { + if !existing_coords.contains(m.coord.as_str()) { + let parts = m.to_tag_parts(); + let parts_ref: Vec<&str> = parts.iter().map(String::as_str).collect(); + tags.push( + Tag::parse(parts_ref.iter().copied()) + .map_err(|e| CliError::Other(format!("member tag construction failed: {e}")))?, + ); + added += 1; + } + } + + // All requested coordinates were already present — no change to publish. + if added == 0 { + return Err(CliError::Conflict(format!( + "all requested repositories are already members of project {slug:?}" + ))); + } + + let builder = rebuild_project(&head.content, tags, next_ts)?; + submit_project(client, builder).await +} + +/// `buzz projects remove-repo` +pub async fn cmd_remove_repo( + client: &BuzzClient, + slug: &str, + repos: &[String], +) -> Result<(), CliError> { + validate_project_slug(slug)?; + let caller_pubkey = client.keys().public_key().to_hex(); + + // ── Local validation before any .await ──────────────────────────────── + let to_remove: Vec = repos + .iter() + .map(|r| expand_repo_coord(r, &caller_pubkey)) + .collect::, _>>()?; + + // ── Network: fetch head ─────────────────────────────────────────────── + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head)?; + + // Verify all requested repos exist in the project. + let existing_coords: std::collections::HashSet = head + .tags + .iter() + .filter(|t| tag_name(t) == Some("a")) + .filter_map(|t| tag_value(t).map(String::from)) + .collect(); + for m in &to_remove { + if !existing_coords.contains(m.coord.as_str()) { + return Err(CliError::NotFound(format!( + "project {slug:?} does not contain member {:?}", + m.coord + ))); + } + } + + let remove_coords: std::collections::HashSet<&str> = + to_remove.iter().map(|m| m.coord.as_str()).collect(); + + // Keep all tags except auth and the removed members. + let tags: Vec = head + .tags + .iter() + .filter(|t| { + if tag_name(t) == Some("auth") { + return false; + } + if tag_name(t) == Some("a") { + if let Some(coord) = tag_value(t) { + return !remove_coords.contains(coord); + } + } + true + }) + .cloned() + .collect(); + + // Single rebuild validates the full envelope and strips any remaining auth. + let builder = rebuild_project(&head.content, tags, next_ts)?; + submit_project(client, builder).await +} + +/// `buzz projects update` +/// +/// Requires at least one setter or clearer; a no-op call is a usage error. +#[allow(clippy::too_many_arguments)] +pub async fn cmd_update( + client: &BuzzClient, + slug: &str, + name: Option<&str>, + clear_name: bool, + description: Option<&str>, + clear_description: bool, + channel: Option<&str>, + clear_channel: bool, + visibility: Option<&str>, + clear_visibility: bool, +) -> Result<(), CliError> { + // Guard: at least one mutation required. The clap `ArgGroup` with + // `required(true).multiple(true)` enforces this at parse time; this + // runtime check is a defense-in-depth safety net for callers that invoke + // `cmd_update` directly (e.g. tests and future programmatic callers). + let has_mutation = name.is_some() + || clear_name + || description.is_some() + || clear_description + || channel.is_some() + || clear_channel + || visibility.is_some() + || clear_visibility; + if !has_mutation { + return Err(CliError::Usage( + "buzz projects update requires at least one of: \ + --name, --clear-name, --description, --clear-description, \ + --channel, --clear-channel, --visibility, --clear-visibility" + .into(), + )); + } + + validate_project_slug(slug)?; + if let Some(ch) = channel { + crate::validate::validate_uuid(ch)?; + } + if let Some(vis) = visibility { + validate_visibility(vis)?; + } + + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head)?; + + // Build the new tag set. For each singleton metadata field: + // - setter present: replace value (strip old, append new) + // - clear flag set: drop the tag + // - neither: keep existing + // Non-singleton / non-metadata tags (d, a, unknown) are preserved as-is. + let singleton_fields = ["name", "description", "buzz-channel", "buzz-visibility"]; + let mut tags: Vec = head + .tags + .iter() + .filter(|t| { + if tag_name(t) == Some("auth") { + return false; + } + // Drop singletons we're replacing or clearing. + if let Some(field) = tag_name(t) { + if singleton_fields.contains(&field) { + let clear = match field { + "name" => clear_name || name.is_some(), + "description" => clear_description || description.is_some(), + "buzz-channel" => clear_channel || channel.is_some(), + "buzz-visibility" => clear_visibility || visibility.is_some(), + _ => false, + }; + return !clear; + } + } + true + }) + .cloned() + .collect(); + + // Append new singleton values. + if let Some(n) = name { + tags.push(make_tag(&["name", n])?); + } + if let Some(d) = description { + tags.push(make_tag(&["description", d])?); + } + if let Some(ch) = channel { + tags.push(make_tag(&["buzz-channel", ch])?); + } + if let Some(vis) = visibility { + tags.push(make_tag(&["buzz-visibility", vis])?); + } + + let builder = build_project_with_tags(&head.content, tags) + .map_err(|e| CliError::Other(format!("envelope validation failed: {e}")))? + .custom_created_at(next_ts); + submit_project(client, builder).await +} + +/// `buzz projects delete` +/// +/// Head-based and verified: +/// 1. Fetch own live head — `NotFound` if absent. +/// 2. Build tombstone at `head.created_at + 1`. +/// 3. Submit. +/// 4. Re-query the coordinate; if a newer head survived → `Conflict`. +pub async fn cmd_delete(client: &BuzzClient, slug: &str) -> Result<(), CliError> { + validate_project_slug(slug)?; + + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head)?; + + let pubkey_hex = client.keys().public_key().to_hex(); + let tombstone = build_delete_addressable(KIND_PROJECT, &pubkey_hex, slug) + .map_err(|e| CliError::Other(format!("failed to build delete event: {e}")))? + .custom_created_at(next_ts); + + let event = client.sign_event(tombstone)?; + let raw = client.submit_event(event).await?; + parse_write_response(&raw, "delete event was dominated; a newer head exists")?; + + // Post-submit verification: re-query to confirm the head is gone. + if let Some(survivor) = fetch_own_project(client, slug).await? { + // A newer head survived the tombstone. + return Err(CliError::Conflict(format!( + "project {slug:?} still exists (head at {}); a concurrent write raced the delete", + survivor.created_at.as_secs() + ))); + } + + println!("{}", serde_json::json!({ "deleted": slug, "status": "ok" })); + Ok(()) +} + +// ── Validation helpers ──────────────────────────────────────────────────────── + +/// Validate a project slug: non-empty, ≤1024 bytes, verbatim. +/// Does NOT impose the Buzz repo-ID grammar — project slugs are more permissive. +fn validate_project_slug(slug: &str) -> Result<(), CliError> { + if slug.is_empty() { + return Err(CliError::Usage("project slug must not be empty".into())); + } + if slug.len() > PROJECT_D_MAX_LEN { + return Err(CliError::Usage(format!( + "project slug must not exceed {PROJECT_D_MAX_LEN} bytes (got {})", + slug.len() + ))); + } + Ok(()) +} + +/// Validate a `buzz-visibility` value at the writer level. +fn validate_visibility(vis: &str) -> Result<(), CliError> { + if vis != "listed" && vis != "unlisted" { + return Err(CliError::Usage(format!( + "visibility must be 'listed' or 'unlisted' (got {vis:?})" + ))); + } + Ok(()) +} + +// ── Dispatch ────────────────────────────────────────────────────────────────── + +pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<(), CliError> { + use crate::ProjectsCmd; + match cmd { + ProjectsCmd::Create { + slug, + repo, + name, + description, + channel, + visibility, + } => { + cmd_create( + client, + &slug, + &repo, + name.as_deref(), + description.as_deref(), + channel.as_deref(), + visibility.map(|v| v.as_str()), + ) + .await + } + ProjectsCmd::Get { slug, owner } => cmd_get(client, &slug, owner.as_deref()).await, + ProjectsCmd::List { owner, limit } => cmd_list(client, owner.as_deref(), limit).await, + ProjectsCmd::AddRepo { slug, repo } => cmd_add_repo(client, &slug, &repo).await, + ProjectsCmd::RemoveRepo { slug, repo } => cmd_remove_repo(client, &slug, &repo).await, + ProjectsCmd::Update { + slug, + name, + clear_name, + description, + clear_description, + channel, + clear_channel, + visibility, + clear_visibility, + } => { + cmd_update( + client, + &slug, + name.as_deref(), + clear_name, + description.as_deref(), + clear_description, + channel.as_deref(), + clear_channel, + visibility.map(|v| v.as_str()), + clear_visibility, + ) + .await + } + ProjectsCmd::Delete { slug } => cmd_delete(client, &slug).await, + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use buzz_sdk::{validate_project_envelope, PROJECT_MEMBER_CAP}; + use nostr::Tag; + + use super::*; + + // ── Coordinate expansion ────────────────────────────────────────────────── + + const OWNER_HEX: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OWNER_B_HEX: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + #[test] + fn expand_repo_coord_bare_expands_with_caller_pubkey() { + let coord = expand_repo_coord("my-repo", OWNER_HEX).unwrap(); + assert_eq!(coord.coord, format!("30617:{OWNER_HEX}:my-repo")); + } + + #[test] + fn expand_repo_coord_full_passes_through() { + let full = format!("30617:{OWNER_HEX}:some-repo"); + let coord = expand_repo_coord(&full, OWNER_B_HEX).unwrap(); + // Owner from the full coord, not the caller. + assert_eq!(coord.coord, full); + } + + #[test] + fn expand_repo_coord_full_cross_owner() { + let full = format!("30617:{OWNER_B_HEX}:infra"); + let coord = expand_repo_coord(&full, OWNER_HEX).unwrap(); + assert_eq!(coord.coord, full); + } + + #[test] + fn expand_repo_coord_rejects_uppercase_owner() { + let upper = "30617:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:buzz"; + assert!(expand_repo_coord(upper, OWNER_HEX).is_err()); + } + + #[test] + fn expand_repo_coord_rejects_coordinate_shaped_bare_value() { + // A value with a colon is never a bare id. + let not_bare = "30617:something"; + // parse_full will fail because it's not a valid full coordinate either. + assert!(expand_repo_coord(not_bare, OWNER_HEX).is_err()); + } + + // ── validate_project_slug ───────────────────────────────────────────────── + + #[test] + fn validate_project_slug_accepts_normal() { + assert!(validate_project_slug("my-project").is_ok()); + assert!(validate_project_slug("platform:v2").is_ok()); // colons allowed — more permissive than repo-id + } + + #[test] + fn validate_project_slug_rejects_empty() { + assert!(validate_project_slug("").is_err()); + } + + #[test] + fn validate_project_slug_rejects_over_1024() { + let long = "a".repeat(1025); + assert!(validate_project_slug(&long).is_err()); + } + + #[test] + fn validate_project_slug_accepts_1024() { + let at_limit = "a".repeat(1024); + assert!(validate_project_slug(&at_limit).is_ok()); + } + + // ── validate_visibility ─────────────────────────────────────────────────── + + #[test] + fn validate_visibility_accepts_listed_and_unlisted() { + assert!(validate_visibility("listed").is_ok()); + assert!(validate_visibility("unlisted").is_ok()); + } + + #[test] + fn validate_visibility_rejects_unknown_token() { + assert!(validate_visibility("chartreuse").is_err()); + assert!(validate_visibility("").is_err()); + } + + // ── is_bare_repo_id ─────────────────────────────────────────────────────── + + #[test] + fn bare_repo_id_accepts_valid() { + assert!(is_bare_repo_id("buzz")); + assert!(is_bare_repo_id("my-repo_1.0")); + } + + #[test] + fn bare_repo_id_rejects_colon() { + assert!(!is_bare_repo_id("30617:something")); + assert!(!is_bare_repo_id("has:colon")); + } + + #[test] + fn bare_repo_id_rejects_empty() { + assert!(!is_bare_repo_id("")); + } + + #[test] + fn bare_repo_id_rejects_over_64() { + let long = "a".repeat(65); + assert!(!is_bare_repo_id(&long)); + } + + // ── tag helpers ─────────────────────────────────────────────────────────── + + fn make_test_tag(parts: &[&str]) -> Tag { + Tag::parse(parts.iter().copied()).unwrap() + } + + // ── rebuild_project: hinted / unknown tag preservation ─────────────────── + + #[test] + fn rebuild_project_preserves_hinted_member_tags() { + // A member 'a' tag with a relay hint must survive RMW untouched. + let coord = format!("30617:{OWNER_HEX}:buzz"); + let hint = "wss://relay.example.com"; + let tags = vec![ + make_test_tag(&["d", "platform"]), + Tag::parse(["a", &coord, hint]).unwrap(), + ]; + let ts = Timestamp::from(1_700_000_001u64); + let b = rebuild_project("", tags, ts).unwrap(); + let ev = b.sign_with_keys(&nostr::Keys::generate()).expect("sign"); + let a_tag = ev + .tags + .iter() + .find(|t| tag_name(t) == Some("a")) + .expect("a tag present"); + assert_eq!( + a_tag.as_slice(), + &["a".to_string(), coord, hint.to_string()], + "relay hint must survive rebuild" + ); + } + + #[test] + fn rebuild_project_preserves_unknown_tags() { + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["future-metadata", "value"]), + ]; + let ts = Timestamp::from(1_700_000_001u64); + let b = rebuild_project("", tags, ts).unwrap(); + let ev = b.sign_with_keys(&nostr::Keys::generate()).expect("sign"); + assert!(ev + .tags + .iter() + .any(|t| tag_name(t) == Some("future-metadata"))); + } + + #[test] + fn rebuild_project_strips_auth_tag() { + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["auth", &"a".repeat(64), "kind=30617", &"b".repeat(128)]), + ]; + let ts = Timestamp::from(1_700_000_001u64); + let b = rebuild_project("", tags, ts).unwrap(); + let ev = b.sign_with_keys(&nostr::Keys::generate()).expect("sign"); + assert!( + !ev.tags.iter().any(|t| tag_name(t) == Some("auth")), + "auth tag must be stripped" + ); + } + + #[test] + fn rebuild_project_rejects_over_cap_foreign_head() { + // A foreign head with 65 members must fail Layer A on republish. + let mut tags = vec![make_test_tag(&["d", "wide"])]; + for i in 0..=64u32 { + let coord = format!("30617:{OWNER_HEX}:repo-{i:02}"); + tags.push(make_test_tag(&["a", &coord])); + } + assert_eq!( + tags.iter().filter(|t| tag_name(t) == Some("a")).count(), + 65, + "65 a-tags" + ); + let ts = Timestamp::from(1_700_000_001u64); + // rebuild_project strips auth, but 65 a-tags still exceeds cap. + assert!( + rebuild_project("", tags, ts).is_err(), + "over-cap foreign head must fail rebuild" + ); + } + + #[test] + fn rebuild_project_at_exact_cap_succeeds() { + let mut tags = vec![make_test_tag(&["d", "wide"])]; + for i in 0..PROJECT_MEMBER_CAP { + let coord = format!("30617:{OWNER_HEX}:repo-{i:02}"); + tags.push(make_test_tag(&["a", &coord])); + } + let ts = Timestamp::from(1_700_000_001u64); + assert!(rebuild_project("", tags, ts).is_ok()); + } + + // ── clear-flag semantics ────────────────────────────────────────────────── + + /// Build a minimal head Event for testing update semantics without the relay. + fn make_head_tags(extra: &[Tag]) -> Vec { + let mut tags = vec![make_test_tag(&["d", "platform"])]; + tags.extend_from_slice(extra); + tags + } + + #[allow(clippy::too_many_arguments)] + fn apply_update_tags( + head_tags: Vec, + name: Option<&str>, + clear_name: bool, + description: Option<&str>, + clear_description: bool, + channel: Option<&str>, + clear_channel: bool, + visibility: Option<&str>, + clear_visibility: bool, + ) -> Vec { + // Replicate the tag-mutation logic from cmd_update (sans relay I/O). + let singleton_fields = ["name", "description", "buzz-channel", "buzz-visibility"]; + let mut tags: Vec = head_tags + .iter() + .filter(|t| { + if tag_name(t) == Some("auth") { + return false; + } + if let Some(field) = tag_name(t) { + if singleton_fields.contains(&field) { + let clear = match field { + "name" => clear_name || name.is_some(), + "description" => clear_description || description.is_some(), + "buzz-channel" => clear_channel || channel.is_some(), + "buzz-visibility" => clear_visibility || visibility.is_some(), + _ => false, + }; + return !clear; + } + } + true + }) + .cloned() + .collect(); + if let Some(n) = name { + tags.push(make_test_tag(&["name", n])); + } + if let Some(d) = description { + tags.push(make_test_tag(&["description", d])); + } + if let Some(ch) = channel { + tags.push(make_test_tag(&["buzz-channel", ch])); + } + if let Some(vis) = visibility { + tags.push(make_test_tag(&["buzz-visibility", vis])); + } + tags + } + + #[test] + fn update_omission_preserves_existing_field() { + let head = make_head_tags(&[make_test_tag(&["name", "Old Name"])]); + let result = apply_update_tags(head, None, false, None, false, None, false, None, false); + assert!(result.iter().any(|t| tag_value(t) == Some("Old Name"))); + } + + #[test] + fn update_setter_replaces_existing_field() { + let head = make_head_tags(&[make_test_tag(&["name", "Old Name"])]); + let result = apply_update_tags( + head, + Some("New Name"), + false, + None, + false, + None, + false, + None, + false, + ); + assert!(result.iter().any(|t| tag_value(t) == Some("New Name"))); + assert!(!result.iter().any(|t| tag_value(t) == Some("Old Name"))); + } + + #[test] + fn update_clear_drops_existing_field() { + let head = make_head_tags(&[make_test_tag(&["name", "Old Name"])]); + let result = apply_update_tags(head, None, true, None, false, None, false, None, false); + assert!(!result.iter().any(|t| tag_name(t) == Some("name"))); + } + + #[test] + fn update_clear_visibility_drops_tag() { + let head = make_head_tags(&[make_test_tag(&["buzz-visibility", "unlisted"])]); + let result = apply_update_tags(head, None, false, None, false, None, false, None, true); + assert!(!result + .iter() + .any(|t| tag_name(t) == Some("buzz-visibility"))); + } + + #[test] + fn update_exactly_one_singleton_after_replace() { + // Start with a buzz-channel; replace with a new one; must have exactly one. + let uuid1 = "3580ca9b-47b4-4af9-b22a-1068778f26c6"; + let uuid2 = "00000000-0000-0000-0000-000000000000"; + let head = make_head_tags(&[make_test_tag(&["buzz-channel", uuid1])]); + let result = apply_update_tags( + head, + None, + false, + None, + false, + Some(uuid2), + false, + None, + false, + ); + let channels: Vec<_> = result + .iter() + .filter(|t| tag_name(t) == Some("buzz-channel")) + .collect(); + assert_eq!(channels.len(), 1); + assert_eq!(tag_value(channels[0]), Some(uuid2)); + } + + // ── duplicate-member rejection on republish ─────────────────────────────── + + #[test] + fn duplicate_member_in_foreign_head_fails_rebuild() { + let coord = format!("30617:{OWNER_HEX}:buzz"); + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["a", &coord]), + make_test_tag(&["a", &coord]), // duplicate + ]; + let ts = Timestamp::from(1_700_000_001u64); + assert!(rebuild_project("", tags, ts).is_err()); + } + + // ── validate_project_envelope integration ──────────────────────────────── + + #[test] + fn validate_project_envelope_accepts_hinted_member() { + let coord = format!("30617:{OWNER_HEX}:buzz"); + let tags = vec![ + make_test_tag(&["d", "platform"]), + Tag::parse(["a", &coord, "wss://relay.example.com"]).unwrap(), + ]; + assert!(validate_project_envelope(&tags, "").is_ok()); + } + + #[test] + fn validate_project_envelope_rejects_four_element_member() { + let coord = format!("30617:{OWNER_HEX}:buzz"); + let tags = vec![ + make_test_tag(&["d", "platform"]), + Tag::parse(["a", &coord, "wss://relay.example.com", "extra"]).unwrap(), + ]; + assert!(validate_project_envelope(&tags, "").is_err()); + } + + // ── next_timestamp ordering ─────────────────────────────────────────────── + + /// `next_timestamp` must return `head.created_at + 1` regardless of the wall + /// clock. NIP-MP Deletion rule: a tombstone older than the live head does + /// NOT remove it, so we must advance strictly off the observed head — never + /// use wall-clock time, which could be behind a head that was bumped + /// multiple times in the same second. + #[test] + fn next_timestamp_returns_head_plus_one_when_head_is_ahead_of_wall_clock() { + // Build a minimal signed event with a created_at far in the future. + let keys = nostr::Keys::generate(); + let far_future_ts = Timestamp::from(9_999_999_999u64); // year 2286 + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["a", &format!("30617:{OWNER_HEX}:buzz")]), + ]; + let builder = rebuild_project("", tags, far_future_ts).expect("valid head envelope"); + let head = builder.sign_with_keys(&keys).expect("sign"); + // Verify the event actually has our future timestamp. + assert_eq!(head.created_at, far_future_ts); + + // next_timestamp must return far_future + 1, not now(). + let next = next_timestamp(&head).expect("no overflow"); + assert_eq!( + next.as_secs(), + far_future_ts.as_secs() + 1, + "tombstone must be strictly after head, even when head is far in the future" + ); + } + + // ── empty update guard ──────────────────────────────────────────────────── + + /// `cmd_update` with no setters or clearers must return `CliError::Usage` + /// before making any network call. The guard is synchronous (before the + /// first `.await`) so we can drive it with a dummy client whose address + /// would reject any real connection attempt. + #[tokio::test] + async fn empty_update_returns_usage_error_before_any_network_call() { + let keys = nostr::Keys::generate(); + // Port 9 is the discard protocol — any real connect will be refused + // immediately, but the guard fires before the first await so this + // never reaches the network. + let client = crate::client::BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None) + .expect("client construction"); + + let err = cmd_update( + &client, "my-slug", None, false, // name / clear_name + None, false, // description / clear_description + None, false, // channel / clear_channel + None, false, // visibility / clear_visibility + ) + .await + .expect_err("empty update must fail"); + + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage, got {err:?}" + ); + } + + // ── no-network malformed-input tests ───────────────────────────────────── + // + // All three cases use port 9 (discard protocol): any real connection is + // refused immediately, but local validation fires before the first .await + // so the network is never touched. + + fn discard_client() -> crate::client::BuzzClient { + let keys = nostr::Keys::generate(); + crate::client::BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None) + .expect("client construction") + } + + /// Invalid visibility token must return Usage before touching the relay. + #[tokio::test] + async fn create_invalid_visibility_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_create( + &client, + "my-slug", + &["buzz".to_string()], + None, + None, + None, + Some("chartreuse"), + ) + .await + .expect_err("invalid visibility must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for invalid visibility, got {err:?}" + ); + } + + /// A name longer than 256 bytes must return Usage before touching the relay. + #[tokio::test] + async fn create_overlong_name_returns_usage_before_any_network_call() { + let client = discard_client(); + let long_name = "a".repeat(257); + let err = cmd_create( + &client, + "my-slug", + &["buzz".to_string()], + Some(&long_name), + None, + None, + None, + ) + .await + .expect_err("overlong name must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for overlong name, got {err:?}" + ); + } + + /// A malformed --repo coordinate must return Usage before touching the relay. + #[tokio::test] + async fn create_malformed_repo_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_create( + &client, + "my-slug", + &["nope:bad".to_string()], + None, + None, + None, + None, + ) + .await + .expect_err("malformed repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for malformed repo, got {err:?}" + ); + } + + /// A malformed --repo coordinate on add-repo must return Usage before touching the relay. + #[tokio::test] + async fn add_repo_malformed_coord_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_add_repo(&client, "my-slug", &["nope:bad".to_string()]) + .await + .expect_err("malformed repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for malformed repo on add-repo, got {err:?}" + ); + } + + /// A malformed --repo coordinate on remove-repo must return Usage before touching the relay. + #[tokio::test] + async fn remove_repo_malformed_coord_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_remove_repo(&client, "my-slug", &["nope:bad".to_string()]) + .await + .expect_err("malformed repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for malformed repo on remove-repo, got {err:?}" + ); + } + + // ── duplicate --repo within one invocation ──────────────────────────────── + + /// Supplying the same coordinate twice in one create call must return Usage + /// (names the duplicate) before any network call. + #[tokio::test] + async fn create_duplicate_repo_returns_usage_before_any_network_call() { + let client = discard_client(); + let coord = format!("30617:{OWNER_HEX}:buzz"); + let err = cmd_create( + &client, + "my-slug", + &[coord.clone(), coord.clone()], + None, + None, + None, + None, + ) + .await + .expect_err("duplicate repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for duplicate repo, got {err:?}" + ); + // Error message must name the duplicate coordinate. + assert!( + format!("{err}").contains("buzz"), + "Usage message must name the duplicate coordinate, got {err:?}" + ); + } + + /// Supplying the same coordinate twice in one add-repo call must return Usage + /// (names the duplicate) before any network call. + #[tokio::test] + async fn add_repo_duplicate_coord_returns_usage_before_any_network_call() { + let client = discard_client(); + let coord = format!("30617:{OWNER_HEX}:buzz"); + let err = cmd_add_repo(&client, "my-slug", &[coord.clone(), coord.clone()]) + .await + .expect_err("duplicate repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for duplicate repo on add-repo, got {err:?}" + ); + } + + // ── create collision guard ──────────────────────────────────────────────── + + // The create-collision Conflict path is pinned by the live transcript + // (step: duplicate create → Conflict, exit=5). No relay mock is available + // for a unit test; the no-network tests above cover all pre-await paths. + + // ── add-repo no-op guard ────────────────────────────────────────────────── + + // The add-repo no-op Conflict path is pinned by the live transcript + // (step 7: buzz already present → exit=5). No relay mock is available + // for a unit test; the async no-network tests above cover all pre-await paths. +} diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index 608d495055..15e064d9c3 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -4,7 +4,8 @@ use buzz_core::{ }; use nostr::{Event, EventBuilder, Tag, Timestamp}; -use crate::client::{normalize_write_response, BuzzClient}; +use crate::client::BuzzClient; +use crate::commands::parse_write_response; use crate::error::CliError; use crate::validate::validate_repo_id; @@ -186,25 +187,10 @@ fn protection_rules_json(event: &Event) -> Result { } fn validate_write_response(raw: &str) -> Result { - let response: serde_json::Value = serde_json::from_str(raw) - .map_err(|error| CliError::Other(format!("relay response is not JSON: {error} ({raw})")))?; - let accepted = response - .get("accepted") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - let message = response - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - if !accepted { - return Err(CliError::Other(format!("relay rejected event: {message}"))); - } - if message == "duplicate" || message.starts_with("duplicate:") { - return Err(CliError::Conflict( - "repository changed concurrently; fetch the latest rules and retry".into(), - )); - } - Ok(normalize_write_response(raw)) + parse_write_response( + raw, + "repository changed concurrently; fetch the latest rules and retry", + ) } async fn submit_repo_update(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> { diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0726406d29..30217ad965 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -212,6 +212,9 @@ enum Cmd { /// Announce and discover git repositories (NIP-34) #[command(subcommand)] Repos(ReposCmd), + /// Create and manage multi-repo projects (NIP-MP) + #[command(subcommand)] + Projects(ProjectsCmd), /// Send, get, list, and set status on git patches (NIP-34) #[command(subcommand)] Patches(PatchesCmd), @@ -1227,6 +1230,122 @@ pub enum RepoPushRole { Member, } +/// Visibility of a multi-repo project listing. +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +pub enum ProjectVisibility { + /// Project appears in public listings (default). + Listed, + /// Project is hidden from public listings. + Unlisted, +} + +impl ProjectVisibility { + pub fn as_str(self) -> &'static str { + match self { + ProjectVisibility::Listed => "listed", + ProjectVisibility::Unlisted => "unlisted", + } + } +} + +#[derive(Subcommand)] +pub enum ProjectsCmd { + /// Create a new multi-repo project (NIP-MP kind:30621) + /// + /// Requires at least one --repo. Fails with Conflict if the project already exists. + Create { + /// Project identifier (slug), up to 1024 bytes + slug: String, + /// Member repository coordinate: bare Buzz repo id (e.g. `buzz`) or full + /// `30617::` for cross-owner or colon-bearing repo ids. + /// At least one --repo is required. + #[arg(long = "repo", required = true)] + repo: Vec, + /// Display name (≤256 bytes) + #[arg(long)] + name: Option, + /// Description (≤2048 bytes) + #[arg(long)] + description: Option, + /// Associated Buzz channel UUID + #[arg(long)] + channel: Option, + /// Visibility: `listed` (default) or `unlisted` + #[arg(long)] + visibility: Option, + }, + /// Get a project by slug + Get { + /// Project slug + slug: String, + /// Owner pubkey (64-char hex). Defaults to the current identity. + #[arg(long)] + owner: Option, + }, + /// List projects + List { + /// Owner pubkey (64-char hex). Defaults to the current identity. + #[arg(long)] + owner: Option, + /// Maximum number of results + #[arg(long)] + limit: Option, + }, + /// Add one or more member repositories to a project + #[command(name = "add-repo")] + AddRepo { + /// Project slug + slug: String, + /// Member repository coordinate (bare id or full `30617::`) + #[arg(long = "repo", required = true)] + repo: Vec, + }, + /// Remove one or more member repositories from a project + #[command(name = "remove-repo")] + RemoveRepo { + /// Project slug + slug: String, + /// Member repository coordinate to remove (bare id or full `30617::`) + #[arg(long = "repo", required = true)] + repo: Vec, + }, + /// Update project metadata (at least one setter or clearer required) + #[command(group = clap::ArgGroup::new("mutation").required(true).multiple(true))] + Update { + /// Project slug + slug: String, + /// Set the display name + #[arg(long, group = "mutation")] + name: Option, + /// Remove the display name + #[arg(long, group = "mutation", conflicts_with = "name")] + clear_name: bool, + /// Set the description + #[arg(long, group = "mutation")] + description: Option, + /// Remove the description + #[arg(long, group = "mutation", conflicts_with = "description")] + clear_description: bool, + /// Set the associated Buzz channel UUID + #[arg(long, group = "mutation")] + channel: Option, + /// Remove the associated channel + #[arg(long, group = "mutation", conflicts_with = "channel")] + clear_channel: bool, + /// Set visibility: `listed` or `unlisted` + #[arg(long, group = "mutation")] + visibility: Option, + /// Remove the visibility tag (absence defaults to `listed`) + #[arg(long, group = "mutation", conflicts_with = "visibility")] + clear_visibility: bool, + }, + /// Delete a project (head-based tombstone; verified after submit) + Delete { + /// Project slug + slug: String, + }, +} + #[derive(Subcommand)] pub enum PatchesCmd { /// Send a git patch (NIP-34 kind:1617) @@ -1819,6 +1938,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Social(sub) => commands::social::dispatch(sub, &client).await, Cmd::Notes(sub) => commands::notes::dispatch(sub, &client).await, Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await, + Cmd::Projects(sub) => commands::projects::dispatch(sub, &client).await, Cmd::Patches(sub) => commands::patches::dispatch(sub, &client).await, Cmd::Issues(sub) => commands::issues::dispatch(sub, &client).await, Cmd::Pr(sub) => commands::pr::dispatch(sub, &client).await, @@ -1883,6 +2003,7 @@ mod tests { "pack", "patches", "pr", + "projects", "reactions", "repos", "social", @@ -2038,6 +2159,18 @@ mod tests { names(&cmd, "patches"), vec!["get", "list", "send", "status"] ); + assert_eq!( + names(&cmd, "projects"), + vec![ + "add-repo", + "create", + "delete", + "get", + "list", + "remove-repo", + "update" + ] + ); assert_eq!( names(&cmd, "issues"), vec!["create", "get", "list", "status"] @@ -2075,6 +2208,7 @@ mod tests { ("pack", 2), ("patches", 4), ("pr", 5), + ("projects", 7), ("reactions", 3), ("repos", 5), ("social", 7), @@ -2142,4 +2276,111 @@ mod tests { .join("\n") ); } + + // ── projects update mutation group ──────────────────────────────────────── + + /// Multiple independent fields must be accepted in the same invocation. + #[test] + fn projects_update_multi_field_is_accepted() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "update", + "my-slug", + "--name", + "X", + "--description", + "Y", + ]) + .is_ok(), + "--name and --description together must be accepted" + ); + } + + /// A setter for one field and a clearer for a different field must be accepted. + #[test] + fn projects_update_setter_with_other_clearer_is_accepted() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "update", + "my-slug", + "--name", + "X", + "--clear-description", + ]) + .is_ok(), + "--name with --clear-description must be accepted" + ); + } + + /// A setter and its own clearer are mutually exclusive — clap must reject this. + #[test] + fn projects_update_setter_with_own_clearer_is_rejected() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "update", + "my-slug", + "--name", + "X", + "--clear-name", + ]) + .is_err(), + "--name and --clear-name together must be rejected by clap" + ); + } + + /// Providing no mutation options at all must be rejected by clap (required group). + #[test] + fn projects_update_no_mutation_is_rejected_by_clap() { + // Without credentials, a valid parse would reach authentication and fail + // with auth_error — but a clap-level rejection happens before any I/O. + // We verify it's a clap error (not just any error) by checking the error + // kind is not a runtime/auth failure — Cli::try_parse_from returns Err + // immediately for argument violations. + assert!( + Cli::try_parse_from(["buzz", "projects", "update", "my-slug"]).is_err(), + "update with no setters or clearers must be rejected at parse time" + ); + } + + /// An unrecognised visibility token must be rejected by clap before any I/O. + #[test] + fn projects_create_invalid_visibility_is_rejected_by_clap() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "create", + "my-slug", + "--repo", + "buzz", + "--visibility", + "chartreuse", + ]) + .is_err(), + "--visibility chartreuse must be rejected at parse time" + ); + } + + /// An unrecognised visibility token on update must be rejected by clap before any I/O. + #[test] + fn projects_update_invalid_visibility_is_rejected_by_clap() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "update", + "my-slug", + "--visibility", + "chartreuse", + ]) + .is_err(), + "--visibility chartreuse on update must be rejected at parse time" + ); + } } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 8cc9c8650a..9a139f0377 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -11,8 +11,8 @@ use buzz_core::{ KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, - KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_USER_STATUS, - KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_PROJECT, + KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -1499,12 +1499,7 @@ pub fn build_workflow_delete( author_pubkey: &str, workflow_id: Uuid, ) -> Result { - let pk = check_pubkey_hex(author_pubkey, "author_pubkey")?; - let tags = vec![tag(&[ - "a", - &format!("{}:{pk}:{workflow_id}", KIND_WORKFLOW_DEF), - ])?]; - Ok(EventBuilder::new(Kind::Custom(KIND_DELETION as u16), "").tags(tags)) + build_delete_addressable(KIND_WORKFLOW_DEF, author_pubkey, &workflow_id.to_string()) } /// Build a workflow trigger event (kind 46020). @@ -1838,6 +1833,364 @@ pub fn build_unarchive_identity_request( ) } +// ─── NIP-MP: Multi-repo projects (kind:30621) ──────────────────────────────── +// +// Public surface: +// • `validate_project_envelope` — Layer A protocol validator (8 ingest rules) +// • `build_project_with_tags` — Layer A raw builder (content + tags, no canonicalization) +// • `ProjectMemberCoord` — parsed member coordinate + optional relay hint +// • `build_project` — Layer B writer-policy builder +// • `build_delete_addressable` — generic NIP-09 kind:5 coordinate delete +// +// Byte-length bounds from NIP-MP §Relay Processing: +/// Maximum byte length of a project `d` tag value. +pub const PROJECT_D_MAX_LEN: usize = 1024; +/// Maximum byte length of a project `name` tag value. +pub const PROJECT_NAME_MAX: usize = 256; +/// Maximum byte length of a project `description` tag value. +pub const PROJECT_DESCRIPTION_MAX: usize = 2048; +/// Maximum byte length of a project `buzz-channel` tag value. +pub const PROJECT_CHANNEL_MAX: usize = 256; +/// Maximum byte length of a project `buzz-visibility` tag value. +pub const PROJECT_VISIBILITY_MAX: usize = 256; +/// Maximum number of `a` member tags per project event (checked before dedup). +pub const PROJECT_MEMBER_CAP: usize = 64; + +/// A validated NIP-MP member `a`-tag coordinate with an optional relay hint. +/// +/// Equality and `Hash` are by `coord` only (per spec: duplicate detection ignores hint). +#[derive(Clone, Debug)] +pub struct ProjectMemberCoord { + /// The full `30617::` coordinate string. + pub coord: String, + /// Optional opaque relay hint (third `a`-tag element, never validated by content). + pub hint: Option, +} + +impl PartialEq for ProjectMemberCoord { + fn eq(&self, other: &Self) -> bool { + self.coord == other.coord + } +} + +impl Eq for ProjectMemberCoord {} + +impl std::hash::Hash for ProjectMemberCoord { + fn hash(&self, state: &mut H) { + self.coord.hash(state); + } +} + +impl ProjectMemberCoord { + /// Parse a full `30617::` coordinate string. + /// + /// Accepts an optional relay hint as the third colon-separated element + /// after the split, but the split is always first-two-colons: kind, owner, + /// everything-else-as-repo-d. + /// + /// Rules enforced: + /// - Exactly three segments after splitting on the first two colons + /// - First segment must be the literal string `"30617"` + /// - Second segment must be exactly 64 lowercase hex characters + /// - Third segment (repo-d) must be non-empty + /// - Uppercase owners are rejected (never normalized) + pub fn parse_full(coord: &str) -> Result { + // Split on first two colons only: kind:owner:rest + let mut parts = coord.splitn(3, ':'); + let kind_part = parts.next().unwrap_or(""); + let owner_part = parts.next().unwrap_or(""); + let rest = parts.next().unwrap_or(""); + + if kind_part != "30617" { + return Err(SdkError::InvalidInput(format!( + "member coordinate must start with '30617:' (got kind {kind_part:?})" + ))); + } + if owner_part.len() != 64 || !owner_part.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(SdkError::InvalidInput(format!( + "member owner must be a 64-character hex pubkey (got {owner_part:?})" + ))); + } + // Reject uppercase (spec: lowercase hex required) + if owner_part.chars().any(|c| c.is_ascii_uppercase()) { + return Err(SdkError::InvalidInput( + "member owner hex must be lowercase".into(), + )); + } + if rest.is_empty() { + return Err(SdkError::InvalidInput( + "member coordinate repo-d must not be empty".into(), + )); + } + Ok(ProjectMemberCoord { + coord: format!("30617:{owner_part}:{rest}"), + hint: None, + }) + } + + /// Returns the `a`-tag element slice: `[coord]` or `[coord, hint]`. + pub fn to_tag_parts(&self) -> Vec { + let mut parts = vec!["a".to_string(), self.coord.clone()]; + if let Some(h) = &self.hint { + parts.push(h.clone()); + } + parts + } +} + +/// **Layer A**: Validate a complete kind:30621 envelope against the 8 NIP-MP +/// ingest rules. This is the single source of protocol truth used by both +/// `build_project_with_tags` (raw path) and `build_project` (policy path). +/// +/// Rules enforced (matches relay `buzz-db` ingest logic): +/// 1. `d` cardinality: exactly one `d` tag. +/// 2. `d` value: non-empty, ≤1024 bytes. +/// 3. Member cap: raw count of every `a` tag ≤ 64 (checked **before** per-tag +/// parsing, matching relay rule order). +/// 4. Member tag arity: every `a` tag has 2 or 3 elements (no more, no fewer). +/// 5. Member coordinate grammar: first-two-colons split; kind literal `"30617"`; +/// owner lowercase 64-hex; repo-d non-empty verbatim. +/// 6. Member deduplication: coordinate equality only (hint ignored); any +/// coordinate that appears more than once is a duplicate. +/// 7. Singleton metadata: each of `name`, `description`, `buzz-channel`, +/// `buzz-visibility` appears at most once. +/// 8. Metadata byte lengths: `name` ≤256, `description` ≤2048, +/// `buzz-channel` ≤256, `buzz-visibility` ≤256. +pub fn validate_project_envelope(tags: &[Tag], _content: &str) -> Result<(), SdkError> { + // --- Rule 1 & 2: d tag --- + let d_tags: Vec<&Tag> = tags.iter().filter(|t| tag_name(t) == Some("d")).collect(); + match d_tags.len() { + 0 => { + return Err(SdkError::InvalidInput( + "project must have exactly one 'd' tag (rule: d-cardinality)".into(), + )) + } + 1 => {} + _ => { + return Err(SdkError::InvalidInput( + "project must have exactly one 'd' tag (rule: d-cardinality)".into(), + )) + } + } + let d_val = tag_value(d_tags[0]).unwrap_or(""); + if d_val.is_empty() { + return Err(SdkError::InvalidInput( + "project 'd' tag must not be empty (rule: d-empty)".into(), + )); + } + if d_val.len() > PROJECT_D_MAX_LEN { + return Err(SdkError::InvalidInput(format!( + "project 'd' tag exceeds {PROJECT_D_MAX_LEN} bytes (rule: d-empty)" + ))); + } + + let a_tags: Vec<&Tag> = tags.iter().filter(|t| tag_name(t) == Some("a")).collect(); + + // --- Rule 3: member cap (checked before per-tag parsing, matching relay rule order) --- + if a_tags.len() > PROJECT_MEMBER_CAP { + return Err(SdkError::InvalidInput(format!( + "project exceeds member cap of {PROJECT_MEMBER_CAP} (got {}) (rule: member-cap)", + a_tags.len() + ))); + } + + // --- Rule 4: member arity --- + for a in &a_tags { + let len = a.as_slice().len() - 1; // exclude the "a" name element + if !(1..=2).contains(&len) { + return Err(SdkError::InvalidInput(format!( + "member 'a' tag must have 1 or 2 value elements (got {len}) (rule: member-tag-arity)" + ))); + } + } + + // --- Rules 5 & 6: coordinate grammar + deduplication --- + let mut seen_coords: std::collections::HashSet = std::collections::HashSet::new(); + for a in &a_tags { + let coord_val = tag_value(a).unwrap_or(""); + ProjectMemberCoord::parse_full(coord_val).map_err(|e| { + SdkError::InvalidInput(format!("{e} (rule: member-coordinate-malformed)")) + })?; + if !seen_coords.insert(coord_val.to_string()) { + return Err(SdkError::InvalidInput(format!( + "duplicate member coordinate {coord_val:?} (rule: member-duplicate)" + ))); + } + } + + // --- Rules 7 & 8: singleton metadata + byte bounds --- + let singleton_fields = [ + ( + "name", + PROJECT_NAME_MAX, + "metadata-cardinality", + "metadata-length", + ), + ( + "description", + PROJECT_DESCRIPTION_MAX, + "metadata-cardinality", + "metadata-length", + ), + ( + "buzz-channel", + PROJECT_CHANNEL_MAX, + "metadata-cardinality", + "metadata-length", + ), + ( + "buzz-visibility", + PROJECT_VISIBILITY_MAX, + "metadata-cardinality", + "metadata-length", + ), + ]; + for (field, max_bytes, card_rule, len_rule) in singleton_fields { + let matches: Vec<&Tag> = tags.iter().filter(|t| tag_name(t) == Some(field)).collect(); + if matches.len() > 1 { + return Err(SdkError::InvalidInput(format!( + "project must have at most one '{field}' tag (rule: {card_rule})" + ))); + } + if let Some(t) = matches.first() { + let val = tag_value(t).unwrap_or(""); + if val.len() > max_bytes { + return Err(SdkError::InvalidInput(format!( + "'{field}' tag exceeds {max_bytes} bytes (rule: {len_rule})" + ))); + } + } + } + + Ok(()) +} + +/// Helper: tag name (first element). +fn tag_name(tag: &Tag) -> Option<&str> { + tag.as_slice().first().map(String::as_str) +} + +/// Helper: tag value (second element). +fn tag_value(tag: &Tag) -> Option<&str> { + tag.as_slice().get(1).map(String::as_str) +} + +/// **Layer A raw builder**: Build a kind:30621 project event from a raw +/// `content` string and a raw `tags` slice, without any canonicalization. +/// +/// Validates the entire envelope through `validate_project_envelope` before +/// accepting it. The caller is responsible for supplying the correct `d` tag. +/// This is the path exercised by fixture conformance tests and by read-modify- +/// write mutations in the CLI. +pub fn build_project_with_tags(content: &str, tags: Vec) -> Result { + validate_project_envelope(&tags, content)?; + Ok(EventBuilder::new(Kind::Custom(KIND_PROJECT as u16), content).tags(tags)) +} + +/// **Layer B writer-policy builder**: Build a kind:30621 project event with +/// enforced writer policy: +/// - The `d` tag is constructed from `slug`; `check_project_slug` rejects +/// an empty or over-length slug. +/// - `channel` must be a valid UUID string. +/// - `visibility` must be `"listed"` or `"unlisted"`. +/// - Content is always empty. +/// - Member coordinates are parsed through `ProjectMemberCoord::parse_full`. +/// +/// The resulting envelope is validated through Layer A before the builder is +/// returned. +pub fn build_project( + slug: &str, + name: Option<&str>, + description: Option<&str>, + members: &[ProjectMemberCoord], + channel: Option<&str>, + visibility: Option<&str>, +) -> Result { + // Slug validation + if slug.is_empty() { + return Err(SdkError::InvalidInput( + "project slug must not be empty".into(), + )); + } + if slug.len() > PROJECT_D_MAX_LEN { + return Err(SdkError::InvalidInput(format!( + "project slug must not exceed {PROJECT_D_MAX_LEN} bytes (got {})", + slug.len() + ))); + } + + // Channel UUID validation + if let Some(ch) = channel { + uuid::Uuid::parse_str(ch).map_err(|_| { + SdkError::InvalidInput(format!("buzz-channel must be a valid UUID (got {ch:?})")) + })?; + } + + // Visibility enum validation + if let Some(vis) = visibility { + if vis != "listed" && vis != "unlisted" { + return Err(SdkError::InvalidInput(format!( + "buzz-visibility must be 'listed' or 'unlisted' (got {vis:?})" + ))); + } + } + + let mut tags: Vec = Vec::new(); + tags.push(tag(&["d", slug])?); + + if let Some(n) = name { + tags.push(tag(&["name", n])?); + } + if let Some(d) = description { + tags.push(tag(&["description", d])?); + } + for m in members { + let tag_parts = m.to_tag_parts(); + let parts: Vec<&str> = tag_parts.iter().map(|s| s.as_str()).collect(); + // Safety: to_tag_parts always produces ["a", coord, ...hint] + tags.push( + Tag::parse(parts.iter().copied()).map_err(|e| SdkError::InvalidTag(e.to_string()))?, + ); + } + if let Some(ch) = channel { + tags.push(tag(&["buzz-channel", ch])?); + } + if let Some(vis) = visibility { + tags.push(tag(&["buzz-visibility", vis])?); + } + + build_project_with_tags("", tags) +} + +/// **Generic NIP-09 coordinate delete**: Build a kind:5 deletion event with +/// a single `a`-tag addressing `::`. +/// +/// Validates: +/// - `kind` is an addressable kind (10000–19999 or 30000–39999). +/// - `pubkey` is a 64-character lowercase hex string. +/// - `d` is non-empty. +/// +/// `build_workflow_delete` delegates to this function. +pub fn build_delete_addressable( + kind: u32, + pubkey: &str, + d: &str, +) -> Result { + let is_addressable = (10000..20000).contains(&kind) || (30000..40000).contains(&kind); + if !is_addressable { + return Err(SdkError::InvalidInput(format!( + "kind {kind} is not an addressable kind (must be 10000–19999 or 30000–39999)" + ))); + } + let pk = check_pubkey_hex(pubkey, "pubkey")?; + if d.is_empty() { + return Err(SdkError::InvalidInput("d must not be empty".into())); + } + let coord = format!("{kind}:{pk}:{d}"); + let tags = vec![tag(&["a", &coord])?]; + Ok(EventBuilder::new(Kind::Custom(KIND_DELETION as u16), "").tags(tags)) +} + #[cfg(test)] mod tests { use super::*; @@ -3884,4 +4237,280 @@ mod tests { .iter() .any(|t| t.as_slice().first().map(String::as_str) == Some("replaced-by"))); } + + // ── NIP-MP cap-before-arity ordering ───────────────────────────────────── + + /// When an envelope exceeds the member cap AND contains a malformed `a` tag, + /// the validator must fire `member-cap` (rule 3) — not `member-tag-arity` + /// (rule 4). This matches the relay's ingest ordering and means a client + /// sending an oversized list never receives a per-tag parse error. + #[test] + fn validate_project_envelope_cap_wins_over_arity_when_both_fail() { + let owner = "a".repeat(64); + // Build 65 well-formed `a` tags — enough to trigger the cap. + let mut tags = vec![Tag::parse(["d", "platform"]).unwrap()]; + for i in 0..65usize { + let coord = format!("30617:{owner}:repo-{i}"); + tags.push(Tag::parse(["a", &coord]).unwrap()); + } + // Also add one malformed tag (four elements) that would fire + // member-tag-arity if evaluated before the cap check. + let coord_extra = format!("30617:{owner}:repo-extra"); + tags.push( + Tag::parse([ + "a", + &coord_extra, + "wss://relay.example.com", + "extra-element", + ]) + .unwrap(), + ); + + let err = validate_project_envelope(&tags, "").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("member-cap"), + "expected member-cap to win, got: {msg}" + ); + assert!( + !msg.contains("member-tag-arity"), + "arity rule must not fire before cap rule, got: {msg}" + ); + } + + // ── Layer B writer-policy builder ─────────────────────────────────────── + + const OWNER64: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const VALID_UUID: &str = "3580ca9b-47b4-4af9-b22a-1068778f26c6"; + + fn member_coord(repo: &str) -> ProjectMemberCoord { + ProjectMemberCoord::parse_full(&format!("30617:{OWNER64}:{repo}")).unwrap() + } + + #[test] + fn build_project_emitted_envelope_has_correct_shape() { + // slug, name, description, channel, visibility, and one member. + let m = member_coord("buzz"); + let ev = sign( + build_project( + "my-proj", + Some("My Project"), + Some("A description"), + &[m], + Some(VALID_UUID), + Some("listed"), + ) + .expect("Layer B must accept valid inputs"), + ); + + // Kind must be 30621. + assert_eq!(ev.kind.as_u16(), KIND_PROJECT as u16); + // Content must be empty (Layer B policy). + assert!(ev.content.is_empty(), "content must be empty"); + + let all_tags: Vec> = ev.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + + // d tag must be present exactly once. + let d_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "d").collect(); + assert_eq!(d_tags.len(), 1); + assert_eq!(d_tags[0][1], "my-proj"); + + // name, description, buzz-channel, buzz-visibility present. + let name_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "name").collect(); + assert_eq!(name_tags.len(), 1); + assert_eq!(name_tags[0][1], "My Project"); + + let desc_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "description").collect(); + assert_eq!(desc_tags.len(), 1); + assert_eq!(desc_tags[0][1], "A description"); + + let ch_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "buzz-channel").collect(); + assert_eq!(ch_tags.len(), 1); + assert_eq!(ch_tags[0][1], VALID_UUID); + + let vis_tags: Vec<_> = all_tags + .iter() + .filter(|t| t[0] == "buzz-visibility") + .collect(); + assert_eq!(vis_tags.len(), 1); + assert_eq!(vis_tags[0][1], "listed"); + + // member a tag. + let a_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "a").collect(); + assert_eq!(a_tags.len(), 1); + assert_eq!(a_tags[0][1], format!("30617:{OWNER64}:buzz")); + } + + #[test] + fn build_project_optional_fields_absent_when_not_supplied() { + let m = member_coord("core"); + let ev = sign( + build_project("my-proj", None, None, &[m], None, None) + .expect("minimal build must succeed"), + ); + let names: Vec<_> = ev + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("name")) + .collect(); + assert!(names.is_empty(), "name tag must not be emitted when absent"); + } + + #[test] + fn build_project_rejects_empty_slug() { + let m = member_coord("r"); + let err = build_project("", None, None, &[m], None, None).unwrap_err(); + assert!( + matches!(err, SdkError::InvalidInput(_)), + "empty slug must be InvalidInput, got: {err:?}" + ); + assert!(err.to_string().contains("empty")); + } + + #[test] + fn build_project_rejects_overlong_slug() { + let long_slug = "a".repeat(PROJECT_D_MAX_LEN + 1); + let m = member_coord("r"); + let err = build_project(&long_slug, None, None, &[m], None, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn build_project_rejects_invalid_channel_uuid() { + let m = member_coord("r"); + let err = build_project("slug", None, None, &[m], Some("not-a-uuid"), None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!(err.to_string().contains("UUID") || err.to_string().contains("uuid")); + } + + #[test] + fn build_project_rejects_invalid_visibility_token() { + let m = member_coord("r"); + let err = build_project("slug", None, None, &[m], None, Some("chartreuse")).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!(err.to_string().contains("listed") || err.to_string().contains("unlisted")); + } + + #[test] + fn build_project_rejects_over_cap_members() { + let members: Vec<_> = (0..=PROJECT_MEMBER_CAP) + .map(|i| member_coord(&format!("repo-{i}"))) + .collect(); + let err = build_project("slug", None, None, &members, None, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!( + err.to_string().contains("member-cap"), + "over-cap must report member-cap, got: {err}" + ); + } + + #[test] + fn build_project_rejects_duplicate_members() { + let m = member_coord("same"); + let err = build_project("slug", None, None, &[m.clone(), m], None, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!( + err.to_string().contains("dedup") || err.to_string().contains("duplicate"), + "duplicate member must report dedup, got: {err}" + ); + } + + #[test] + fn build_project_content_is_always_empty() { + // build_project forces content="" regardless; Layer A also enforces + // that the envelope is valid. Any non-empty content would be dropped. + // This test pins the Layer B content-forced-empty policy. + let m = member_coord("r"); + let ev = sign(build_project("slug", None, None, &[m], None, None).unwrap()); + assert!( + ev.content.is_empty(), + "Layer B must always emit empty content" + ); + } + + // ── NIP-MP conformance fixtures ────────────────────────────────────────── + // `build_project_with_tags` directly. Accept cases must build; reject + // cases must fail with an error message containing the expected rule name. + // A count assertion guards against silent omissions. + // + // `include_str!` path is relative to this source file. + fn nip_mp_fixture_tags(json_tags: &serde_json::Value) -> Vec { + json_tags + .as_array() + .unwrap() + .iter() + .map(|t| { + let parts: Vec = t + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + let parts_ref: Vec<&str> = parts.iter().map(String::as_str).collect(); + Tag::parse(parts_ref.iter().copied()) + .unwrap_or_else(|e| panic!("fixture tag parse error: {e}\n raw: {t}")) + }) + .collect() + } + + #[test] + fn nip_mp_fixtures_all_31_cases_exercised() { + const FIXTURE_JSON: &str = include_str!("../../../docs/nips/NIP-MP.fixtures.json"); + + let data: serde_json::Value = + serde_json::from_str(FIXTURE_JSON).expect("fixture JSON must parse"); + let cases = data["cases"].as_array().expect("cases must be array"); + + // Count gate: the spec says "required to test against this one file" + // with the exact count as-shipped. + assert_eq!( + cases.len(), + 31, + "expected 31 fixture cases, got {} — was NIP-MP.fixtures.json edited?", + cases.len() + ); + + let mut accept_count = 0usize; + let mut reject_count = 0usize; + + for case in cases { + let name = case["name"].as_str().unwrap(); + let expect = case["expect"].as_str().unwrap(); + let template = &case["template"]; + let content = template["content"].as_str().unwrap_or(""); + let tags = nip_mp_fixture_tags(&template["tags"]); + + match expect { + "accept" => { + build_project_with_tags(content, tags).unwrap_or_else(|e| { + panic!("fixture '{name}' (accept) must build successfully, got: {e}") + }); + accept_count += 1; + } + "reject" => { + let reject_rules = case["reject_rules"] + .as_array() + .expect("reject case must have reject_rules") + .iter() + .map(|r| r.as_str().unwrap().to_string()) + .collect::>(); + + let err = build_project_with_tags(content, tags).unwrap_err(); + let err_msg = err.to_string(); + + // The error must mention at least one of the expected rules. + let rule_matched = reject_rules.iter().any(|r| err_msg.contains(r.as_str())); + assert!( + rule_matched, + "fixture '{name}' rejected with wrong rule.\n expected one of: {reject_rules:?}\n got error: {err_msg}" + ); + reject_count += 1; + } + other => panic!("fixture '{name}' has unknown expect value: {other:?}"), + } + } + + assert_eq!(accept_count, 11, "expected 11 accept cases"); + assert_eq!(reject_count, 20, "expected 20 reject cases"); + } }