use crate::output::{DIM, GRAY, GREEN, RED, RESET, YELLOW};
use indicatif::{ProgressBar, ProgressStyle};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use terminal_size::{terminal_size, Width};
const SPINNER_CHARS: &str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
const DEFAULT_TERMINAL_WIDTH: u16 = 80;
pub const ACTIVITY_TEXT_WIDTH: usize = 40;
#[derive(Debug, Clone, Default)]
pub struct IterationInfo {
pub current: Option<u32>,
pub total: Option<u32>,
pub phase: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ProgressContext {
pub story_index: Option<u32>,
pub total_stories: Option<u32>,
pub story_id: Option<String>,
pub current_phase: Option<String>,
}
impl ProgressContext {
pub fn new(story_id: &str, story_index: u32, total_stories: u32) -> Self {
Self {
story_index: Some(story_index),
total_stories: Some(total_stories),
story_id: Some(story_id.to_string()),
current_phase: None,
}
}
pub fn with_phase(story_id: &str, story_index: u32, total_stories: u32, phase: &str) -> Self {
Self {
story_index: Some(story_index),
total_stories: Some(total_stories),
story_id: Some(story_id.to_string()),
current_phase: Some(phase.to_string()),
}
}
pub fn set_phase(&mut self, phase: &str) {
self.current_phase = Some(phase.to_string());
}
pub fn format_story_progress(&self) -> Option<String> {
match (&self.story_id, self.story_index, self.total_stories) {
(Some(id), Some(idx), Some(total)) => Some(format!("[{} {}/{}]", id, idx, total)),
_ => None,
}
}
pub fn format_dual_context(&self, iteration_info: &Option<IterationInfo>) -> Option<String> {
let story_part = self.format_story_progress();
let iter_part = iteration_info.as_ref().and_then(|info| info.format());
match (story_part, iter_part) {
(Some(story), Some(iter)) => {
let story_inner = story.trim_start_matches('[').trim_end_matches(']');
let iter_inner = iter.trim_start_matches('[').trim_end_matches(']');
Some(format!("[{} | {}]", story_inner, iter_inner))
}
(Some(story), None) => Some(story),
(None, Some(iter)) => Some(iter),
(None, None) => None,
}
}
}
impl IterationInfo {
pub fn new(current: u32, total: u32) -> Self {
Self {
current: Some(current),
total: Some(total),
phase: None,
}
}
pub fn with_phase(phase: &str, current: u32, total: u32) -> Self {
Self {
current: Some(current),
total: Some(total),
phase: Some(phase.to_string()),
}
}
pub fn phase_only(phase: &str) -> Self {
Self {
current: None,
total: None,
phase: Some(phase.to_string()),
}
}
pub fn format(&self) -> Option<String> {
match (&self.phase, self.current, self.total) {
(Some(phase), Some(curr), Some(tot)) => Some(format!("[{} {}/{}]", phase, curr, tot)),
(Some(phase), None, None) => Some(format!("[{}]", phase)),
(None, Some(curr), Some(tot)) => Some(format!("[{}/{}]", curr, tot)),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct Outcome {
pub success: bool,
pub message: String,
pub tokens: Option<u64>,
}
impl Outcome {
pub fn success(message: impl Into<String>) -> Self {
Self {
success: true,
message: message.into(),
tokens: None,
}
}
pub fn failure(message: impl Into<String>) -> Self {
Self {
success: false,
message: message.into(),
tokens: None,
}
}
pub fn with_tokens(mut self, tokens: u64) -> Self {
self.tokens = Some(tokens);
self
}
pub fn with_optional_tokens(mut self, tokens: Option<u64>) -> Self {
self.tokens = tokens;
self
}
}
pub trait AgentDisplay {
fn start(&mut self);
fn update(&mut self, activity: &str);
fn finish_success(&mut self);
fn finish_error(&mut self, error: &str);
fn finish_with_outcome(&mut self, outcome: Outcome);
fn agent_name(&self) -> &str;
fn elapsed_secs(&self) -> u64;
fn iteration_info(&self) -> Option<&IterationInfo>;
fn set_iteration_info(&mut self, info: IterationInfo);
}
pub trait AgentDisplayExt: AgentDisplay {
fn complete_success(&mut self) {
AgentDisplay::finish_success(self);
}
fn complete_error(&mut self, error: &str) {
AgentDisplay::finish_error(self, error);
}
fn complete_with_outcome(&mut self, outcome: Outcome) {
AgentDisplay::finish_with_outcome(self, outcome);
}
}
impl<T: AgentDisplay> AgentDisplayExt for T {}
pub struct VerboseTimer {
story_id: String,
stop_flag: Arc<AtomicBool>,
timer_thread: Option<JoinHandle<()>>,
start_time: Instant,
iteration_info: Option<IterationInfo>,
started: bool,
}
impl VerboseTimer {
pub fn new(story_id: &str) -> Self {
let stop_flag = Arc::new(AtomicBool::new(false));
let start_time = Instant::now();
let stop_flag_clone = Arc::clone(&stop_flag);
let story_id_owned = story_id.to_string();
let timer_thread = thread::spawn(move || {
let mut last_print = Instant::now();
while !stop_flag_clone.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(500));
if stop_flag_clone.load(Ordering::Relaxed) {
break;
}
if last_print.elapsed().as_secs() >= 10 {
let elapsed = start_time.elapsed();
let hours = elapsed.as_secs() / 3600;
let mins = (elapsed.as_secs() % 3600) / 60;
let secs = elapsed.as_secs() % 60;
eprintln!(
"{DIM}[{} elapsed: {:02}:{:02}:{:02}]{RESET}",
story_id_owned, hours, mins, secs
);
last_print = Instant::now();
}
}
});
Self {
story_id: story_id.to_string(),
stop_flag,
timer_thread: Some(timer_thread),
start_time,
iteration_info: None,
started: true,
}
}
pub fn new_with_story_progress(story_id: &str, current: u32, total: u32) -> Self {
let mut timer = Self::new(story_id);
timer.iteration_info = Some(IterationInfo::with_phase(story_id, current, total));
timer
}
pub fn new_for_review(current: u32, total: u32) -> Self {
let mut timer = Self::new("Review");
timer.iteration_info = Some(IterationInfo::with_phase("Review", current, total));
timer
}
pub fn new_for_correct(current: u32, total: u32) -> Self {
let mut timer = Self::new("Correct");
timer.iteration_info = Some(IterationInfo::with_phase("Correct", current, total));
timer
}
pub fn new_for_spec() -> Self {
Self::new("Spec generation")
}
pub fn new_for_commit() -> Self {
let mut timer = Self::new("Commit");
timer.iteration_info = Some(IterationInfo::phase_only("Commit"));
timer
}
fn stop_timer(&mut self) {
self.stop_flag.store(true, Ordering::Relaxed);
if let Some(handle) = self.timer_thread.take() {
let _ = handle.join();
}
}
pub fn finish_success(&mut self) {
self.stop_timer();
let elapsed = self.start_time.elapsed();
let duration = format_duration(elapsed.as_secs());
let prefix = format_display_prefix(&self.story_id, &self.iteration_info);
eprintln!("{GREEN}{} completed in {}{RESET}", prefix, duration);
}
pub fn finish_error(&mut self, error: &str) {
self.stop_timer();
let prefix = format_display_prefix(&self.story_id, &self.iteration_info);
eprintln!("{RED}{} failed: {}{RESET}", prefix, error);
}
pub fn elapsed_secs(&self) -> u64 {
self.start_time.elapsed().as_secs()
}
}
impl AgentDisplay for VerboseTimer {
fn start(&mut self) {
self.started = true;
}
fn update(&mut self, _activity: &str) {
}
fn finish_success(&mut self) {
VerboseTimer::finish_success(self);
}
fn finish_error(&mut self, error: &str) {
VerboseTimer::finish_error(self, error);
}
fn finish_with_outcome(&mut self, outcome: Outcome) {
self.stop_timer();
let elapsed = self.start_time.elapsed();
let duration = format_duration(elapsed.as_secs());
let prefix = format_display_prefix(&self.story_id, &self.iteration_info);
let token_suffix = outcome
.tokens
.map(|t| format!(" - {} tokens", format_tokens(t)))
.unwrap_or_default();
if outcome.success {
eprintln!(
"{GREEN}\u{2714} {} completed in {} - {}{}{RESET}",
prefix, duration, outcome.message, token_suffix
);
} else {
eprintln!(
"{RED}\u{2718} {} failed in {} - {}{}{RESET}",
prefix, duration, outcome.message, token_suffix
);
}
}
fn agent_name(&self) -> &str {
&self.story_id
}
fn elapsed_secs(&self) -> u64 {
VerboseTimer::elapsed_secs(self)
}
fn iteration_info(&self) -> Option<&IterationInfo> {
self.iteration_info.as_ref()
}
fn set_iteration_info(&mut self, info: IterationInfo) {
self.iteration_info = Some(info);
}
}
impl Drop for VerboseTimer {
fn drop(&mut self) {
self.stop_flag.store(true, Ordering::Relaxed);
if let Some(handle) = self.timer_thread.take() {
let _ = handle.join();
}
}
}
pub fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{}s", secs)
} else {
let mins = secs / 60;
let remaining_secs = secs % 60;
format!("{}m {}s", mins, remaining_secs)
}
}
pub fn format_tokens(tokens: u64) -> String {
let s = tokens.to_string();
let mut result = String::with_capacity(s.len() + s.len() / 3);
let chars: Vec<char> = s.chars().collect();
for (i, c) in chars.iter().enumerate() {
if i > 0 && (chars.len() - i).is_multiple_of(3) {
result.push(',');
}
result.push(*c);
}
result
}
fn format_display_prefix(story_id: &str, iteration_info: &Option<IterationInfo>) -> String {
if let Some(info) = iteration_info {
if let Some(formatted) = info.format() {
return formatted;
}
}
if story_id == "Spec" {
"Spec generation".to_string()
} else {
story_id.to_string()
}
}
fn get_terminal_width() -> usize {
terminal_size()
.map(|(Width(w), _)| w as usize)
.unwrap_or(DEFAULT_TERMINAL_WIDTH as usize)
}
pub struct ClaudeSpinner {
spinner: Arc<ProgressBar>,
story_id: String,
stop_flag: Arc<AtomicBool>,
timer_thread: Option<JoinHandle<()>>,
start_time: Instant,
last_activity: Arc<std::sync::Mutex<String>>,
iteration_info: Option<IterationInfo>,
iteration_info_shared: Arc<std::sync::Mutex<Option<IterationInfo>>>,
}
impl ClaudeSpinner {
pub fn new(story_id: &str) -> Self {
Self::create(story_id, format!("{} | Starting...", story_id))
}
pub fn new_with_story_progress(story_id: &str, current: u32, total: u32) -> Self {
let info = IterationInfo::with_phase(story_id, current, total);
let prefix = info.format().unwrap_or_else(|| story_id.to_string());
Self::create_with_iteration(story_id, format!("{} | Starting...", prefix), Some(info))
}
pub fn new_for_review(current: u32, total: u32) -> Self {
let info = IterationInfo::with_phase("Review", current, total);
let prefix = info.format().unwrap_or_else(|| "Review".to_string());
Self::create_with_iteration("Review", format!("{} | Starting...", prefix), Some(info))
}
pub fn new_for_correct(current: u32, total: u32) -> Self {
let info = IterationInfo::with_phase("Correct", current, total);
let prefix = info.format().unwrap_or_else(|| "Correct".to_string());
Self::create_with_iteration("Correct", format!("{} | Starting...", prefix), Some(info))
}
pub fn new_for_spec() -> Self {
Self::create("Spec", "Spec generation | Starting...".to_string())
}
pub fn new_for_commit() -> Self {
let info = IterationInfo::phase_only("Commit");
let prefix = info.format().unwrap_or_else(|| "Commit".to_string());
Self::create_with_iteration("Commit", format!("{} | Starting...", prefix), Some(info))
}
fn create(story_id: &str, initial_message: String) -> Self {
Self::create_with_iteration(story_id, initial_message, None)
}
fn create_with_iteration(
story_id: &str,
initial_message: String,
iteration_info: Option<IterationInfo>,
) -> Self {
let spinner = Arc::new(ProgressBar::new_spinner());
spinner.set_style(
ProgressStyle::default_spinner()
.tick_chars(SPINNER_CHARS)
.template("{spinner:.cyan} Claude working on {msg}")
.expect("invalid template"),
);
spinner.set_message(format!("{} [00:00:00]", initial_message));
spinner.enable_steady_tick(Duration::from_millis(80));
let stop_flag = Arc::new(AtomicBool::new(false));
let start_time = Instant::now();
let last_activity = Arc::new(std::sync::Mutex::new("Starting...".to_string()));
let iteration_info_shared = Arc::new(std::sync::Mutex::new(iteration_info.clone()));
let spinner_clone = Arc::clone(&spinner);
let stop_flag_clone = Arc::clone(&stop_flag);
let last_activity_clone = Arc::clone(&last_activity);
let iteration_info_clone = Arc::clone(&iteration_info_shared);
let story_id_owned = story_id.to_string();
let timer_thread = thread::spawn(move || {
while !stop_flag_clone.load(Ordering::Relaxed) {
thread::sleep(Duration::from_secs(1));
if stop_flag_clone.load(Ordering::Relaxed) {
break;
}
let elapsed = start_time.elapsed();
let hours = elapsed.as_secs() / 3600;
let mins = (elapsed.as_secs() % 3600) / 60;
let secs = elapsed.as_secs() % 60;
let time_str = format!("{:02}:{:02}:{:02}", hours, mins, secs);
let activity = last_activity_clone.lock().unwrap().clone();
let iter_info = iteration_info_clone.lock().unwrap().clone();
let prefix = format_display_prefix(&story_id_owned, &iter_info);
let fixed_activity = fixed_width_activity(&activity);
spinner_clone
.set_message(format!("{} | {} [{}]", prefix, fixed_activity, time_str));
}
});
Self {
spinner,
story_id: story_id.to_string(),
stop_flag,
timer_thread: Some(timer_thread),
start_time,
last_activity,
iteration_info,
iteration_info_shared,
}
}
pub fn update(&self, activity: &str) {
if let Ok(mut guard) = self.last_activity.lock() {
*guard = activity.to_string();
}
let elapsed = self.start_time.elapsed();
let hours = elapsed.as_secs() / 3600;
let mins = (elapsed.as_secs() % 3600) / 60;
let secs = elapsed.as_secs() % 60;
let time_str = format!("{:02}:{:02}:{:02}", hours, mins, secs);
let iter_info = self.iteration_info_shared.lock().unwrap().clone();
let prefix = format_display_prefix(&self.story_id, &iter_info);
let fixed_activity = fixed_width_activity(activity);
self.spinner
.set_message(format!("{} | {} [{}]", prefix, fixed_activity, time_str));
}
fn stop_timer(&mut self) {
self.stop_flag.store(true, Ordering::Relaxed);
if let Some(handle) = self.timer_thread.take() {
let _ = handle.join();
}
}
pub fn clear(&self) {
self.spinner.finish_and_clear();
}
pub fn finish_success(&mut self, duration_secs: u64) {
self.stop_timer();
let duration = format_duration(duration_secs);
let prefix = format_display_prefix(&self.story_id, &self.iteration_info);
self.spinner.finish_and_clear();
println!(
"{GREEN}\u{2714} {} completed in {}{RESET}",
prefix, duration
);
}
pub fn finish_error(&mut self, error: &str) {
self.stop_timer();
let prefix = format_display_prefix(&self.story_id, &self.iteration_info);
let available = get_terminal_width().saturating_sub(prefix.chars().count() + 15);
let truncated = truncate_activity(error, available.max(20));
self.spinner.finish_and_clear();
println!("{RED}\u{2718} {} failed: {}{RESET}", prefix, truncated);
}
pub fn finish_with_message(&mut self, message: &str) {
self.stop_timer();
let prefix = format_display_prefix(&self.story_id, &self.iteration_info);
self.spinner.finish_and_clear();
println!("{GREEN}\u{2714} {}: {}{RESET}", prefix, message);
}
pub fn elapsed_secs(&self) -> u64 {
self.start_time.elapsed().as_secs()
}
}
impl AgentDisplay for ClaudeSpinner {
fn start(&mut self) {
}
fn update(&mut self, activity: &str) {
ClaudeSpinner::update(self, activity);
}
fn finish_success(&mut self) {
let elapsed = self.start_time.elapsed().as_secs();
ClaudeSpinner::finish_success(self, elapsed);
}
fn finish_error(&mut self, error: &str) {
ClaudeSpinner::finish_error(self, error);
}
fn finish_with_outcome(&mut self, outcome: Outcome) {
self.stop_timer();
let elapsed = self.start_time.elapsed();
let duration = format_duration(elapsed.as_secs());
let prefix = format_display_prefix(&self.story_id, &self.iteration_info);
self.spinner.finish_and_clear();
let token_suffix = outcome
.tokens
.map(|t| format!(" - {} tokens", format_tokens(t)))
.unwrap_or_default();
if outcome.success {
println!(
"{GREEN}\u{2714} {} completed in {} - {}{}{RESET}",
prefix, duration, outcome.message, token_suffix
);
} else {
println!(
"{RED}\u{2718} {} failed in {} - {}{}{RESET}",
prefix, duration, outcome.message, token_suffix
);
}
}
fn agent_name(&self) -> &str {
&self.story_id
}
fn elapsed_secs(&self) -> u64 {
ClaudeSpinner::elapsed_secs(self)
}
fn iteration_info(&self) -> Option<&IterationInfo> {
self.iteration_info.as_ref()
}
fn set_iteration_info(&mut self, info: IterationInfo) {
self.iteration_info = Some(info.clone());
if let Ok(mut guard) = self.iteration_info_shared.lock() {
*guard = Some(info);
}
}
}
impl Drop for ClaudeSpinner {
fn drop(&mut self) {
self.stop_flag.store(true, Ordering::Relaxed);
if let Some(handle) = self.timer_thread.take() {
let _ = handle.join();
}
self.spinner.finish_and_clear();
}
}
fn truncate_activity(activity: &str, max_len: usize) -> String {
let first_line = activity.lines().next().unwrap_or(activity);
let cleaned = first_line.trim();
let char_count = cleaned.chars().count();
if char_count <= max_len {
cleaned.to_string()
} else {
if max_len < 4 {
"...".to_string()
} else {
let truncated: String = cleaned.chars().take(max_len - 3).collect();
format!("{}...", truncated)
}
}
}
fn fixed_width_activity(activity: &str) -> String {
let first_line = activity.lines().next().unwrap_or(activity);
let cleaned = first_line.trim();
let char_count = cleaned.chars().count();
if char_count > ACTIVITY_TEXT_WIDTH {
let truncated: String = cleaned.chars().take(ACTIVITY_TEXT_WIDTH - 3).collect();
format!("{}...", truncated)
} else {
format!("{:width$}", cleaned, width = ACTIVITY_TEXT_WIDTH)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum BreadcrumbState {
Story,
Review,
Correct,
Commit,
}
impl BreadcrumbState {
pub fn display_name(&self) -> &'static str {
match self {
BreadcrumbState::Story => "Story",
BreadcrumbState::Review => "Review",
BreadcrumbState::Correct => "Correct",
BreadcrumbState::Commit => "Commit",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Breadcrumb {
completed: Vec<BreadcrumbState>,
current: Option<BreadcrumbState>,
}
impl Breadcrumb {
pub fn new() -> Self {
Self {
completed: Vec::new(),
current: None,
}
}
pub fn reset(&mut self) {
self.completed.clear();
self.current = None;
}
pub fn enter_state(&mut self, state: BreadcrumbState) {
if let Some(current) = self.current.take() {
self.completed.push(current);
}
self.current = Some(state);
}
pub fn complete_current(&mut self) {
if let Some(current) = self.current.take() {
self.completed.push(current);
}
}
pub fn completed_states(&self) -> &[BreadcrumbState] {
&self.completed
}
pub fn current_state(&self) -> Option<&BreadcrumbState> {
self.current.as_ref()
}
pub fn is_empty(&self) -> bool {
self.completed.is_empty() && self.current.is_none()
}
pub fn render(&self, max_width: Option<usize>) -> String {
if self.is_empty() {
return String::new();
}
let max_width = max_width.unwrap_or_else(get_terminal_width);
let separator = format!("{GRAY} → {RESET}");
let prefix = format!("{DIM}Journey:{RESET} ");
let mut parts: Vec<String> = Vec::new();
for state in &self.completed {
parts.push(format!("{GREEN}{}{RESET}", state.display_name()));
}
if let Some(current) = &self.current {
parts.push(format!("{YELLOW}{}{RESET}", current.display_name()));
}
let plain_prefix = "Journey: ";
let plain_separator = " → ";
let plain_parts: Vec<&str> = self
.completed
.iter()
.map(|s| s.display_name())
.chain(self.current.iter().map(|s| s.display_name()))
.collect();
let plain_trail = plain_parts.join(plain_separator);
let plain_full = format!("{}{}", plain_prefix, plain_trail);
let plain_len = plain_full.chars().count();
if plain_len <= max_width {
return format!("{}{}", prefix, parts.join(&separator));
}
let ellipsis = "...";
let available = max_width.saturating_sub(plain_prefix.len() + ellipsis.len() + 4);
let mut fit_parts: Vec<String> = Vec::new();
let mut fit_plain_parts: Vec<&str> = Vec::new();
let mut current_len: usize = 0;
if let Some(current) = &self.current {
fit_parts.push(format!("{YELLOW}{}{RESET}", current.display_name()));
fit_plain_parts.push(current.display_name());
current_len = current.display_name().chars().count();
}
for state in self.completed.iter().rev() {
let state_len = state.display_name().chars().count();
let sep_len = if fit_parts.is_empty() {
0
} else {
plain_separator.len()
};
if current_len + sep_len + state_len <= available {
fit_parts.insert(0, format!("{GREEN}{}{RESET}", state.display_name()));
fit_plain_parts.insert(0, state.display_name());
current_len += sep_len + state_len;
} else {
break;
}
}
if fit_plain_parts.len() < plain_parts.len() {
format!(
"{}{DIM}...{RESET}{}{}",
prefix,
separator,
fit_parts.join(&separator)
)
} else {
format!("{}{}", prefix, fit_parts.join(&separator))
}
}
pub fn print(&self) {
if !self.is_empty() {
println!("{}", self.render(None));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_truncate_activity() {
assert_eq!(truncate_activity("Short message", 50), "Short message");
let long_msg = "This is a very long message that should be truncated";
let result = truncate_activity(long_msg, 30);
assert_eq!(result.chars().count(), 30);
assert!(result.ends_with("..."));
assert_eq!(
truncate_activity("First line\nSecond line", 50),
"First line"
);
let utf8_msg = "Implementing 日本語 feature with more text here";
let result = truncate_activity(utf8_msg, 20);
assert_eq!(result.chars().count(), 20);
}
#[test]
fn test_fixed_width_activity() {
let result = fixed_width_activity("Working");
assert_eq!(result.chars().count(), ACTIVITY_TEXT_WIDTH);
assert!(result.starts_with("Working"));
let long_msg = "This is a very long message that exceeds forty characters limit";
let result = fixed_width_activity(long_msg);
assert_eq!(result.chars().count(), ACTIVITY_TEXT_WIDTH);
assert!(result.ends_with("..."));
let result = fixed_width_activity("");
assert_eq!(result.chars().count(), ACTIVITY_TEXT_WIDTH);
assert!(result.chars().all(|c| c == ' '));
}
#[test]
fn test_format_duration() {
assert_eq!(format_duration(0), "0s");
assert_eq!(format_duration(59), "59s");
assert_eq!(format_duration(60), "1m 0s");
assert_eq!(format_duration(125), "2m 5s");
}
#[test]
fn test_iteration_info_format() {
let info = IterationInfo::with_phase("Review", 1, 3);
assert_eq!(info.format(), Some("[Review 1/3]".to_string()));
let info = IterationInfo::phase_only("Commit");
assert_eq!(info.format(), Some("[Commit]".to_string()));
let info = IterationInfo::new(2, 5);
assert_eq!(info.format(), Some("[2/5]".to_string()));
assert_eq!(IterationInfo::default().format(), None);
}
#[test]
fn test_format_display_prefix() {
let info = IterationInfo::with_phase("Review", 1, 3);
assert_eq!(format_display_prefix("Review", &Some(info)), "[Review 1/3]");
assert_eq!(format_display_prefix("US-001", &None), "US-001");
assert_eq!(format_display_prefix("Spec", &None), "Spec generation");
}
#[test]
fn test_progress_context_dual_context() {
let ctx = ProgressContext::new("US-001", 2, 5);
let iter_info = Some(IterationInfo::with_phase("Review", 1, 3));
assert_eq!(
ctx.format_dual_context(&iter_info),
Some("[US-001 2/5 | Review 1/3]".to_string())
);
assert_eq!(
ctx.format_dual_context(&None),
Some("[US-001 2/5]".to_string())
);
let empty_ctx = ProgressContext::default();
assert_eq!(empty_ctx.format_dual_context(&None), None);
}
#[test]
fn test_breadcrumb_workflow() {
let mut breadcrumb = Breadcrumb::new();
assert!(breadcrumb.is_empty());
breadcrumb.enter_state(BreadcrumbState::Story);
assert_eq!(breadcrumb.current_state(), Some(&BreadcrumbState::Story));
assert!(breadcrumb.completed_states().is_empty());
breadcrumb.enter_state(BreadcrumbState::Review);
assert_eq!(breadcrumb.current_state(), Some(&BreadcrumbState::Review));
assert_eq!(breadcrumb.completed_states(), &[BreadcrumbState::Story]);
breadcrumb.reset();
assert!(breadcrumb.is_empty());
}
#[test]
fn test_breadcrumb_render() {
let mut breadcrumb = Breadcrumb::new();
breadcrumb.enter_state(BreadcrumbState::Story);
breadcrumb.enter_state(BreadcrumbState::Review);
let rendered = breadcrumb.render(Some(100));
assert!(rendered.contains("Journey:"));
assert!(rendered.contains("Story"));
assert!(rendered.contains("Review"));
assert!(rendered.contains("→"));
}
#[test]
fn test_spinner_lifecycle() {
let mut spinner = ClaudeSpinner::new("US-001");
assert!(!spinner.stop_flag.load(Ordering::Relaxed));
spinner.update("Working");
let activity = spinner.last_activity.lock().unwrap().clone();
assert_eq!(activity, "Working");
spinner.stop_timer();
assert!(spinner.stop_flag.load(Ordering::Relaxed));
}
#[test]
fn test_verbose_timer_lifecycle() {
let mut timer = VerboseTimer::new("US-001");
assert!(!timer.stop_flag.load(Ordering::Relaxed));
timer.stop_timer();
assert!(timer.stop_flag.load(Ordering::Relaxed));
}
#[test]
fn test_drop_stops_timer() {
let stop_flag_clone;
{
let spinner = ClaudeSpinner::new("test");
stop_flag_clone = Arc::clone(&spinner.stop_flag);
assert!(!stop_flag_clone.load(Ordering::Relaxed));
}
assert!(stop_flag_clone.load(Ordering::Relaxed));
}
#[test]
fn test_format_tokens_zero() {
assert_eq!(format_tokens(0), "0");
}
#[test]
fn test_format_tokens_small() {
assert_eq!(format_tokens(1), "1");
assert_eq!(format_tokens(12), "12");
assert_eq!(format_tokens(123), "123");
}
#[test]
fn test_format_tokens_thousands() {
assert_eq!(format_tokens(1000), "1,000");
assert_eq!(format_tokens(1234), "1,234");
assert_eq!(format_tokens(12345), "12,345");
assert_eq!(format_tokens(123456), "123,456");
}
#[test]
fn test_format_tokens_millions() {
assert_eq!(format_tokens(1000000), "1,000,000");
assert_eq!(format_tokens(1234567), "1,234,567");
assert_eq!(format_tokens(12345678), "12,345,678");
}
#[test]
fn test_format_tokens_large() {
assert_eq!(format_tokens(123456789), "123,456,789");
assert_eq!(format_tokens(1234567890), "1,234,567,890");
}
#[test]
fn test_format_tokens_boundary_cases() {
assert_eq!(format_tokens(999), "999");
assert_eq!(format_tokens(1000), "1,000");
assert_eq!(format_tokens(9999), "9,999");
assert_eq!(format_tokens(10000), "10,000");
assert_eq!(format_tokens(99999), "99,999");
assert_eq!(format_tokens(100000), "100,000");
}
#[test]
fn test_outcome_with_tokens() {
let outcome = Outcome::success("Done").with_tokens(45678);
assert!(outcome.success);
assert_eq!(outcome.message, "Done");
assert_eq!(outcome.tokens, Some(45678));
}
#[test]
fn test_outcome_with_optional_tokens_some() {
let outcome = Outcome::success("Done").with_optional_tokens(Some(12345));
assert_eq!(outcome.tokens, Some(12345));
}
#[test]
fn test_outcome_with_optional_tokens_none() {
let outcome = Outcome::success("Done").with_optional_tokens(None);
assert_eq!(outcome.tokens, None);
}
#[test]
fn test_outcome_default_no_tokens() {
let outcome = Outcome::success("Done");
assert_eq!(outcome.tokens, None);
let outcome_fail = Outcome::failure("Error");
assert_eq!(outcome_fail.tokens, None);
}
#[test]
fn test_outcome_failure_with_tokens() {
let outcome = Outcome::failure("Build failed").with_tokens(1000);
assert!(!outcome.success);
assert_eq!(outcome.message, "Build failed");
assert_eq!(outcome.tokens, Some(1000));
}
}