//! Pure message-formatting functions for pipeline-event notifications. //! //! All functions are pure (no I/O, no side effects) and accept only owned //! or borrowed string data. They return `(plain_text, html)` pairs suitable //! for `ChatTransport::send_message`. use crate::service::common::item_id::extract_item_number; /// Human-readable display name for a pipeline stage directory. pub fn stage_display_name(stage: &str) -> &'static str { match stage { "1_backlog" => "Backlog", "2_current" => "Current", "3_qa" => "QA", "4_merge" => "Merge", "5_done" => "Done", "6_archived" => "Archived", _ => "Unknown", } } /// Format a stage transition notification message. /// /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_stage_notification( item_id: &str, story_name: Option<&str>, from_stage: &str, to_stage: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let name = story_name.unwrap_or(item_id); let prefix = if to_stage == "Done" { "\u{1f389} " } else { "" }; let plain = format!("{prefix}#{number} {name} \u{2014} {from_stage} \u{2192} {to_stage}"); let html = format!( "{prefix}#{number} {name} \u{2014} {from_stage} \u{2192} {to_stage}" ); (plain, html) } /// Format an error notification message for a story merge failure. /// /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_error_notification( item_id: &str, story_name: Option<&str>, reason: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let name = story_name.unwrap_or(item_id); let plain = format!("\u{274c} #{number} {name} \u{2014} {reason}"); let html = format!("\u{274c} #{number} {name} \u{2014} {reason}"); (plain, html) } /// Format a blocked-story notification message. /// /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_blocked_notification( item_id: &str, story_name: Option<&str>, reason: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let name = story_name.unwrap_or(item_id); let plain = format!("\u{1f6ab} #{number} {name} \u{2014} BLOCKED: {reason}"); let html = format!("\u{1f6ab} #{number} {name} \u{2014} BLOCKED: {reason}"); (plain, html) } /// Format a rate limit warning notification message. /// /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_rate_limit_notification( item_id: &str, story_name: Option<&str>, agent_name: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); let name = story_name.unwrap_or(item_id); let plain = format!("\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit"); let html = format!( "\u{26a0}\u{fe0f} #{number} {name} \u{2014} \ {agent_name} hit an API rate limit" ); (plain, html) } #[cfg(test)] mod tests { use super::*; // ── stage_display_name ──────────────────────────────────────────────────── #[test] fn stage_display_name_maps_all_known_stages() { assert_eq!(stage_display_name("1_backlog"), "Backlog"); assert_eq!(stage_display_name("2_current"), "Current"); assert_eq!(stage_display_name("3_qa"), "QA"); assert_eq!(stage_display_name("4_merge"), "Merge"); assert_eq!(stage_display_name("5_done"), "Done"); assert_eq!(stage_display_name("6_archived"), "Archived"); assert_eq!(stage_display_name("unknown"), "Unknown"); } #[test] fn stage_display_name_unknown_slug_returns_unknown() { assert_eq!(stage_display_name("99_future"), "Unknown"); assert_eq!(stage_display_name(""), "Unknown"); } // ── format_stage_notification ───────────────────────────────────────────── #[test] fn format_notification_done_stage_includes_party_emoji() { let (plain, html) = format_stage_notification("353_story_done", Some("Done Story"), "Merge", "Done"); assert_eq!( plain, "\u{1f389} #353 Done Story \u{2014} Merge \u{2192} Done" ); assert_eq!( html, "\u{1f389} #353 Done Story \u{2014} Merge \u{2192} Done" ); } #[test] fn format_notification_non_done_stage_has_no_emoji() { let (plain, _html) = format_stage_notification("42_story_thing", Some("Some Story"), "Backlog", "Current"); assert!(!plain.contains("\u{1f389}")); } #[test] fn format_notification_with_story_name() { let (plain, html) = format_stage_notification( "261_story_bot_notifications", Some("Bot notifications"), "Upcoming", "Current", ); assert_eq!( plain, "#261 Bot notifications \u{2014} Upcoming \u{2192} Current" ); assert_eq!( html, "#261 Bot notifications \u{2014} Upcoming \u{2192} Current" ); } #[test] fn format_notification_without_story_name_falls_back_to_item_id() { let (plain, _html) = format_stage_notification("42_bug_fix_thing", None, "Current", "QA"); assert_eq!(plain, "#42 42_bug_fix_thing \u{2014} Current \u{2192} QA"); } #[test] fn format_notification_non_numeric_id_uses_full_id() { let (plain, _html) = format_stage_notification("abc_story_thing", Some("Some Story"), "QA", "Merge"); assert_eq!( plain, "#abc_story_thing Some Story \u{2014} QA \u{2192} Merge" ); } #[test] fn format_stage_notification_long_name_is_preserved() { let long_name = "A".repeat(300); let (plain, _html) = format_stage_notification("1_story_long", Some(&long_name), "Current", "QA"); assert!(plain.contains(&long_name)); } #[test] fn format_stage_notification_empty_story_name_falls_back_to_id() { // Some("") is a valid Some but empty — treat as missing? Currently we use it as-is. let (plain, _html) = format_stage_notification("42_story_empty", Some(""), "Current", "QA"); // The name slot is empty but the structure is still correct. assert!(plain.contains("#42")); assert!(plain.contains("Current \u{2192} QA")); } #[test] fn format_stage_notification_unicode_name() { let (plain, html) = format_stage_notification("7_story_i18n", Some("Ünïcödé Ñämé 🎉"), "QA", "Merge"); assert!(plain.contains("Ünïcödé Ñämé 🎉")); assert!(html.contains("Ünïcödé Ñämé 🎉")); } // ── format_error_notification ───────────────────────────────────────────── #[test] fn format_error_notification_with_story_name() { let (plain, html) = format_error_notification( "262_story_bot_errors", Some("Bot error notifications"), "merge conflict in src/main.rs", ); assert_eq!( plain, "\u{274c} #262 Bot error notifications \u{2014} merge conflict in src/main.rs" ); assert_eq!( html, "\u{274c} #262 Bot error notifications \u{2014} merge conflict in src/main.rs" ); } #[test] fn format_error_notification_without_story_name_falls_back_to_item_id() { let (plain, _html) = format_error_notification("42_bug_fix_thing", None, "tests failed"); assert_eq!(plain, "\u{274c} #42 42_bug_fix_thing \u{2014} tests failed"); } #[test] fn format_error_notification_non_numeric_id_uses_full_id() { let (plain, _html) = format_error_notification("abc_story_thing", Some("Some Story"), "clippy errors"); assert_eq!( plain, "\u{274c} #abc_story_thing Some Story \u{2014} clippy errors" ); } #[test] fn format_error_notification_long_reason_preserved() { let long_reason = "x".repeat(500); let (plain, _html) = format_error_notification("1_story_foo", None, &long_reason); assert!(plain.contains(&long_reason)); } #[test] fn format_error_notification_unicode_reason() { let (plain, _html) = format_error_notification("5_story_foo", Some("Foo"), "错误:合并冲突"); assert!(plain.contains("错误:合并冲突")); } // ── format_blocked_notification ─────────────────────────────────────────── #[test] fn format_blocked_notification_with_story_name() { let (plain, html) = format_blocked_notification( "425_story_blocking_reason", Some("Blocking Reason Story"), "Retry limit exceeded (3/3) at coder stage", ); assert_eq!( plain, "\u{1f6ab} #425 Blocking Reason Story \u{2014} BLOCKED: Retry limit exceeded (3/3) at coder stage" ); assert_eq!( html, "\u{1f6ab} #425 Blocking Reason Story \u{2014} BLOCKED: Retry limit exceeded (3/3) at coder stage" ); } #[test] fn format_blocked_notification_falls_back_to_item_id() { let (plain, _html) = format_blocked_notification("42_story_thing", None, "empty diff"); assert_eq!( plain, "\u{1f6ab} #42 42_story_thing \u{2014} BLOCKED: empty diff" ); } #[test] fn format_blocked_notification_unicode_reason() { let (plain, _html) = format_blocked_notification("3_story_x", Some("X"), "理由:空の差分"); assert!(plain.contains("BLOCKED: 理由:空の差分")); } // ── format_rate_limit_notification ──────────────────────────────────────── #[test] fn format_rate_limit_notification_includes_agent_and_story() { let (plain, html) = format_rate_limit_notification("365_story_my_feature", Some("My Feature"), "coder-2"); assert_eq!( plain, "\u{26a0}\u{fe0f} #365 My Feature \u{2014} coder-2 hit an API rate limit" ); assert_eq!( html, "\u{26a0}\u{fe0f} #365 My Feature \u{2014} coder-2 hit an API rate limit" ); } #[test] fn format_rate_limit_notification_falls_back_to_item_id() { let (plain, _html) = format_rate_limit_notification("42_story_thing", None, "coder-1"); assert_eq!( plain, "\u{26a0}\u{fe0f} #42 42_story_thing \u{2014} coder-1 hit an API rate limit" ); } #[test] fn format_rate_limit_notification_unicode_agent_name() { let (plain, _html) = format_rate_limit_notification("9_story_foo", Some("Foo"), "агент-1"); assert!(plain.contains("агент-1")); assert!(plain.contains("hit an API rate limit")); } }