//! Pure status-event message formatter. //! //! A single [`format_status_event`] function converts any [`StatusEvent`] into //! a human-readable string. Adding a new event type means adding one match arm //! here — no per-transport duplication anywhere in the codebase. use crate::pipeline_state::Stage; use crate::service::common::item_id::extract_item_number; use crate::service::notifications::format::stage_display_name; use crate::service::status::StatusEvent; /// Render a [`StatusEvent`] into a human-readable plain-text string. /// /// This is the single formatter for all status event types. Every transport /// (chat, Web UI, agent context) calls this function rather than duplicating /// formatting logic. // Used by chat/agent transports (stories 642/644); the web UI uses StatusUpdate frames instead. #[allow(dead_code)] pub fn format_status_event(event: &StatusEvent) -> String { match event { StatusEvent::StageTransition { story_id, story_name, from_stage, to_stage, } => { let number = extract_item_number(story_id).unwrap_or(story_id.as_str()); let name = story_name.as_deref().unwrap_or(story_id.as_str()); let from_typed = Stage::from_dir(from_stage).unwrap_or(Stage::Upcoming); let to_typed = Stage::from_dir(to_stage).unwrap_or(Stage::Upcoming); let from = stage_display_name(&from_typed); let to = stage_display_name(&to_typed); let prefix = if matches!(to_typed, Stage::Done { .. }) { "\u{1f389} " } else { "" }; format!("{prefix}#{number} {name} \u{2014} {from} \u{2192} {to}") } StatusEvent::MergeFailure { story_id, story_name, reason, } => { let number = extract_item_number(story_id).unwrap_or(story_id.as_str()); let name = story_name.as_deref().unwrap_or(story_id.as_str()); format!("\u{274c} #{number} {name} \u{2014} {reason}") } StatusEvent::StoryBlocked { story_id, story_name, reason, } => { let number = extract_item_number(story_id).unwrap_or(story_id.as_str()); let name = story_name.as_deref().unwrap_or(story_id.as_str()); format!("\u{1f6ab} #{number} {name} \u{2014} BLOCKED: {reason}") } StatusEvent::RateLimitWarning { story_id, story_name, agent_name, } => { let number = extract_item_number(story_id).unwrap_or(story_id.as_str()); let name = story_name.as_deref().unwrap_or(story_id.as_str()); format!("\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit") } StatusEvent::RateLimitHardBlock { story_id, story_name, agent_name, reset_at, } => { let number = extract_item_number(story_id).unwrap_or(story_id.as_str()); let name = story_name.as_deref().unwrap_or(story_id.as_str()); let reset = reset_at.format("%H:%M UTC").to_string(); format!( "\u{26d4} #{number} {name} \u{2014} {agent_name} hard rate-limited until {reset}" ) } } } #[cfg(test)] mod tests { use super::*; use chrono::TimeZone; #[test] fn formats_stage_transition_to_done_with_emoji() { let event = StatusEvent::StageTransition { story_id: "42_story_foo".to_string(), story_name: Some("Foo Story".to_string()), from_stage: "merge".to_string(), to_stage: "done".to_string(), }; let s = format_status_event(&event); assert!( s.contains("\u{1f389}"), "done transition should include party emoji" ); assert!(s.contains("#42")); assert!(s.contains("Foo Story")); assert!(s.contains("Merge \u{2192} Done")); } #[test] fn formats_stage_transition_no_emoji_for_non_done() { let event = StatusEvent::StageTransition { story_id: "10_story_bar".to_string(), story_name: Some("Bar".to_string()), from_stage: "backlog".to_string(), to_stage: "coding".to_string(), }; let s = format_status_event(&event); assert!(!s.contains("\u{1f389}")); assert!(s.contains("Backlog \u{2192} Current")); } #[test] fn formats_stage_transition_falls_back_to_story_id_when_no_name() { let event = StatusEvent::StageTransition { story_id: "5_story_x".to_string(), story_name: None, from_stage: "coding".to_string(), to_stage: "qa".to_string(), }; let s = format_status_event(&event); assert!(s.contains("5_story_x")); } #[test] fn formats_merge_failure() { let event = StatusEvent::MergeFailure { story_id: "7_story_fail".to_string(), story_name: Some("Failing Story".to_string()), reason: "conflicts detected".to_string(), }; let s = format_status_event(&event); assert!(s.contains("\u{274c}")); assert!(s.contains("#7")); assert!(s.contains("conflicts detected")); } #[test] fn formats_story_blocked() { let event = StatusEvent::StoryBlocked { story_id: "8_story_blk".to_string(), story_name: Some("Blocked Story".to_string()), reason: "retry limit exceeded".to_string(), }; let s = format_status_event(&event); assert!(s.contains("\u{1f6ab}")); assert!(s.contains("BLOCKED: retry limit exceeded")); } #[test] fn formats_rate_limit_warning() { let event = StatusEvent::RateLimitWarning { story_id: "9_story_rl".to_string(), story_name: Some("RL Story".to_string()), agent_name: "coder-1".to_string(), }; let s = format_status_event(&event); assert!(s.contains("coder-1 hit an API rate limit")); } #[test] fn formats_rate_limit_hard_block() { let reset = chrono::Utc .with_ymd_and_hms(2026, 4, 26, 15, 30, 0) .unwrap(); let event = StatusEvent::RateLimitHardBlock { story_id: "3_story_hb".to_string(), story_name: Some("HB Story".to_string()), agent_name: "coder-2".to_string(), reset_at: reset, }; let s = format_status_event(&event); assert!(s.contains("\u{26d4}")); assert!(s.contains("coder-2")); assert!(s.contains("hard rate-limited")); assert!(s.contains("15:30 UTC")); } #[test] fn single_formatter_no_per_transport_duplication() { // Each event type produces output through a single call — no per-transport variants. let events: Vec = vec![ StatusEvent::StageTransition { story_id: "1_story_a".to_string(), story_name: None, from_stage: "backlog".to_string(), to_stage: "coding".to_string(), }, StatusEvent::MergeFailure { story_id: "2_story_b".to_string(), story_name: None, reason: "test".to_string(), }, StatusEvent::StoryBlocked { story_id: "3_story_c".to_string(), story_name: None, reason: "limit".to_string(), }, ]; for e in &events { let s = format_status_event(e); assert!(!s.is_empty()); } } }