Files
huskies/server/src/service/status/format.rs
T

204 lines
7.3 KiB
Rust
Raw Normal View History

//! 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::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.
#[allow(dead_code)]
///
/// 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.
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 = stage_display_name(from_stage);
let to = stage_display_name(to_stage);
let prefix = if to == "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: "4_merge".to_string(),
to_stage: "5_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: "1_backlog".to_string(),
to_stage: "2_current".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: "2_current".to_string(),
to_stage: "3_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<StatusEvent> = vec![
StatusEvent::StageTransition {
story_id: "1_story_a".to_string(),
story_name: None,
from_stage: "1_backlog".to_string(),
to_stage: "2_current".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());
}
}
}