1use std::collections::HashMap;
8use std::io::{self, Stdout, Write};
9use std::time::Duration;
10
11use chrono::{DateTime, Utc};
12use ratatui::backend::CrosstermBackend;
13use ratatui::style::Style;
14use ratatui::text::{Line, Span};
15use ratatui::widgets::{Paragraph, Widget};
16use ratatui::{Terminal, TerminalOptions, Viewport};
17
18use crate::yarli_core::domain::CancellationProvenance;
19use crate::yarli_core::domain::TaskId;
20use crate::yarli_core::entities::continuation::TaskHealthAction;
21use crate::yarli_core::explain::DeteriorationTrend;
22use crate::yarli_core::fsm::task::TaskState;
23
24use super::events::{StreamEvent, TaskView};
25use super::spinner::{Spinner, GLYPH_BLOCKED, GLYPH_COMPLETE, GLYPH_FAILED, GLYPH_PENDING};
26use super::style::Tier;
27
28const DEFAULT_VIEWPORT_HEIGHT: u16 = 8;
30const RUN_ID_DISPLAY_LEN: usize = 12;
31const TRANSIENT_STATUS_EMIT_SECS: i64 = 30;
32
33#[derive(Debug, Clone)]
35pub struct StreamConfig {
36 pub viewport_height: u16,
38 pub verbose_output: bool,
40}
41
42impl Default for StreamConfig {
43 fn default() -> Self {
44 Self {
45 viewport_height: DEFAULT_VIEWPORT_HEIGHT,
46 verbose_output: false,
47 }
48 }
49}
50
51pub struct StreamRenderer {
57 terminal: Terminal<CrosstermBackend<Stdout>>,
58 config: StreamConfig,
59 tasks: HashMap<TaskId, TaskView>,
61 task_states: HashMap<TaskId, TaskState>,
63 task_order: Vec<TaskId>,
65 spinners: HashMap<TaskId, Spinner>,
67 explain_summary: Option<String>,
69 transient_status: Option<String>,
71 last_transient_status_emit_at: Option<DateTime<Utc>>,
73}
74
75impl StreamRenderer {
76 pub fn new(config: StreamConfig) -> io::Result<Self> {
78 let backend = CrosstermBackend::new(io::stdout());
79 let terminal = Terminal::with_options(
80 backend,
81 TerminalOptions {
82 viewport: Viewport::Inline(config.viewport_height),
83 },
84 )?;
85
86 Ok(Self {
87 terminal,
88 config,
89 tasks: HashMap::new(),
90 task_states: HashMap::new(),
91 task_order: Vec::new(),
92 spinners: HashMap::new(),
93 explain_summary: None,
94 transient_status: None,
95 last_transient_status_emit_at: None,
96 })
97 }
98
99 pub fn handle_event(&mut self, event: StreamEvent) -> io::Result<()> {
101 match event {
102 StreamEvent::TaskDiscovered {
103 task_id,
104 task_name,
105 depends_on,
106 } => {
107 let blocked_by = if depends_on.is_empty() {
108 None
109 } else {
110 Some(depends_on.join(", "))
111 };
112 if let std::collections::hash_map::Entry::Vacant(e) = self.tasks.entry(task_id) {
113 self.task_order.push(task_id);
114 e.insert(TaskView {
115 task_id,
116 name: task_name,
117 state: TaskState::TaskOpen,
118 elapsed: None,
119 last_output_line: None,
120 blocked_by,
121 worker_id: None,
122 });
123 }
124 self.task_states
125 .entry(task_id)
126 .or_insert(TaskState::TaskOpen);
127 }
128 StreamEvent::TaskTransition {
129 task_id,
130 task_name,
131 from,
132 to,
133 elapsed,
134 exit_code,
135 detail,
136 at,
137 } => {
138 self.handle_task_transition(
139 task_id,
140 &task_name,
141 from,
142 to,
143 elapsed,
144 exit_code,
145 detail.as_deref(),
146 at,
147 )?;
148 }
149 StreamEvent::RunTransition {
150 run_id,
151 from,
152 to,
153 reason,
154 at,
155 } => {
156 let progress = self.progress_snapshot();
157 self.push_run_transition(run_id, from, to, reason.as_deref(), at, progress)?;
158 }
159 StreamEvent::RunStarted {
160 run_id,
161 objective,
162 at,
163 } => {
164 self.push_run_started(run_id, &objective, at)?;
165 }
166 StreamEvent::CommandOutput {
167 task_id,
168 task_name,
169 line,
170 } => {
171 if self.config.verbose_output {
172 let output_line = Line::from(vec![
173 Span::styled(format!(" [{task_name}] "), Tier::Background.style()),
174 Span::styled(line.clone(), Tier::Contextual.style()),
175 ]);
176 self.terminal.insert_before(1, |buf| {
177 Paragraph::new(output_line).render(buf.area, buf);
178 })?;
179 }
180 if let Some(view) = self.tasks.get_mut(&task_id) {
181 view.last_output_line = Some(line);
182 }
183 }
184 StreamEvent::TransientStatus { message } => {
185 if should_emit_transient_status_line(
186 self.last_transient_status_emit_at,
187 &message,
188 Utc::now(),
189 ) {
190 let progress = self.progress_snapshot();
191 self.push_transient_status_line(&message, Utc::now(), progress)?;
192 }
193 self.transient_status = Some(message);
194 }
195 StreamEvent::ExplainUpdate { summary } => {
196 self.explain_summary = Some(summary);
197 }
198 StreamEvent::TaskWorker { task_id, worker_id } => {
199 if let Some(view) = self.tasks.get_mut(&task_id) {
200 view.worker_id = Some(worker_id);
201 }
202 }
203 StreamEvent::RunExited { payload } => {
204 self.push_continuation_summary(&payload)?;
205 }
206 StreamEvent::Tick => {
207 for spinner in self.spinners.values_mut() {
208 spinner.tick();
209 }
210 }
211 }
212
213 self.draw_viewport()?;
214 io::stdout().flush()?;
216 Ok(())
217 }
218
219 #[allow(clippy::too_many_arguments)]
221 fn handle_task_transition(
222 &mut self,
223 task_id: TaskId,
224 task_name: &str,
225 from: TaskState,
226 to: TaskState,
227 elapsed: Option<Duration>,
228 exit_code: Option<i32>,
229 detail: Option<&str>,
230 at: DateTime<Utc>,
231 ) -> io::Result<()> {
232 self.task_states.insert(task_id, to);
233 let progress = self.progress_snapshot();
234
235 self.push_task_transition(
237 task_id, task_name, from, to, elapsed, exit_code, detail, at, progress,
238 )?;
239
240 if to.is_terminal() {
241 self.tasks.remove(&task_id);
243 self.task_order.retain(|id| *id != task_id);
244 self.spinners.remove(&task_id);
245 } else {
246 if let std::collections::hash_map::Entry::Vacant(e) = self.tasks.entry(task_id) {
248 self.task_order.push(task_id);
249 e.insert(TaskView {
250 task_id,
251 name: task_name.to_string(),
252 state: to,
253 elapsed,
254 last_output_line: None,
255 blocked_by: None,
256 worker_id: None,
257 });
258 } else {
259 let view = self.tasks.get_mut(&task_id).unwrap();
260 view.state = to;
261 view.elapsed = elapsed;
262 }
263
264 if to == TaskState::TaskExecuting {
265 self.spinners.entry(task_id).or_default();
266 }
267 }
268
269 Ok(())
270 }
271
272 #[allow(clippy::too_many_arguments)]
277 fn push_task_transition(
278 &mut self,
279 _task_id: TaskId,
280 task_name: &str,
281 from: TaskState,
282 to: TaskState,
283 elapsed: Option<Duration>,
284 exit_code: Option<i32>,
285 detail: Option<&str>,
286 at: DateTime<Utc>,
287 progress: ProgressSnapshot,
288 ) -> io::Result<()> {
289 let tier = tier_for_task_state(to);
290 let time_str = at.format("%H:%M:%S").to_string();
291
292 let mut spans = vec![
293 Span::styled(time_str, Tier::Contextual.style()),
294 Span::styled(" ▸ ", Tier::Background.style()),
295 Span::styled(format!("task/{:<16}", task_name), tier.style()),
296 Span::styled(format!("{:?}", from), Tier::Contextual.style()),
297 Span::styled(" → ", Tier::Background.style()),
298 Span::styled(format!("{:?}", to), tier.style()),
299 ];
300
301 let mut meta_parts = Vec::new();
303 if let Some(d) = elapsed {
304 meta_parts.push(format_duration(d));
305 }
306 if let Some(code) = exit_code {
307 meta_parts.push(format!("exit {code}"));
308 }
309 if let Some(d) = detail {
310 meta_parts.push(d.to_string());
311 }
312 if !meta_parts.is_empty() {
313 spans.push(Span::styled(
314 format!(" ({})", meta_parts.join(", ")),
315 Tier::Contextual.style(),
316 ));
317 }
318 spans.push(Span::styled(
319 format!(" progress {}", format_ascii_progress(progress, 20)),
320 Tier::Contextual.style(),
321 ));
322
323 let line = Line::from(spans);
324
325 self.terminal.insert_before(1, |buf| {
326 Paragraph::new(line).render(buf.area, buf);
327 })?;
328
329 Ok(())
330 }
331
332 fn push_run_started(
334 &mut self,
335 run_id: uuid::Uuid,
336 objective: &str,
337 at: DateTime<Utc>,
338 ) -> io::Result<()> {
339 let time_str = at.format("%H:%M:%S").to_string();
340 let display_id = display_run_id(run_id);
341
342 let spans = vec![
343 Span::styled(time_str, Tier::Contextual.style()),
344 Span::styled(" ▸ ", Tier::Background.style()),
345 Span::styled(format!("run/{display_id}"), Tier::Active.style()),
346 Span::styled(format!(" started: {objective}"), Tier::Contextual.style()),
347 ];
348
349 let line = Line::from(spans);
350
351 self.terminal.insert_before(1, |buf| {
352 Paragraph::new(line).render(buf.area, buf);
353 })?;
354
355 Ok(())
356 }
357
358 fn push_run_transition(
360 &mut self,
361 run_id: uuid::Uuid,
362 from: crate::yarli_core::fsm::run::RunState,
363 to: crate::yarli_core::fsm::run::RunState,
364 reason: Option<&str>,
365 at: DateTime<Utc>,
366 progress: ProgressSnapshot,
367 ) -> io::Result<()> {
368 let tier = tier_for_run_state(to);
369 let time_str = at.format("%H:%M:%S").to_string();
370 let display_id = display_run_id(run_id);
371
372 let mut spans = vec![
373 Span::styled(time_str, Tier::Contextual.style()),
374 Span::styled(" ▸ ", Tier::Background.style()),
375 Span::styled(format!("run/{:<20}", display_id), tier.style()),
376 Span::styled(format!("{:?}", from), Tier::Contextual.style()),
377 Span::styled(" → ", Tier::Background.style()),
378 Span::styled(format!("{:?}", to), tier.style()),
379 ];
380
381 if let Some(r) = reason {
382 spans.push(Span::styled(
383 format!(" (reason: {r})"),
384 Tier::Contextual.style(),
385 ));
386 }
387 spans.push(Span::styled(
388 format!(" progress {}", format_ascii_progress(progress, 20)),
389 Tier::Contextual.style(),
390 ));
391
392 let line = Line::from(spans);
393
394 self.terminal.insert_before(1, |buf| {
395 Paragraph::new(line).render(buf.area, buf);
396 })?;
397
398 Ok(())
399 }
400
401 fn push_continuation_summary(
403 &mut self,
404 payload: &crate::yarli_core::entities::ContinuationPayload,
405 ) -> io::Result<()> {
406 let s = &payload.summary;
407
408 let header = Line::from(vec![Span::styled(
410 "── Continuation ──",
411 Tier::Active.style(),
412 )]);
413 self.terminal.insert_before(1, |buf| {
414 Paragraph::new(header).render(buf.area, buf);
415 })?;
416
417 let counts = format!(
419 " {} completed, {} failed, {} pending",
420 s.completed, s.failed, s.pending
421 );
422 let counts_line = Line::from(vec![Span::styled(counts, Tier::Contextual.style())]);
423 self.terminal.insert_before(1, |buf| {
424 Paragraph::new(counts_line).render(buf.area, buf);
425 })?;
426
427 if let Some(reason) = payload.exit_reason {
428 let reason_line = Line::from(vec![Span::styled(
429 format!(" Exit reason: {reason}"),
430 Tier::Contextual.style(),
431 )]);
432 self.terminal.insert_before(1, |buf| {
433 Paragraph::new(reason_line).render(buf.area, buf);
434 })?;
435 }
436
437 let cancelled = payload.exit_state == crate::yarli_core::fsm::run::RunState::RunCancelled;
438 if cancelled || payload.cancellation_source.is_some() {
439 let source = payload
440 .cancellation_source
441 .map(|value| value.to_string())
442 .unwrap_or_else(|| "unknown".to_string());
443 let source_line = Line::from(vec![Span::styled(
444 format!(" Cancel source: {source}"),
445 Tier::Contextual.style(),
446 )]);
447 self.terminal.insert_before(1, |buf| {
448 Paragraph::new(source_line).render(buf.area, buf);
449 })?;
450 }
451
452 if cancelled || payload.cancellation_provenance.is_some() {
453 let summary =
454 format_cancel_provenance_summary(payload.cancellation_provenance.as_ref());
455 let provenance_line = Line::from(vec![Span::styled(
456 format!(" Cancel provenance: {summary}"),
457 Tier::Contextual.style(),
458 )]);
459 self.terminal.insert_before(1, |buf| {
460 Paragraph::new(provenance_line).render(buf.area, buf);
461 })?;
462 }
463
464 if let Some(tranche) = &payload.next_tranche {
466 if !tranche.retry_task_keys.is_empty() {
467 let retry = format!(" Retry: [{}]", tranche.retry_task_keys.join(", "));
468 let retry_line = Line::from(vec![Span::styled(retry, Tier::Urgent.style())]);
469 self.terminal.insert_before(1, |buf| {
470 Paragraph::new(retry_line).render(buf.area, buf);
471 })?;
472 }
473 if !tranche.unfinished_task_keys.is_empty() {
474 let unfinished = format!(
475 " Unfinished: [{}]",
476 tranche.unfinished_task_keys.join(", ")
477 );
478 let unfinished_line =
479 Line::from(vec![Span::styled(unfinished, Tier::Contextual.style())]);
480 self.terminal.insert_before(1, |buf| {
481 Paragraph::new(unfinished_line).render(buf.area, buf);
482 })?;
483 }
484 let next = format!(" Next: \"{}\"", tranche.suggested_objective);
485 let next_line = Line::from(vec![Span::styled(next, Tier::Active.style())]);
486 self.terminal.insert_before(1, |buf| {
487 Paragraph::new(next_line).render(buf.area, buf);
488 })?;
489 }
490
491 if let Some(quality_gate) = payload.quality_gate.as_ref() {
492 if matches!(
493 quality_gate.task_health_action,
494 TaskHealthAction::ForcePivot
495 ) {
496 if let Some(guidance) = Self::force_pivot_guidance(quality_gate.trend.as_ref()) {
497 let guidance_line =
498 Line::from(vec![Span::styled(guidance, Tier::Urgent.style())]);
499 self.terminal.insert_before(1, |buf| {
500 Paragraph::new(guidance_line).render(buf.area, buf);
501 })?;
502 }
503 }
504 if matches!(
505 quality_gate.task_health_action,
506 TaskHealthAction::StopAndSummarize
507 ) {
508 let guidance = format!(" Stop-and-summarize guidance: {}", quality_gate.reason);
509 let guidance_line = Line::from(vec![Span::styled(guidance, Tier::Urgent.style())]);
510 self.terminal.insert_before(1, |buf| {
511 Paragraph::new(guidance_line).render(buf.area, buf);
512 })?;
513 }
514 if matches!(
515 quality_gate.task_health_action,
516 TaskHealthAction::CheckpointNow
517 ) {
518 let guidance = format!(" Checkpoint-now guidance: {}", quality_gate.reason);
519 let guidance_line = Line::from(vec![Span::styled(guidance, Tier::Urgent.style())]);
520 self.terminal.insert_before(1, |buf| {
521 Paragraph::new(guidance_line).render(buf.area, buf);
522 })?;
523 }
524 }
525
526 Ok(())
527 }
528
529 fn force_pivot_guidance(trend: Option<&DeteriorationTrend>) -> Option<String> {
530 if matches!(trend, Some(DeteriorationTrend::Deteriorating)) {
531 Some(
532 " Force-pivot guidance: sequence quality is deteriorating; narrow scope and shift task focus before continuing."
533 .to_string(),
534 )
535 } else {
536 None
537 }
538 }
539
540 fn draw_viewport(&mut self) -> io::Result<()> {
542 let tasks: Vec<_> = self
543 .task_order
544 .iter()
545 .filter_map(|id| self.tasks.get(id))
546 .cloned()
547 .collect();
548 let spinners = &self.spinners;
549 let transient = self.transient_status.take();
550 let explain = self.explain_summary.clone();
551
552 self.terminal.draw(|frame| {
553 let area = frame.area();
554 let mut lines = Vec::new();
555
556 for task in &tasks {
558 let (glyph, tier) = match task.state {
559 TaskState::TaskExecuting => {
560 let sp = spinners
561 .get(&task.task_id)
562 .map(|s| s.frame())
563 .unwrap_or('⠋');
564 (sp, Tier::Active)
565 }
566 TaskState::TaskWaiting => ('⠿', Tier::Active),
567 TaskState::TaskBlocked => (GLYPH_BLOCKED, Tier::Contextual),
568 TaskState::TaskReady => (GLYPH_PENDING, Tier::Contextual),
569 TaskState::TaskOpen => (GLYPH_PENDING, Tier::Contextual),
570 TaskState::TaskComplete => (GLYPH_COMPLETE, Tier::Contextual),
571 TaskState::TaskFailed => (GLYPH_FAILED, Tier::Urgent),
572 TaskState::TaskCancelled => (GLYPH_BLOCKED, Tier::Contextual),
573 TaskState::TaskVerifying => (GLYPH_PENDING, Tier::Active),
574 };
575
576 let elapsed_str = task
577 .elapsed
578 .map(|d| format!("{}s", d.as_secs()))
579 .unwrap_or_default();
580
581 let mut spans = vec![
582 Span::styled(" ", Style::default()),
583 Span::styled(format!("{glyph} "), tier.style()),
584 Span::styled(format!("task/{:<14}", task.name), tier.style()),
585 Span::styled(format!("{:<8}", elapsed_str), Tier::Contextual.style()),
586 ];
587
588 if let Some(ref output) = task.last_output_line {
590 let max_len = area.width.saturating_sub(40) as usize;
591 let truncated = if output.len() > max_len {
592 let mut end = max_len;
594 while end > 0 && !output.is_char_boundary(end) {
595 end -= 1;
596 }
597 &output[..end]
598 } else {
599 output.as_str()
600 };
601 spans.push(Span::styled(truncated.to_string(), tier.accent()));
602 } else if task.state == TaskState::TaskBlocked {
603 if let Some(ref by) = task.blocked_by {
604 spans.push(Span::styled(
605 format!("blocked-by: {by}"),
606 Tier::Contextual.accent(),
607 ));
608 }
609 } else if task.state == TaskState::TaskReady || task.state == TaskState::TaskOpen {
610 spans.push(Span::styled("waiting", Tier::Contextual.accent()));
611 }
612
613 lines.push(Line::from(spans));
614 }
615
616 if let Some(msg) = transient {
618 lines.push(Line::from(vec![Span::styled(
619 format!(" {msg}"),
620 Tier::Background.style(),
621 )]));
622 }
623
624 if let Some(ref summary) = explain {
626 lines.push(Line::from(vec![
627 Span::styled(" WHY: ", Tier::Urgent.accent()),
628 Span::styled(summary.clone(), Tier::Urgent.style()),
629 ]));
630 }
631
632 let paragraph = Paragraph::new(lines);
633 frame.render_widget(paragraph, area);
634 })?;
635
636 Ok(())
637 }
638
639 pub fn restore(&mut self) -> io::Result<()> {
644 self.terminal.clear()?;
646 io::stdout().flush()?;
647 Ok(())
648 }
649
650 pub fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<Stdout>> {
652 &mut self.terminal
653 }
654
655 fn push_transient_status_line(
656 &mut self,
657 message: &str,
658 at: DateTime<Utc>,
659 progress: ProgressSnapshot,
660 ) -> io::Result<()> {
661 self.last_transient_status_emit_at = Some(at);
662 let spans = vec![
663 Span::styled(at.format("%H:%M:%S").to_string(), Tier::Contextual.style()),
664 Span::styled(" ▸ ", Tier::Background.style()),
665 Span::styled("status", Tier::Active.style()),
666 Span::styled(
667 format!(
668 " {message} progress {}",
669 format_ascii_progress(progress, 20)
670 ),
671 Tier::Contextual.style(),
672 ),
673 ];
674 let line = Line::from(spans);
675 self.terminal.insert_before(1, |buf| {
676 Paragraph::new(line).render(buf.area, buf);
677 })?;
678 Ok(())
679 }
680
681 fn progress_snapshot(&self) -> ProgressSnapshot {
682 let mut snapshot = ProgressSnapshot {
683 total: self.task_states.len() as u32,
684 ..ProgressSnapshot::default()
685 };
686 for state in self.task_states.values() {
687 match state {
688 TaskState::TaskComplete => snapshot.completed += 1,
689 TaskState::TaskFailed => snapshot.failed += 1,
690 TaskState::TaskCancelled => snapshot.cancelled += 1,
691 _ => {}
692 }
693 }
694 snapshot
695 }
696}
697
698impl Drop for StreamRenderer {
699 fn drop(&mut self) {
700 let _ = self.restore();
701 }
702}
703
704#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
705struct ProgressSnapshot {
706 total: u32,
707 completed: u32,
708 failed: u32,
709 cancelled: u32,
710}
711
712impl ProgressSnapshot {
713 fn terminal_count(self) -> u32 {
714 self.completed + self.failed + self.cancelled
715 }
716}
717
718fn format_ascii_progress(snapshot: ProgressSnapshot, width: usize) -> String {
719 let done = snapshot.terminal_count();
720 let total = snapshot.total;
721 let (filled, percent) = if total == 0 {
722 (0usize, 0u32)
723 } else {
724 let filled = ((done as f64 / total as f64) * width as f64).round() as usize;
725 let percent = ((done as f64 / total as f64) * 100.0).round() as u32;
726 (filled.min(width), percent.min(100))
727 };
728 let bar = format!("{}{}", "#".repeat(filled), ".".repeat(width - filled));
729 format!("[{bar}] {done}/{total} ({percent}%)")
730}
731
732fn should_emit_transient_status_line(
733 last_emit_at: Option<DateTime<Utc>>,
734 message: &str,
735 now: DateTime<Utc>,
736) -> bool {
737 if message.starts_with("operator ") {
738 return true;
739 }
740 let Some(last) = last_emit_at else {
741 return true;
742 };
743 now.signed_duration_since(last).num_seconds() >= TRANSIENT_STATUS_EMIT_SECS
744}
745
746fn format_cancel_provenance_summary(provenance: Option<&CancellationProvenance>) -> String {
747 let signal = provenance
748 .and_then(|p| p.signal_name.as_deref())
749 .unwrap_or("unknown");
750 let sender = provenance
751 .and_then(|p| p.sender_pid)
752 .map(|pid| pid.to_string())
753 .unwrap_or_else(|| "unknown".to_string());
754 let receiver = provenance
755 .and_then(|p| p.receiver_pid)
756 .map(|pid| format!("yarli({pid})"))
757 .unwrap_or_else(|| "unknown".to_string());
758 let actor = provenance
759 .and_then(|p| p.actor_kind)
760 .map(|kind| kind.to_string())
761 .unwrap_or_else(|| "unknown".to_string());
762 let stage = provenance
763 .and_then(|p| p.stage)
764 .map(|stage| stage.to_string())
765 .unwrap_or_else(|| "unknown".to_string());
766 format!("signal={signal} sender={sender} receiver={receiver} actor={actor} stage={stage}")
767}
768
769fn tier_for_task_state(state: TaskState) -> Tier {
771 match state {
772 TaskState::TaskFailed => Tier::Urgent,
773 TaskState::TaskBlocked => Tier::Contextual,
774 TaskState::TaskExecuting | TaskState::TaskWaiting => Tier::Active,
775 TaskState::TaskComplete => Tier::Contextual,
776 _ => Tier::Contextual,
777 }
778}
779
780fn display_run_id(run_id: uuid::Uuid) -> String {
781 let compact = run_id.simple().to_string();
782 compact[..RUN_ID_DISPLAY_LEN.min(compact.len())].to_string()
783}
784
785fn tier_for_run_state(state: crate::yarli_core::fsm::run::RunState) -> Tier {
787 use crate::yarli_core::fsm::run::RunState;
788 match state {
789 RunState::RunFailed | RunState::RunBlocked => Tier::Urgent,
790 RunState::RunActive | RunState::RunVerifying => Tier::Active,
791 RunState::RunCompleted => Tier::Contextual,
792 RunState::RunDrained => Tier::Contextual,
793 _ => Tier::Contextual,
794 }
795}
796
797fn format_duration(d: Duration) -> String {
799 let secs = d.as_secs();
800 let millis = d.subsec_millis();
801 if secs >= 60 {
802 format!("{}m {}s", secs / 60, secs % 60)
803 } else if secs >= 10 {
804 format!("{secs}s")
805 } else {
806 format!("{secs}.{millis_h}s", millis_h = millis / 100)
807 }
808}
809
810#[cfg(test)]
812mod tests {
813 use super::*;
814
815 #[test]
816 fn format_duration_short() {
817 assert_eq!(format_duration(Duration::from_millis(3200)), "3.2s");
818 }
819
820 #[test]
821 fn format_duration_medium() {
822 assert_eq!(format_duration(Duration::from_secs(34)), "34s");
823 }
824
825 #[test]
826 fn format_duration_long() {
827 assert_eq!(format_duration(Duration::from_secs(64)), "1m 4s");
828 }
829
830 #[test]
831 fn format_duration_zero() {
832 assert_eq!(format_duration(Duration::ZERO), "0.0s");
833 }
834
835 #[test]
836 fn tier_for_failed_task() {
837 assert_eq!(tier_for_task_state(TaskState::TaskFailed), Tier::Urgent);
838 }
839
840 #[test]
841 fn tier_for_executing_task() {
842 assert_eq!(tier_for_task_state(TaskState::TaskExecuting), Tier::Active);
843 }
844
845 #[test]
846 fn tier_for_complete_task() {
847 assert_eq!(
848 tier_for_task_state(TaskState::TaskComplete),
849 Tier::Contextual
850 );
851 }
852
853 #[test]
854 fn tier_for_blocked_run() {
855 use crate::yarli_core::fsm::run::RunState;
856 assert_eq!(tier_for_run_state(RunState::RunBlocked), Tier::Urgent);
857 }
858
859 #[test]
860 fn tier_for_active_run() {
861 use crate::yarli_core::fsm::run::RunState;
862 assert_eq!(tier_for_run_state(RunState::RunActive), Tier::Active);
863 }
864
865 #[test]
866 fn tier_for_completed_run() {
867 use crate::yarli_core::fsm::run::RunState;
868 assert_eq!(tier_for_run_state(RunState::RunCompleted), Tier::Contextual);
869 }
870
871 #[test]
872 fn display_run_id_uses_compact_prefix() {
873 let run_id =
874 uuid::Uuid::parse_str("019c4f51-5f70-7d84-a0c8-2f5c6bb8ef12").expect("valid uuid");
875 assert_eq!(display_run_id(run_id), "019c4f515f70");
876 }
877
878 #[test]
879 fn transient_status_line_throttles_regular_heartbeats() {
880 let now = Utc::now();
881 assert!(!should_emit_transient_status_line(
882 Some(now),
883 "heartbeat: pending=1 leased=1",
884 now + chrono::Duration::seconds(10),
885 ));
886 assert!(should_emit_transient_status_line(
887 Some(now),
888 "heartbeat: pending=1 leased=1",
889 now + chrono::Duration::seconds(31),
890 ));
891 }
892
893 #[test]
894 fn transient_status_line_always_emits_operator_messages() {
895 let now = Utc::now();
896 assert!(should_emit_transient_status_line(
897 Some(now),
898 "operator pause: maintenance",
899 now + chrono::Duration::seconds(1),
900 ));
901 }
902
903 #[test]
904 fn ascii_progress_formats_empty_snapshot() {
905 let snapshot = ProgressSnapshot::default();
906 assert_eq!(format_ascii_progress(snapshot, 10), "[..........] 0/0 (0%)");
907 }
908
909 #[test]
910 fn ascii_progress_formats_partial_completion() {
911 let snapshot = ProgressSnapshot {
912 total: 5,
913 completed: 2,
914 failed: 0,
915 cancelled: 0,
916 };
917 assert_eq!(
918 format_ascii_progress(snapshot, 10),
919 "[####......] 2/5 (40%)"
920 );
921 }
922
923 #[test]
924 fn ascii_progress_counts_all_terminal_states() {
925 let snapshot = ProgressSnapshot {
926 total: 4,
927 completed: 1,
928 failed: 1,
929 cancelled: 1,
930 };
931 assert_eq!(
932 format_ascii_progress(snapshot, 10),
933 "[########..] 3/4 (75%)"
934 );
935 }
936}