1use std::io::{Write, stdout};
7
8use anyhow::Result;
9use chrono::{DateTime, Utc};
10use comfy_table::presets::UTF8_FULL;
11use comfy_table::{Cell, CellAlignment, Color, ContentArrangement, Table};
12use ironflow_sdk::client::ApiResponse;
13use ironflow_sdk::types::{
14 AccountState, AccountWindowResponse, AccountWindowStatus, AgentRoleStatsResponse,
15 ApiKeyResponse, ApiKeyScope, ArtifactResponse, AuditLogEntry, ConcurrencyLimit,
16 CreateApiKeyResponse, ExecutionPlanResponse, KeyVersionsResponse, PlannedStepResponse,
17 ProviderAccountResponse, RunDetailResponse, RunResponse, RunStatus, ScopeEntry, SecretResponse,
18 StatsHistoryResponse, StatsResponse, StepResponse, StepStatus, UserGroupsResponse,
19 UserResponse, WorkerRouting, WorkflowDetailResponse, WorkflowSummary,
20};
21use serde::Serialize;
22use serde_json::to_string_pretty;
23use uuid::Uuid;
24
25mod cancel;
26mod pause;
27
28pub use cancel::cancelled_table;
29pub use pause::{paused_table, resumed_table, workflow_pause_table};
30
31fn status_color(status: &RunStatus) -> Color {
33 match status {
34 RunStatus::Completed => Color::Green,
35 RunStatus::Failed => Color::Red,
36 RunStatus::Running => Color::Blue,
37 RunStatus::Pending => Color::Yellow,
38 RunStatus::Cancelled => Color::Grey,
39 RunStatus::AwaitingApproval => Color::Magenta,
40 RunStatus::Retrying => Color::Cyan,
41 RunStatus::Warning => Color::DarkYellow,
42 RunStatus::Sleeping => Color::DarkCyan,
43 RunStatus::Paused => Color::DarkMagenta,
44 }
45}
46
47fn step_status_color(status: &StepStatus) -> Color {
49 match status {
50 StepStatus::Completed => Color::Green,
51 StepStatus::Failed => Color::Red,
52 StepStatus::Running => Color::Blue,
53 StepStatus::Pending => Color::Yellow,
54 StepStatus::Skipped => Color::Grey,
55 StepStatus::AwaitingApproval => Color::Magenta,
56 StepStatus::Rejected => Color::Red,
57 }
58}
59
60fn format_datetime(dt: &DateTime<Utc>) -> String {
62 dt.format("%Y-%m-%d %H:%M:%S").to_string()
63}
64
65fn format_optional_datetime(dt: &Option<DateTime<Utc>>) -> String {
67 dt.as_ref().map_or("-".to_string(), format_datetime)
68}
69
70const SLA_WARNING_RATIO: f64 = 0.1;
72
73fn format_remaining_secs(remaining: Option<i64>) -> String {
77 let Some(remaining) = remaining else {
78 return "-".to_string();
79 };
80 if remaining <= 0 {
81 return "expired".to_string();
82 }
83
84 if remaining < 60 {
85 return format!("{remaining}s");
86 }
87
88 let minutes = remaining / 60;
89 if minutes < 60 {
90 let rest = remaining % 60;
91 return if rest == 0 {
92 format!("{minutes}m")
93 } else {
94 format!("{minutes}m {rest}s")
95 };
96 }
97
98 let hours = minutes / 60;
99 let rest = minutes % 60;
100 if rest == 0 {
101 format!("{hours}h")
102 } else {
103 format!("{hours}h {rest}m")
104 }
105}
106
107fn remaining_color(remaining: Option<i64>, window_secs: Option<i64>) -> Option<Color> {
110 let remaining = remaining?;
111 if remaining <= 0 {
112 return Some(Color::Red);
113 }
114
115 let window = window_secs?;
116 if window > 0 && (remaining as f64) < (window as f64) * SLA_WARNING_RATIO {
117 return Some(Color::Yellow);
118 }
119
120 None
121}
122
123fn format_sla(step: &StepResponse) -> String {
129 format_remaining_secs(step.approval_seconds_remaining)
130}
131
132fn sla_color(step: &StepResponse) -> Option<Color> {
137 let window = match (step.approval_deadline_at, step.started_at) {
138 (Some(deadline), Some(started)) => Some((deadline - started).num_seconds()),
139 _ => None,
140 };
141 remaining_color(step.approval_seconds_remaining, window)
142}
143
144fn format_duration_ms(ms: i64) -> String {
146 if ms < 1000 {
147 return format!("{ms}ms");
148 }
149 let secs = ms / 1000;
150 if secs < 60 {
151 return format!("{secs}s");
152 }
153 let mins = secs / 60;
154 let remaining_secs = secs % 60;
155 if mins < 60 {
156 return format!("{mins}m {remaining_secs}s");
157 }
158 let hours = mins / 60;
159 let remaining_mins = mins % 60;
160 format!("{hours}h {remaining_mins}m")
161}
162
163fn base_table() -> Table {
165 let mut table = Table::new();
166 table
167 .load_preset(UTF8_FULL)
168 .set_content_arrangement(ContentArrangement::Dynamic);
169 table
170}
171
172pub fn render_output<W: Write, T: Serialize>(
178 writer: &mut W,
179 json_mode: bool,
180 value: &T,
181 table_fn: impl FnOnce() -> Table,
182) -> Result<()> {
183 if json_mode {
184 let json = to_string_pretty(value)?;
185 writeln!(writer, "{json}")?;
186 } else {
187 writeln!(writer, "{}", table_fn())?;
188 }
189 Ok(())
190}
191
192pub fn print_output<T: Serialize>(
198 json_mode: bool,
199 value: &T,
200 table_fn: impl FnOnce() -> Table,
201) -> Result<()> {
202 render_output(&mut stdout().lock(), json_mode, value, table_fn)
203}
204
205pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
214 let json = to_string_pretty(value)?;
215 writeln!(stdout().lock(), "{json}")?;
216 Ok(())
217}
218
219const COST_WARNING_RATIO: f64 = 0.8;
222
223fn format_cost(cost_usd: f64, max_cost_usd: Option<f64>) -> String {
227 match max_cost_usd {
228 Some(cap) => format!("${cost_usd:.4} / ${cap:.2}"),
229 None => format!("${cost_usd:.4}"),
230 }
231}
232
233fn cost_color(cost_usd: f64, max_cost_usd: Option<f64>) -> Option<Color> {
239 let cap = max_cost_usd?;
240
241 if cap <= 0.0 {
242 return (cost_usd > 0.0).then_some(Color::Red);
243 }
244
245 let ratio = cost_usd / cap;
246 if ratio >= 1.0 {
247 Some(Color::Red)
248 } else if ratio >= COST_WARNING_RATIO {
249 Some(Color::Yellow)
250 } else {
251 None
252 }
253}
254
255fn cost_cell(cost_usd: f64, max_cost_usd: Option<f64>) -> Cell {
257 let cell = Cell::new(format_cost(cost_usd, max_cost_usd));
258 match cost_color(cost_usd, max_cost_usd) {
259 Some(color) => cell.fg(color),
260 None => cell,
261 }
262}
263
264pub fn runs_table(runs: &[RunResponse]) -> Table {
265 let mut table = base_table();
266 table.set_header(vec![
267 "ID",
268 "Workflow",
269 "Status",
270 "Priority",
271 "Triggered by",
272 "Duration",
273 "Cost",
274 "Created",
275 "Started",
276 ]);
277
278 for run in runs {
279 let status_cell = Cell::new(run.status)
280 .fg(status_color(&run.status))
281 .set_alignment(CellAlignment::Center);
282
283 table.add_row(vec![
284 Cell::new(run.id.to_string().split('-').next().unwrap_or("")),
285 Cell::new(&run.workflow_name),
286 status_cell,
287 Cell::new(format_priority(run.priority)).set_alignment(CellAlignment::Right),
288 Cell::new(&run.created_by.label),
289 Cell::new(format_duration_ms(run.duration_ms)),
290 cost_cell(run.cost_usd, run.max_cost_usd),
291 Cell::new(format_datetime(&run.created_at)),
292 Cell::new(format_optional_datetime(&run.started_at)),
293 ]);
294 }
295
296 table
297}
298
299pub fn run_detail_table(detail: &RunDetailResponse) -> Table {
301 let run = &detail.run;
302 let mut table = base_table();
303 table.set_header(vec!["Field", "Value"]);
304
305 let status_cell = Cell::new(run.status).fg(status_color(&run.status));
306
307 table.add_row(vec![Cell::new("ID"), Cell::new(run.id)]);
308 table.add_row(vec![Cell::new("Workflow"), Cell::new(&run.workflow_name)]);
309 table.add_row(vec![Cell::new("Status"), status_cell]);
310 table.add_row(vec![
311 Cell::new("Priority"),
312 Cell::new(format_priority(run.priority)),
313 ]);
314 table.add_row(vec![
315 Cell::new("Trigger"),
316 Cell::new(format!("{:?}", run.trigger)),
317 ]);
318 table.add_row(vec![
319 Cell::new("Triggered by"),
320 Cell::new(&run.created_by.label),
321 ]);
322 table.add_row(vec![
323 Cell::new("Duration"),
324 Cell::new(format_duration_ms(run.duration_ms)),
325 ]);
326 table.add_row(vec![
327 Cell::new("Cost"),
328 cost_cell(run.cost_usd, run.max_cost_usd),
329 ]);
330 table.add_row(vec![
331 Cell::new("Created"),
332 Cell::new(format_datetime(&run.created_at)),
333 ]);
334 table.add_row(vec![
335 Cell::new("Started"),
336 Cell::new(format_optional_datetime(&run.started_at)),
337 ]);
338 table.add_row(vec![
339 Cell::new("Completed"),
340 Cell::new(format_optional_datetime(&run.completed_at)),
341 ]);
342 table.add_row(vec![
343 Cell::new("Retries"),
344 Cell::new(format!("{}/{}", run.retry_count, run.max_retries)),
345 ]);
346 table.add_row(vec![
347 Cell::new("Lease recoveries"),
348 Cell::new(run.lease_recoveries.to_string()),
349 ]);
350
351 if !run.concurrency_limits.is_empty() {
352 table.add_row(vec![
353 Cell::new("Concurrency groups"),
354 Cell::new(format_concurrency_limits(&run.concurrency_limits)),
355 ]);
356 }
357
358 if !run.worker_tags.is_empty() {
359 table.add_row(vec![
360 Cell::new("Worker tags"),
361 Cell::new(run.worker_tags.join(", ")),
362 ]);
363 }
364
365 if let Some(warning) = detail.worker_routing.as_ref().and_then(routing_warning) {
366 table.add_row(vec![
367 Cell::new("Workers"),
368 Cell::new(warning).fg(Color::Yellow),
369 ]);
370 }
371
372 if let Some(ref kind) = run.capacity_wait_kind {
373 let resumes = format_optional_datetime(&run.scheduled_at);
374 let reason = format!("{kind}, resumes at {resumes}");
375 table.add_row(vec![
376 Cell::new("Waiting for capacity"),
377 Cell::new(reason).fg(Color::DarkCyan),
378 ]);
379 }
380
381 if let Some(ref error) = run.error {
382 table.add_row(vec![Cell::new("Error"), Cell::new(error).fg(Color::Red)]);
383 }
384
385 if let Some(ref output) = run.output {
386 table.add_row(vec![Cell::new("Output"), Cell::new(output)]);
387 }
388
389 if !detail.steps.is_empty() {
390 table.add_row(vec![
391 Cell::new("Steps"),
392 Cell::new(format!("{} step(s)", detail.steps.len())),
393 ]);
394 }
395
396 table
397}
398
399fn format_priority(priority: Option<i32>) -> String {
401 priority.map_or_else(|| "-".to_string(), |p| p.to_string())
402}
403
404fn routing_warning(routing: &WorkerRouting) -> Option<&'static str> {
407 if routing.seen_workers == 0 {
408 Some("No worker seen recently")
409 } else if routing.eligible_workers == 0 {
410 Some("No eligible worker seen: none registers this workflow with every required tag")
411 } else {
412 None
413 }
414}
415
416fn format_concurrency_limits(limits: &[ConcurrencyLimit]) -> String {
418 limits
419 .iter()
420 .map(|l| format!("{} ({})", l.group, l.limit))
421 .collect::<Vec<_>>()
422 .join(", ")
423}
424
425fn format_artifacts(artifacts: &[ArtifactResponse]) -> String {
429 if artifacts.is_empty() {
430 return "-".to_string();
431 }
432
433 let total: i64 = artifacts.iter().map(|artifact| artifact.size_bytes).sum();
434 format!("{} ({})", artifacts.len(), format_bytes(total))
435}
436
437fn format_bytes(bytes: i64) -> String {
439 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
440
441 if bytes < 1024 {
442 return format!("{bytes} B");
443 }
444
445 let mut value = bytes as f64;
446 let mut unit = 0;
447 while value >= 1024.0 && unit < UNITS.len() - 1 {
448 value /= 1024.0;
449 unit += 1;
450 }
451
452 let decimals = if value < 10.0 { 1 } else { 0 };
453 format!("{value:.decimals$} {}", UNITS[unit])
454}
455
456pub fn steps_table(steps: &[StepResponse]) -> Table {
458 let mut table = base_table();
459 table.set_header(vec![
460 "ID",
461 "Name",
462 "Role",
463 "Status",
464 "SLA",
465 "Attempt",
466 "Duration",
467 "Cost",
468 "Artifacts",
469 "Started",
470 "Completed",
471 ]);
472
473 for step in steps {
474 let color = step_status_color(&step.status);
475
476 let mut sla = Cell::new(format_sla(step)).set_alignment(CellAlignment::Center);
477 if let Some(sla_fg) = sla_color(step) {
478 sla = sla.fg(sla_fg);
479 }
480
481 table.add_row(vec![
482 Cell::new(step.id.to_string().split('-').next().unwrap_or("")),
483 Cell::new(&step.name),
484 Cell::new(step.agent_role.as_deref().unwrap_or("-")),
485 Cell::new(step.status)
486 .fg(color)
487 .set_alignment(CellAlignment::Center),
488 sla,
489 Cell::new(step.attempt).set_alignment(CellAlignment::Center),
490 Cell::new(format_duration_ms(step.duration_ms)),
491 Cell::new(format!("${:.4}", step.cost_usd)),
492 Cell::new(format_artifacts(&step.artifacts)).set_alignment(CellAlignment::Center),
493 Cell::new(format_optional_datetime(&step.started_at)),
494 Cell::new(format_optional_datetime(&step.completed_at)),
495 ]);
496 }
497
498 table
499}
500
501pub fn role_steps_table(steps: &[StepResponse]) -> Table {
503 let mut table = base_table();
504 table.set_header(vec![
505 "ID", "Run ID", "Step", "Status", "Duration", "Cost", "Created",
506 ]);
507
508 for step in steps {
509 table.add_row(vec![
510 Cell::new(short_id(step.id)),
511 Cell::new(short_id(step.run_id)),
512 Cell::new(&step.name),
513 Cell::new(step.status)
514 .fg(step_status_color(&step.status))
515 .set_alignment(CellAlignment::Center),
516 Cell::new(format_duration_ms(step.duration_ms)),
517 Cell::new(format!("${:.4}", step.cost_usd)),
518 Cell::new(format_datetime(&step.created_at)),
519 ]);
520 }
521
522 table
523}
524
525pub fn agent_role_stats_table(stats: &AgentRoleStatsResponse) -> Table {
527 let mut table = base_table();
528 table.set_header(vec!["Role", "Executions", "Failed", "Cost", "Duration"]);
529
530 for role in &stats.roles {
531 table.add_row(vec![
532 Cell::new(&role.agent_role),
533 Cell::new(role.executions),
534 Cell::new(role.failed).fg(Color::Red),
535 Cell::new(format!("${:.4}", role.total_cost_usd)),
536 Cell::new(format_duration_ms(role.total_duration_ms)),
537 ]);
538 }
539
540 table
541}
542
543pub fn workflows_table(workflows: &[WorkflowSummary]) -> Table {
545 let mut table = base_table();
546 table.set_header(vec!["Name", "Category", "Version", "Paused since"]);
547
548 for wf in workflows {
549 table.add_row(vec![
550 Cell::new(&wf.name),
551 Cell::new(wf.category.as_deref().unwrap_or("-")),
552 Cell::new(wf.version.as_deref().unwrap_or("-")),
553 Cell::new(format_optional_datetime(&wf.paused_at)),
554 ]);
555 }
556
557 table
558}
559
560pub fn workflow_detail_table(detail: &WorkflowDetailResponse) -> Table {
562 let mut table = base_table();
563 table.set_header(vec!["Field", "Value"]);
564
565 table.add_row(vec![Cell::new("Name"), Cell::new(&detail.name)]);
566 table.add_row(vec![
567 Cell::new("Description"),
568 Cell::new(&detail.description),
569 ]);
570 table.add_row(vec![
571 Cell::new("Category"),
572 Cell::new(detail.category.as_deref().unwrap_or("-")),
573 ]);
574 table.add_row(vec![
575 Cell::new("Version"),
576 Cell::new(detail.version.as_deref().unwrap_or("-")),
577 ]);
578 table.add_row(vec![Cell::new("Priority"), Cell::new(detail.priority)]);
579 if let Some(paused_at) = &detail.paused_at {
580 table.add_row(vec![
581 Cell::new("Paused since"),
582 Cell::new(format_datetime(paused_at)),
583 ]);
584 }
585
586 if !detail.sub_workflows.is_empty() {
587 let names: Vec<&str> = detail
588 .sub_workflows
589 .iter()
590 .map(|s| s.name.as_str())
591 .collect();
592 table.add_row(vec![
593 Cell::new("Sub-workflows"),
594 Cell::new(names.join(", ")),
595 ]);
596 }
597
598 table
599}
600
601pub fn execution_plan_tree(plan: &ExecutionPlanResponse) -> String {
619 let mut lines = Vec::new();
620
621 let mut header = format!("workflow {}", plan.workflow);
622 if let Some(total) = plan.estimated_duration_ms {
623 header.push_str(&format!(" estimated ~{}", format_duration_ms(total)));
624 }
625 lines.push(header);
626
627 let mut current_group: Option<&str> = None;
628 for (index, step) in plan.steps.iter().enumerate() {
629 let group = step.parallel_group.as_deref();
630 if group != current_group {
631 if let Some(name) = group {
632 lines.push(format!("{}├─ {name}", indent(depth_of(step))));
633 }
634 current_group = group;
635 }
636
637 let extra = if group.is_some() { " " } else { "" };
638 let branch = if is_last_at_depth(plan, index) {
639 "└─ "
640 } else {
641 "├─ "
642 };
643 lines.push(format!(
644 "{}{extra}{branch}{}",
645 indent(depth_of(step)),
646 step_label(step)
647 ));
648 }
649
650 if plan.truncated {
651 let reason = plan
652 .incomplete_reason
653 .as_deref()
654 .unwrap_or("the plan was cut short");
655 lines.push(format!("plan incomplete: {reason}"));
656 }
657
658 lines.join("\n")
659}
660
661fn indent(depth: usize) -> String {
663 " ".repeat(depth)
664}
665
666fn depth_of(step: &PlannedStepResponse) -> usize {
668 usize::try_from(step.depth).unwrap_or(0)
669}
670
671fn is_last_at_depth(plan: &ExecutionPlanResponse, index: usize) -> bool {
673 let depth = plan.steps[index].depth;
674 !plan.steps[index + 1..].iter().any(|s| s.depth == depth)
675}
676
677fn step_label(step: &PlannedStepResponse) -> String {
679 let mut label = format!("{} [{}]", step.name, step.kind);
680
681 if let Some(ms) = step.estimated_duration_ms {
682 label.push_str(&format!(" ~{}", format_duration_ms(ms)));
683 }
684
685 if let Some(condition) = &step.condition {
686 let suffix = match condition.state.as_str() {
687 "evaluated" => format!(
688 " (when {} = {})",
689 condition.expression.as_deref().unwrap_or("?"),
690 condition.value.unwrap_or(false)
691 ),
692 "skipped" => format!(
693 " (skipped: {})",
694 condition.reason.as_deref().unwrap_or("no reason given")
695 ),
696 _ => format!(
697 " (condition unevaluable: {})",
698 condition.expression.as_deref().unwrap_or("?")
699 ),
700 };
701 label.push_str(&suffix);
702 }
703
704 label
705}
706
707pub fn render_execution_plan<W: Write>(
713 writer: &mut W,
714 json_mode: bool,
715 response: &ApiResponse<ExecutionPlanResponse>,
716) -> Result<()> {
717 if json_mode {
718 let json = to_string_pretty(response)?;
719 writeln!(writer, "{json}")?;
720 } else {
721 writeln!(writer, "{}", execution_plan_tree(&response.data))?;
722 }
723 Ok(())
724}
725
726pub fn stats_table(stats: &StatsResponse) -> Table {
728 let mut table = base_table();
729 table.set_header(vec!["Metric", "Value"]);
730
731 table.add_row(vec![Cell::new("Total runs"), Cell::new(stats.total_runs)]);
732 table.add_row(vec![
733 Cell::new("Completed"),
734 Cell::new(stats.completed_runs).fg(Color::Green),
735 ]);
736 table.add_row(vec![
737 Cell::new("Failed"),
738 Cell::new(stats.failed_runs).fg(Color::Red),
739 ]);
740 table.add_row(vec![
741 Cell::new("Cancelled"),
742 Cell::new(stats.cancelled_runs).fg(Color::Grey),
743 ]);
744 table.add_row(vec![
745 Cell::new("Active"),
746 Cell::new(stats.active_runs).fg(Color::Blue),
747 ]);
748 table.add_row(vec![
749 Cell::new("Awaiting approval"),
750 Cell::new(stats.awaiting_approval_runs).fg(Color::Magenta),
751 ]);
752 table.add_row(vec![
753 Cell::new("Success rate"),
754 Cell::new(format!("{:.1}%", stats.success_rate_percent)),
755 ]);
756 table.add_row(vec![
757 Cell::new("Total cost"),
758 Cell::new(format!("${:.4}", stats.total_cost_usd)),
759 ]);
760 table.add_row(vec![
761 Cell::new("Total duration"),
762 Cell::new(format_duration_ms(stats.total_duration_ms)),
763 ]);
764
765 table
766}
767
768pub fn stats_history_table(history: &StatsHistoryResponse) -> Table {
770 let mut table = base_table();
771 table.set_header(vec![
772 "Time",
773 "Completed",
774 "Warning",
775 "Failed",
776 "Cancelled",
777 "Active",
778 "Success %",
779 "Avg (ms)",
780 "P95 (ms)",
781 "Cost",
782 ]);
783
784 for bucket in &history.buckets {
785 let active = bucket.pending
786 + bucket.running
787 + bucket.retrying
788 + bucket.awaiting_approval
789 + bucket.sleeping
790 + bucket.paused;
791 table.add_row(vec![
792 Cell::new(bucket.time),
793 Cell::new(bucket.completed).fg(Color::Green),
794 Cell::new(bucket.warning).fg(Color::Yellow),
795 Cell::new(bucket.failed).fg(Color::Red),
796 Cell::new(bucket.cancelled).fg(Color::Grey),
797 Cell::new(active).fg(Color::Blue),
798 Cell::new(format_success_rate(bucket.success_rate_percent)),
799 Cell::new(bucket.avg_duration_ms),
800 Cell::new(bucket.p95_duration_ms),
801 Cell::new(format!("${:.4}", bucket.total_cost_usd)),
802 ]);
803 }
804
805 table
806}
807
808fn format_success_rate(rate: Option<f64>) -> String {
810 rate.map_or_else(|| "-".to_string(), |r| format!("{r:.1}%"))
811}
812
813fn format_versions(versions: &[i32]) -> String {
815 if versions.is_empty() {
816 return "-".to_string();
817 }
818 versions
819 .iter()
820 .map(|v| v.to_string())
821 .collect::<Vec<_>>()
822 .join(", ")
823}
824
825#[derive(Debug, Serialize)]
839pub struct Deleted {
840 pub kind: &'static str,
842 pub id: String,
844 pub deleted: bool,
846}
847
848impl Deleted {
849 pub fn new(kind: &'static str, id: impl Into<String>) -> Self {
851 Self {
852 kind,
853 id: id.into(),
854 deleted: true,
855 }
856 }
857}
858
859pub fn deleted_table(deleted: &Deleted) -> Table {
861 let mut table = base_table();
862 table.set_header(vec!["Deleted", "ID"]);
863 table.add_row(vec![Cell::new(deleted.kind), Cell::new(&deleted.id)]);
864 table
865}
866
867pub fn report_deletion(json_mode: bool, kind: &'static str, id: impl Into<String>) -> Result<()> {
884 let deleted = Deleted::new(kind, id);
885 print_output(json_mode, &deleted, || deleted_table(&deleted))
886}
887
888pub fn secrets_table(secrets: &[SecretResponse]) -> Table {
893 let mut table = base_table();
894 table.set_header(vec!["Key", "Created", "Updated"]);
895
896 for secret in secrets {
897 table.add_row(vec![
898 Cell::new(&secret.key),
899 Cell::new(format_datetime(&secret.created_at)),
900 Cell::new(format_datetime(&secret.updated_at)),
901 ]);
902 }
903
904 table
905}
906
907fn window_percent(windows: &[AccountWindowResponse], name: &str) -> String {
909 windows
910 .iter()
911 .find(|w| w.window == name && w.model_scope.is_none())
912 .map_or_else(
913 || "-".to_string(),
914 |w| format!("{:.0}%", w.utilization * 100.0),
915 )
916}
917
918fn account_state_color(state: &AccountState) -> Color {
920 match state {
921 AccountState::Ok => Color::Green,
922 AccountState::NearLimit => Color::Yellow,
923 AccountState::Limited | AccountState::TokenInvalid => Color::Red,
924 AccountState::NeverUsed => Color::Grey,
925 }
926}
927
928pub fn provider_accounts_table(accounts: &[ProviderAccountResponse]) -> Table {
930 let mut table = base_table();
931 table.set_header(vec![
932 "Name", "Kind", "State", "Enabled", "Priority", "Tags", "5h", "7d", "Expires",
933 ]);
934
935 for account in accounts {
936 table.add_row(vec![
937 Cell::new(&account.name),
938 Cell::new(&account.kind),
939 Cell::new(account.state.to_string()).fg(account_state_color(&account.state)),
940 Cell::new(if account.enabled { "yes" } else { "no" }),
941 Cell::new(account.priority).set_alignment(CellAlignment::Right),
942 Cell::new(account.tags.join(", ")),
943 Cell::new(window_percent(&account.windows, "five_hour"))
944 .set_alignment(CellAlignment::Right),
945 Cell::new(window_percent(&account.windows, "seven_day"))
946 .set_alignment(CellAlignment::Right),
947 Cell::new(format_datetime(&account.expires_at)),
948 ]);
949 }
950
951 table
952}
953
954pub fn provider_account_windows_table(windows: &[AccountWindowResponse]) -> Table {
956 let mut table = base_table();
957 table.set_header(vec![
958 "Window", "Scope", "Used", "Status", "Resets", "Observed",
959 ]);
960
961 for window in windows {
962 let color = match window.status {
963 AccountWindowStatus::Allowed => Color::Green,
964 AccountWindowStatus::AllowedWarning => Color::Yellow,
965 AccountWindowStatus::Rejected => Color::Red,
966 };
967 table.add_row(vec![
968 Cell::new(&window.window),
969 Cell::new(window.model_scope.as_deref().unwrap_or("-")),
970 Cell::new(format!("{:.0}%", window.utilization * 100.0))
971 .set_alignment(CellAlignment::Right),
972 Cell::new(window.status.to_string()).fg(color),
973 Cell::new(format_optional_datetime(&window.resets_at)),
974 Cell::new(format_datetime(&window.observed_at)),
975 ]);
976 }
977
978 table
979}
980
981fn format_scopes(scopes: &[ApiKeyScope]) -> String {
983 scopes
984 .iter()
985 .map(ToString::to_string)
986 .collect::<Vec<_>>()
987 .join(", ")
988}
989
990pub fn key_versions_table(status: &KeyVersionsResponse) -> Table {
992 let mut table = base_table();
993 table.set_header(vec!["Property", "Versions"]);
994
995 table.add_row(vec![
996 Cell::new("Active"),
997 Cell::new(status.active).fg(Color::Green),
998 ]);
999 table.add_row(vec![
1000 Cell::new("Configured"),
1001 Cell::new(format_versions(&status.configured)),
1002 ]);
1003 table.add_row(vec![
1004 Cell::new("In use"),
1005 Cell::new(format_versions(&status.in_use)),
1006 ]);
1007 table.add_row(vec![
1008 Cell::new("Missing"),
1009 Cell::new(format_versions(&status.missing)).fg(if status.missing.is_empty() {
1010 Color::Grey
1011 } else {
1012 Color::Red
1013 }),
1014 ]);
1015 table.add_row(vec![
1016 Cell::new("Retirable"),
1017 Cell::new(format_versions(&status.retirable)).fg(if status.retirable.is_empty() {
1018 Color::Grey
1019 } else {
1020 Color::Yellow
1021 }),
1022 ]);
1023
1024 table
1025}
1026
1027pub fn api_keys_table(keys: &[ApiKeyResponse]) -> Table {
1031 let mut table = base_table();
1032 table.set_header(vec![
1033 "ID",
1034 "Name",
1035 "Prefix",
1036 "Scopes",
1037 "Active",
1038 "Rate limit",
1039 "Last used",
1040 "Expires",
1041 "Created",
1042 ]);
1043
1044 for key in keys {
1045 let active = Cell::new(if key.is_active { "yes" } else { "no" })
1046 .fg(if key.is_active {
1047 Color::Green
1048 } else {
1049 Color::Grey
1050 })
1051 .set_alignment(CellAlignment::Center);
1052
1053 let rate_limit = key
1054 .rate_limit_override
1055 .map(|v| v.to_string())
1056 .unwrap_or_else(|| "-".to_string());
1057
1058 table.add_row(vec![
1059 Cell::new(key.id),
1060 Cell::new(&key.name),
1061 Cell::new(&key.key_prefix),
1062 Cell::new(format_scopes(&key.scopes)),
1063 active,
1064 Cell::new(rate_limit),
1065 Cell::new(format_optional_datetime(&key.last_used_at)),
1066 Cell::new(format_optional_datetime(&key.expires_at)),
1067 Cell::new(format_datetime(&key.created_at)),
1068 ]);
1069 }
1070
1071 table
1072}
1073
1074pub fn created_api_key_table(key: &CreateApiKeyResponse) -> Table {
1080 let mut table = base_table();
1081 table.set_header(vec!["Field", "Value"]);
1082
1083 table.add_row(vec![Cell::new("ID"), Cell::new(key.id)]);
1084 table.add_row(vec![Cell::new("Name"), Cell::new(&key.name)]);
1085 table.add_row(vec![
1086 Cell::new("Key"),
1087 Cell::new(&key.key).fg(Color::Yellow),
1088 ]);
1089 table.add_row(vec![Cell::new("Prefix"), Cell::new(&key.key_prefix)]);
1090 table.add_row(vec![
1091 Cell::new("Scopes"),
1092 Cell::new(format_scopes(&key.scopes)),
1093 ]);
1094 if let Some(override_val) = key.rate_limit_override {
1095 table.add_row(vec![
1096 Cell::new("Rate limit"),
1097 Cell::new(format!("{override_val} req/min")),
1098 ]);
1099 }
1100 table.add_row(vec![
1101 Cell::new("Expires"),
1102 Cell::new(format_optional_datetime(&key.expires_at)),
1103 ]);
1104 table.add_row(vec![
1105 Cell::new("Created"),
1106 Cell::new(format_datetime(&key.created_at)),
1107 ]);
1108
1109 table
1110}
1111
1112pub fn scopes_table(scopes: &[ScopeEntry]) -> Table {
1114 let mut table = base_table();
1115 table.set_header(vec!["Value", "Label", "Description"]);
1116
1117 for scope in scopes {
1118 table.add_row(vec![
1119 Cell::new(&scope.value),
1120 Cell::new(&scope.label),
1121 Cell::new(&scope.description),
1122 ]);
1123 }
1124
1125 table
1126}
1127
1128pub fn users_table(users: &[UserResponse]) -> Table {
1130 let mut table = base_table();
1131 table.set_header(vec!["ID", "Username", "Email", "Admin", "Created"]);
1132
1133 for user in users {
1134 let admin = Cell::new(if user.is_admin { "yes" } else { "no" })
1135 .fg(if user.is_admin {
1136 Color::Magenta
1137 } else {
1138 Color::Grey
1139 })
1140 .set_alignment(CellAlignment::Center);
1141
1142 table.add_row(vec![
1143 Cell::new(user.id),
1144 Cell::new(&user.username),
1145 Cell::new(&user.email),
1146 admin,
1147 Cell::new(format_datetime(&user.created_at)),
1148 ]);
1149 }
1150
1151 table
1152}
1153
1154pub fn user_groups_table(resp: &UserGroupsResponse) -> Table {
1156 let mut table = base_table();
1157 table.set_header(vec!["User ID", "Groups"]);
1158
1159 let groups = if resp.groups.is_empty() {
1160 "-".to_string()
1161 } else {
1162 resp.groups.join(", ")
1163 };
1164 table.add_row(vec![Cell::new(resp.user_id), Cell::new(groups)]);
1165
1166 table
1167}
1168
1169pub fn run_diff_table(a: &RunDetailResponse, b: &RunDetailResponse) -> Table {
1171 let (ra, rb) = (&a.run, &b.run);
1172 let mut table = base_table();
1173 table.set_header(vec![
1174 "Field",
1175 &format!("Run {}", short_id(ra.id)),
1176 &format!("Run {}", short_id(rb.id)),
1177 ]);
1178
1179 let row = |f: &str, va: String, vb: String| -> Vec<Cell> {
1180 let hl = va != vb;
1181 vec![
1182 Cell::new(f),
1183 if hl {
1184 Cell::new(&va).fg(Color::Yellow)
1185 } else {
1186 Cell::new(&va)
1187 },
1188 if hl {
1189 Cell::new(&vb).fg(Color::Yellow)
1190 } else {
1191 Cell::new(&vb)
1192 },
1193 ]
1194 };
1195
1196 table.add_row(row("Status", ra.status.to_string(), rb.status.to_string()));
1197 table.add_row(row(
1198 "Duration",
1199 format_duration_ms(ra.duration_ms),
1200 format_duration_ms(rb.duration_ms),
1201 ));
1202 table.add_row(row(
1203 "Cost",
1204 format_cost(ra.cost_usd, ra.max_cost_usd),
1205 format_cost(rb.cost_usd, rb.max_cost_usd),
1206 ));
1207 table.add_row(row(
1208 "Started",
1209 format_optional_datetime(&ra.started_at),
1210 format_optional_datetime(&rb.started_at),
1211 ));
1212 table.add_row(row(
1213 "Completed",
1214 format_optional_datetime(&ra.completed_at),
1215 format_optional_datetime(&rb.completed_at),
1216 ));
1217 table.add_row(row(
1218 "Error",
1219 ra.error.clone().unwrap_or("-".into()),
1220 rb.error.clone().unwrap_or("-".into()),
1221 ));
1222 if a.payload != b.payload {
1223 table.add_row(row(
1224 "Payload",
1225 serde_json::to_string(&a.payload).unwrap_or_default(),
1226 serde_json::to_string(&b.payload).unwrap_or_default(),
1227 ));
1228 }
1229 for i in 0..a.steps.len().max(b.steps.len()) {
1230 let (sa, sb) = (a.steps.get(i), b.steps.get(i));
1231 let name = sa.or(sb).map(|s| s.name.as_str()).unwrap_or("-");
1232 table.add_row(row(
1233 &format!("{name} status"),
1234 sa.map(|s| s.status.to_string()).unwrap_or("-".into()),
1235 sb.map(|s| s.status.to_string()).unwrap_or("-".into()),
1236 ));
1237 table.add_row(row(
1238 &format!("{name} duration"),
1239 sa.map(|s| format_duration_ms(s.duration_ms))
1240 .unwrap_or("-".into()),
1241 sb.map(|s| format_duration_ms(s.duration_ms))
1242 .unwrap_or("-".into()),
1243 ));
1244 table.add_row(row(
1245 &format!("{name} cost"),
1246 sa.map(|s| format!("${:.4}", s.cost_usd))
1247 .unwrap_or("-".into()),
1248 sb.map(|s| format!("${:.4}", s.cost_usd))
1249 .unwrap_or("-".into()),
1250 ));
1251 }
1252 table
1253}
1254
1255fn short_id(id: Uuid) -> String {
1257 id.to_string()
1258 .split('-')
1259 .next()
1260 .unwrap_or_default()
1261 .to_string()
1262}
1263
1264fn format_optional_id(id: &Option<Uuid>) -> String {
1266 id.map_or_else(|| "-".to_string(), short_id)
1267}
1268
1269pub fn audit_logs_table(entries: &[AuditLogEntry]) -> Table {
1274 let mut table = base_table();
1275 table.set_header(vec!["ID", "Type", "Run", "Step", "User", "Created"]);
1276
1277 for entry in entries {
1278 table.add_row(vec![
1279 Cell::new(short_id(entry.id)),
1280 Cell::new(entry.event_type.to_string()),
1281 Cell::new(format_optional_id(&entry.run_id)),
1282 Cell::new(format_optional_id(&entry.step_id)),
1283 Cell::new(format_optional_id(&entry.user_id)),
1284 Cell::new(format_datetime(&entry.created_at)),
1285 ]);
1286 }
1287
1288 table
1289}
1290
1291#[cfg(test)]
1292mod tests {
1293 use std::collections::HashMap;
1294 use std::slice;
1295
1296 use ironflow_sdk::types::{
1297 ApiKeyScope, ConditionResponse, CreatedBy, CreatedByKind, EventKind, TriggerKind,
1298 };
1299 use serde_json::{Map, Value, from_value, json};
1300
1301 use super::*;
1302
1303 fn run_fixture(created_by: CreatedBy) -> RunResponse {
1305 let now = Utc::now();
1306 RunResponse {
1307 id: Uuid::now_v7(),
1308 workflow_name: "deploy".to_string(),
1309 status: RunStatus::Completed,
1310 trigger: TriggerKind::Api,
1311 error: None,
1312 retry_count: 0,
1313 max_retries: 0,
1314 lease_recoveries: 0,
1315 cost_usd: 0.0,
1316 duration_ms: 0,
1317 created_at: now,
1318 updated_at: now,
1319 started_at: None,
1320 completed_at: None,
1321 handler_version: None,
1322 labels: HashMap::new(),
1323 scheduled_at: None,
1324 capacity_wait_kind: None,
1325 resume_status: None,
1326 created_by,
1327 idempotency_key: None,
1328 concurrency_key: None,
1329 priority: Some(0),
1330 concurrency_limits: Vec::new(),
1331 max_cost_usd: None,
1332 output: None,
1333 worker_tags: Vec::new(),
1334 }
1335 }
1336
1337 #[test]
1338 fn format_success_rate_renders_dash_when_absent() {
1339 assert_eq!(format_success_rate(None), "-");
1340 }
1341
1342 #[test]
1343 fn format_success_rate_renders_one_decimal() {
1344 assert_eq!(format_success_rate(Some(100.0)), "100.0%");
1345 assert_eq!(format_success_rate(Some(200.0 / 3.0)), "66.7%");
1346 assert_eq!(format_success_rate(Some(0.0)), "0.0%");
1347 }
1348
1349 #[test]
1350 fn format_cost_without_cap_shows_amount_only() {
1351 assert_eq!(format_cost(0.1234, None), "$0.1234");
1352 }
1353
1354 #[test]
1355 fn format_cost_with_cap_shows_both_amounts() {
1356 assert_eq!(format_cost(0.18, Some(2.0)), "$0.1800 / $2.00");
1357 }
1358
1359 #[test]
1360 fn cost_color_is_absent_without_a_cap() {
1361 assert_eq!(cost_color(999.0, None), None);
1362 }
1363
1364 #[test]
1365 fn cost_color_warns_past_the_threshold_and_alerts_at_the_cap() {
1366 assert_eq!(cost_color(1.0, Some(2.0)), None); assert_eq!(cost_color(1.6, Some(2.0)), Some(Color::Yellow)); assert_eq!(cost_color(1.99, Some(2.0)), Some(Color::Yellow));
1369 assert_eq!(cost_color(2.0, Some(2.0)), Some(Color::Red)); assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); }
1372
1373 #[test]
1374 fn cost_color_handles_a_zero_cap() {
1375 assert_eq!(cost_color(0.0, Some(0.0)), None);
1376 assert_eq!(cost_color(0.01, Some(0.0)), Some(Color::Red));
1377 }
1378
1379 fn artifact(name: &str, size_bytes: i64) -> ArtifactResponse {
1380 ArtifactResponse {
1381 id: Uuid::now_v7(),
1382 step_id: Uuid::now_v7(),
1383 name: name.to_string(),
1384 content_type: "text/plain".to_string(),
1385 size_bytes,
1386 sha256: "0".repeat(64),
1387 created_at: Utc::now(),
1388 }
1389 }
1390
1391 #[test]
1392 fn format_bytes_keeps_raw_bytes_below_one_kilobyte() {
1393 assert_eq!(format_bytes(0), "0 B");
1394 assert_eq!(format_bytes(1023), "1023 B");
1395 }
1396
1397 #[test]
1398 fn format_bytes_switches_units_at_each_boundary() {
1399 assert_eq!(format_bytes(1024), "1.0 KB");
1400 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
1401 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
1402 }
1403
1404 #[test]
1405 fn format_bytes_drops_the_decimal_past_ten() {
1406 assert_eq!(format_bytes(145_408), "142 KB");
1407 }
1408
1409 #[test]
1410 fn format_artifacts_shows_a_dash_when_there_are_none() {
1411 assert_eq!(format_artifacts(&[]), "-");
1412 }
1413
1414 #[test]
1415 fn format_artifacts_shows_the_count_and_total_size() {
1416 let artifacts = vec![artifact("a.txt", 1024), artifact("b.txt", 1024)];
1417 assert_eq!(format_artifacts(&artifacts), "2 (2.0 KB)");
1418 }
1419
1420 #[test]
1421 fn format_duration_ms_millis() {
1422 assert_eq!(format_duration_ms(500), "500ms");
1423 assert_eq!(format_duration_ms(0), "0ms");
1424 }
1425
1426 #[test]
1427 fn format_duration_ms_seconds() {
1428 assert_eq!(format_duration_ms(5000), "5s");
1429 assert_eq!(format_duration_ms(59000), "59s");
1430 }
1431
1432 #[test]
1433 fn format_duration_ms_minutes() {
1434 assert_eq!(format_duration_ms(60000), "1m 0s");
1435 assert_eq!(format_duration_ms(125000), "2m 5s");
1436 }
1437
1438 #[test]
1439 fn format_duration_ms_hours() {
1440 assert_eq!(format_duration_ms(3_600_000), "1h 0m");
1441 assert_eq!(format_duration_ms(5_400_000), "1h 30m");
1442 }
1443
1444 #[test]
1445 fn format_sla_without_a_deadline_is_a_dash() {
1446 assert_eq!(format_remaining_secs(None), "-");
1447 }
1448
1449 #[test]
1450 fn format_sla_reports_an_elapsed_deadline_as_expired() {
1451 assert_eq!(format_remaining_secs(Some(0)), "expired");
1452 assert_eq!(format_remaining_secs(Some(-30)), "expired");
1453 }
1454
1455 #[test]
1456 fn format_sla_uses_coarse_units() {
1457 assert_eq!(format_remaining_secs(Some(45)), "45s");
1458 assert_eq!(format_remaining_secs(Some(59)), "59s");
1459 assert_eq!(format_remaining_secs(Some(60)), "1m");
1460 assert_eq!(format_remaining_secs(Some(750)), "12m 30s");
1461 assert_eq!(format_remaining_secs(Some(3599)), "59m 59s");
1462 assert_eq!(format_remaining_secs(Some(3600)), "1h");
1463 assert_eq!(format_remaining_secs(Some(4320)), "1h 12m");
1464 }
1465
1466 #[test]
1467 fn sla_has_no_colour_without_a_deadline() {
1468 assert_eq!(remaining_color(None, None), None);
1469 assert_eq!(remaining_color(None, Some(3600)), None);
1470 }
1471
1472 #[test]
1473 fn sla_turns_red_once_expired() {
1474 assert_eq!(remaining_color(Some(0), Some(3600)), Some(Color::Red));
1475 assert_eq!(remaining_color(Some(-1), None), Some(Color::Red));
1476 }
1477
1478 #[test]
1479 fn sla_turns_yellow_in_the_last_tenth_of_the_window() {
1480 assert_eq!(remaining_color(Some(359), Some(3600)), Some(Color::Yellow));
1481 assert_eq!(remaining_color(Some(360), Some(3600)), None);
1482 assert_eq!(remaining_color(Some(3000), Some(3600)), None);
1483 }
1484
1485 #[test]
1486 fn sla_has_no_colour_without_a_measurable_window() {
1487 assert_eq!(remaining_color(Some(120), None), None);
1488 assert_eq!(remaining_color(Some(120), Some(0)), None);
1489 }
1490
1491 #[test]
1492 fn format_optional_datetime_none() {
1493 assert_eq!(format_optional_datetime(&None), "-");
1494 }
1495
1496 #[test]
1497 fn format_optional_datetime_some() {
1498 let dt = "2026-06-02T14:30:00Z".parse::<DateTime<Utc>>().unwrap();
1499 assert_eq!(format_optional_datetime(&Some(dt)), "2026-06-02 14:30:00");
1500 }
1501
1502 #[test]
1503 fn status_colors_are_distinct() {
1504 let statuses = [
1505 RunStatus::Completed,
1506 RunStatus::Failed,
1507 RunStatus::Running,
1508 RunStatus::Pending,
1509 RunStatus::Cancelled,
1510 RunStatus::AwaitingApproval,
1511 RunStatus::Retrying,
1512 ];
1513
1514 let colors: Vec<Color> = statuses.iter().map(status_color).collect();
1515 for (i, c1) in colors.iter().enumerate() {
1516 for (j, c2) in colors.iter().enumerate() {
1517 if i != j {
1518 assert_ne!(c1, c2, "status colors must be distinct");
1519 }
1520 }
1521 }
1522 }
1523
1524 #[test]
1525 fn empty_runs_table_has_header() {
1526 let table = runs_table(&[]);
1527 let output = table.to_string();
1528 assert!(output.contains("ID"));
1529 assert!(output.contains("Workflow"));
1530 assert!(output.contains("Status"));
1531 assert!(output.contains("Triggered by"));
1532 assert!(output.contains("Priority"));
1533 }
1534
1535 #[test]
1536 fn runs_table_renders_the_author_label() {
1537 let run = run_fixture(CreatedBy {
1538 kind: CreatedByKind::ApiKey,
1539 id: Some(Uuid::now_v7()),
1540 label: "ci-deploy (alice)".to_string(),
1541 });
1542
1543 let output = runs_table(slice::from_ref(&run)).to_string();
1544 assert!(
1545 output.contains("ci-deploy (alice)"),
1546 "author missing from:\n{output}"
1547 );
1548 }
1549
1550 #[test]
1551 fn format_priority_renders_the_value_or_a_dash() {
1552 assert_eq!(format_priority(Some(-40)), "-40");
1553 assert_eq!(format_priority(Some(0)), "0");
1554 assert_eq!(format_priority(None), "-");
1555 }
1556
1557 #[test]
1558 fn runs_table_renders_the_priority() {
1559 let mut run = run_fixture(CreatedBy {
1560 kind: CreatedByKind::System,
1561 id: None,
1562 label: "api".to_string(),
1563 });
1564 run.priority = Some(-73);
1565
1566 let output = runs_table(slice::from_ref(&run)).to_string();
1567 assert!(output.contains("-73"), "priority missing from:\n{output}");
1568 }
1569
1570 #[test]
1571 fn run_detail_table_shows_the_priority() {
1572 let mut run = run_fixture(CreatedBy {
1573 kind: CreatedByKind::System,
1574 id: None,
1575 label: "api".to_string(),
1576 });
1577 run.priority = Some(64);
1578 let detail = RunDetailResponse {
1579 run,
1580 steps: Vec::new(),
1581 payload: Value::Object(Map::new()),
1582 active_descendant_count: 0,
1583 worker_routing: None,
1584 };
1585
1586 let output = run_detail_table(&detail).to_string();
1587 assert!(output.contains("Priority"), "row missing from:\n{output}");
1588 assert!(output.contains("64"), "priority missing from:\n{output}");
1589 }
1590
1591 fn detail_with_lease_recoveries(lease_recoveries: i32) -> RunDetailResponse {
1592 let mut run = run_fixture(CreatedBy {
1593 kind: CreatedByKind::System,
1594 id: None,
1595 label: "api".to_string(),
1596 });
1597 run.lease_recoveries = lease_recoveries;
1598 RunDetailResponse {
1599 run,
1600 steps: Vec::new(),
1601 payload: Value::Object(Map::new()),
1602 active_descendant_count: 0,
1603 worker_routing: None,
1604 }
1605 }
1606
1607 #[test]
1608 fn run_detail_table_shows_the_lease_recoveries() {
1609 let output = run_detail_table(&detail_with_lease_recoveries(2)).to_string();
1610 let row = output
1611 .lines()
1612 .find(|line| line.contains("Lease recoveries"))
1613 .unwrap_or_else(|| panic!("row missing from:\n{output}"));
1614 assert!(row.contains('2'), "value missing from row: {row}");
1615 }
1616
1617 #[test]
1618 fn run_detail_table_shows_zero_lease_recoveries() {
1619 let output = run_detail_table(&detail_with_lease_recoveries(0)).to_string();
1620 let row = output
1621 .lines()
1622 .find(|line| line.contains("Lease recoveries"))
1623 .unwrap_or_else(|| panic!("row missing from:\n{output}"));
1624 assert!(row.contains('0'), "value missing from row: {row}");
1625 }
1626
1627 #[test]
1628 fn workflow_detail_table_shows_the_priority() {
1629 let detail: WorkflowDetailResponse = from_value(json!({
1630 "name": "prio-workflow",
1631 "description": "A workflow",
1632 "source_code": null,
1633 "sub_workflows": [],
1634 "category": null,
1635 "version": null,
1636 "priority": -40,
1637 }))
1638 .unwrap();
1639
1640 let output = workflow_detail_table(&detail).to_string();
1641 assert!(output.contains("Priority"), "row missing from:\n{output}");
1642 assert!(output.contains("-40"), "priority missing from:\n{output}");
1643 }
1644
1645 #[test]
1646 fn run_detail_table_renders_the_run_output() {
1647 let mut run = run_fixture(CreatedBy {
1648 kind: CreatedByKind::System,
1649 id: None,
1650 label: "cron".to_string(),
1651 });
1652 run.output = Some(json!({"verdict": "approved"}));
1653 let detail = RunDetailResponse {
1654 run,
1655 steps: Vec::new(),
1656 payload: Value::Object(Map::new()),
1657 active_descendant_count: 0,
1658 worker_routing: None,
1659 };
1660
1661 let output = run_detail_table(&detail).to_string();
1662 assert!(
1663 output.contains("Output"),
1664 "output row missing from:\n{output}"
1665 );
1666 assert!(output.contains(r#"{"verdict":"approved"}"#), "{output}");
1667 }
1668
1669 #[test]
1670 fn run_detail_table_has_no_output_row_without_an_output() {
1671 let detail = RunDetailResponse {
1672 run: run_fixture(CreatedBy {
1673 kind: CreatedByKind::System,
1674 id: None,
1675 label: "cron".to_string(),
1676 }),
1677 steps: Vec::new(),
1678 payload: Value::Object(Map::new()),
1679 active_descendant_count: 0,
1680 worker_routing: None,
1681 };
1682
1683 let output = run_detail_table(&detail).to_string();
1684 assert!(!output.contains("Output"), "{output}");
1685 }
1686
1687 #[test]
1688 fn run_detail_table_renders_the_author_label() {
1689 let detail = RunDetailResponse {
1690 run: run_fixture(CreatedBy {
1691 kind: CreatedByKind::System,
1692 id: None,
1693 label: "/hooks/github".to_string(),
1694 }),
1695 steps: Vec::new(),
1696 payload: Value::Object(Map::new()),
1697 active_descendant_count: 0,
1698 worker_routing: None,
1699 };
1700
1701 let output = run_detail_table(&detail).to_string();
1702 assert!(output.contains("Triggered by"));
1703 assert!(
1704 output.contains("/hooks/github"),
1705 "author missing from:\n{output}"
1706 );
1707 }
1708
1709 #[test]
1710 fn format_concurrency_limits_lists_each_group_with_its_limit() {
1711 let limits = [
1712 ConcurrencyLimit {
1713 group: "repo:acme".to_string(),
1714 limit: 2,
1715 },
1716 ConcurrencyLimit {
1717 group: "tenant:42".to_string(),
1718 limit: 1,
1719 },
1720 ];
1721 assert_eq!(
1722 format_concurrency_limits(&limits),
1723 "repo:acme (2), tenant:42 (1)"
1724 );
1725 }
1726
1727 #[test]
1728 fn run_detail_table_shows_concurrency_groups_only_when_present() {
1729 let mut detail = RunDetailResponse {
1730 run: run_fixture(CreatedBy {
1731 kind: CreatedByKind::System,
1732 id: None,
1733 label: "api".to_string(),
1734 }),
1735 steps: Vec::new(),
1736 payload: Value::Object(Map::new()),
1737 active_descendant_count: 0,
1738 worker_routing: None,
1739 };
1740 let output = run_detail_table(&detail).to_string();
1741 assert!(
1742 !output.contains("Concurrency groups"),
1743 "unexpected row in:\n{output}"
1744 );
1745
1746 detail.run.concurrency_limits = vec![ConcurrencyLimit {
1747 group: "repo:acme".to_string(),
1748 limit: 2,
1749 }];
1750 let output = run_detail_table(&detail).to_string();
1751 assert!(
1752 output.contains("Concurrency groups"),
1753 "row missing from:\n{output}"
1754 );
1755 assert!(
1756 output.contains("repo:acme (2)"),
1757 "group missing from:\n{output}"
1758 );
1759 }
1760
1761 #[test]
1762 fn run_detail_table_shows_the_capacity_wait_only_when_waiting() {
1763 let mut detail = RunDetailResponse {
1764 run: run_fixture(CreatedBy {
1765 kind: CreatedByKind::System,
1766 id: None,
1767 label: "api".to_string(),
1768 }),
1769 steps: Vec::new(),
1770 payload: Value::Object(Map::new()),
1771 active_descendant_count: 0,
1772 worker_routing: None,
1773 };
1774 let output = run_detail_table(&detail).to_string();
1775 assert!(
1776 !output.contains("Waiting for capacity"),
1777 "unexpected row in:\n{output}"
1778 );
1779
1780 let wake_at = Utc::now();
1781 detail.run.status = RunStatus::Sleeping;
1782 detail.run.scheduled_at = Some(wake_at);
1783 detail.run.capacity_wait_kind = Some("claude_subscription".to_string());
1784 let output = run_detail_table(&detail).to_string();
1785 assert!(
1786 output.contains("Waiting for capacity"),
1787 "row missing from:\n{output}"
1788 );
1789 let expected = format!(
1790 "claude_subscription, resumes at {}",
1791 format_datetime(&wake_at)
1792 );
1793 assert!(
1794 output.contains(&expected),
1795 "{expected} missing from:\n{output}"
1796 );
1797 }
1798
1799 #[test]
1800 fn routing_warning_covers_each_case() {
1801 let none_seen = WorkerRouting {
1802 seen_workers: 0,
1803 eligible_workers: 0,
1804 };
1805 let warning = routing_warning(&none_seen);
1806 assert_eq!(warning, Some("No worker seen recently"));
1807
1808 let none_eligible = WorkerRouting {
1809 seen_workers: 3,
1810 eligible_workers: 0,
1811 };
1812 let warning = routing_warning(&none_eligible).expect("a warning");
1813 assert!(warning.starts_with("No eligible worker seen"), "{warning}");
1814
1815 let eligible = WorkerRouting {
1816 seen_workers: 3,
1817 eligible_workers: 1,
1818 };
1819 assert_eq!(routing_warning(&eligible), None);
1820 }
1821
1822 #[test]
1823 fn run_detail_table_shows_worker_tags_only_when_present() {
1824 let mut detail = RunDetailResponse {
1825 run: run_fixture(CreatedBy {
1826 kind: CreatedByKind::System,
1827 id: None,
1828 label: "api".to_string(),
1829 }),
1830 steps: Vec::new(),
1831 payload: Value::Object(Map::new()),
1832 active_descendant_count: 0,
1833 worker_routing: None,
1834 };
1835 let output = run_detail_table(&detail).to_string();
1836 assert!(
1837 !output.contains("Worker tags"),
1838 "unexpected row in:\n{output}"
1839 );
1840
1841 detail.run.worker_tags = vec!["gpu".to_string(), "region:eu".to_string()];
1842 let output = run_detail_table(&detail).to_string();
1843 assert!(
1844 output.contains("Worker tags"),
1845 "row missing from:\n{output}"
1846 );
1847 assert!(
1848 output.contains("gpu, region:eu"),
1849 "tags missing from:\n{output}"
1850 );
1851 }
1852
1853 #[test]
1854 fn run_detail_table_warns_when_no_worker_can_take_the_run() {
1855 let mut detail = RunDetailResponse {
1856 run: run_fixture(CreatedBy {
1857 kind: CreatedByKind::System,
1858 id: None,
1859 label: "api".to_string(),
1860 }),
1861 steps: Vec::new(),
1862 payload: Value::Object(Map::new()),
1863 active_descendant_count: 0,
1864 worker_routing: None,
1865 };
1866 let output = run_detail_table(&detail).to_string();
1867 assert!(!output.contains("Workers"), "unexpected row in:\n{output}");
1868
1869 detail.worker_routing = Some(WorkerRouting {
1870 seen_workers: 2,
1871 eligible_workers: 1,
1872 });
1873 let output = run_detail_table(&detail).to_string();
1874 assert!(!output.contains("Workers"), "unexpected row in:\n{output}");
1875
1876 detail.worker_routing = Some(WorkerRouting {
1877 seen_workers: 2,
1878 eligible_workers: 0,
1879 });
1880 let output = run_detail_table(&detail).to_string();
1881 assert!(output.contains("Workers"), "row missing from:\n{output}");
1882 assert!(
1883 output.contains("No eligible worker seen"),
1884 "warning missing from:\n{output}"
1885 );
1886 }
1887
1888 #[test]
1889 fn empty_workflows_table_has_header() {
1890 let table = workflows_table(&[]);
1891 let output = table.to_string();
1892 assert!(output.contains("Name"));
1893 assert!(output.contains("Category"));
1894 assert!(output.contains("Paused since"));
1895 }
1896
1897 fn secret_fixture(key: &str) -> SecretResponse {
1900 let now = Utc::now();
1901 SecretResponse {
1902 id: Uuid::now_v7(),
1903 key: key.to_string(),
1904 created_at: now,
1905 updated_at: now,
1906 }
1907 }
1908
1909 #[test]
1910 fn empty_secrets_table_has_header() {
1911 let output = secrets_table(&[]).to_string();
1912 assert!(output.contains("Key"));
1913 assert!(output.contains("Created"));
1914 assert!(output.contains("Updated"));
1915 }
1916
1917 #[test]
1918 fn secrets_table_renders_the_key() {
1919 let secret = secret_fixture("workflows/inbox/gmail_token");
1920 let output = secrets_table(slice::from_ref(&secret)).to_string();
1921 assert!(output.contains("workflows/inbox/gmail_token"), "{output}");
1922 }
1923
1924 #[test]
1927 fn a_secret_response_carries_no_value_at_all() {
1928 let secret = secret_fixture("db/password");
1929 let json = serde_json::to_string(&secret).unwrap();
1930 assert!(!json.contains("value"), "{json}");
1931 }
1932
1933 fn api_key_fixture() -> ApiKeyResponse {
1936 ApiKeyResponse {
1937 id: Uuid::now_v7(),
1938 name: "ci-deploy".to_string(),
1939 key_prefix: "ifk_abcd".to_string(),
1940 scopes: vec![ApiKeyScope::RunsRead, ApiKeyScope::RunsWrite],
1941 is_active: true,
1942 created_at: Utc::now(),
1943 expires_at: None,
1944 last_used_at: None,
1945 rate_limit_override: None,
1946 }
1947 }
1948
1949 #[test]
1950 fn empty_api_keys_table_has_header() {
1951 let output = api_keys_table(&[]).to_string();
1952 for header in ["ID", "Name", "Prefix", "Scopes", "Active"] {
1953 assert!(output.contains(header), "missing {header} in {output}");
1954 }
1955 }
1956
1957 #[test]
1958 fn api_keys_table_joins_the_scopes() {
1959 let key = api_key_fixture();
1960 let output = api_keys_table(slice::from_ref(&key)).to_string();
1961 assert!(output.contains("runs_read, runs_write"), "{output}");
1962 assert!(output.contains("ifk_abcd"), "{output}");
1963 }
1964
1965 #[test]
1966 fn created_api_key_table_shows_the_raw_key() {
1967 let created = CreateApiKeyResponse {
1968 id: Uuid::now_v7(),
1969 name: "ci-deploy".to_string(),
1970 key: "ifk_full_raw_key".to_string(),
1971 key_prefix: "ifk_full".to_string(),
1972 scopes: vec![ApiKeyScope::Admin],
1973 created_at: Utc::now(),
1974 expires_at: None,
1975 rate_limit_override: None,
1976 };
1977
1978 let output = created_api_key_table(&created).to_string();
1979 assert!(output.contains("ifk_full_raw_key"), "{output}");
1980 }
1981
1982 #[test]
1983 fn empty_scopes_table_has_header() {
1984 let output = scopes_table(&[]).to_string();
1985 assert!(output.contains("Value"));
1986 assert!(output.contains("Description"));
1987 }
1988
1989 fn user_fixture(is_admin: bool) -> UserResponse {
1992 let now = Utc::now();
1993 UserResponse {
1994 id: Uuid::now_v7(),
1995 username: "alice".to_string(),
1996 email: "alice@example.com".to_string(),
1997 is_admin,
1998 created_at: now,
1999 updated_at: now,
2000 }
2001 }
2002
2003 #[test]
2004 fn empty_users_table_has_header() {
2005 let output = users_table(&[]).to_string();
2006 for header in ["ID", "Username", "Email", "Admin", "Created"] {
2007 assert!(output.contains(header), "missing {header} in {output}");
2008 }
2009 }
2010
2011 #[test]
2012 fn users_table_spells_out_the_role() {
2013 let admin = user_fixture(true);
2014 assert!(
2015 users_table(slice::from_ref(&admin))
2016 .to_string()
2017 .contains("yes")
2018 );
2019
2020 let member = user_fixture(false);
2021 assert!(
2022 users_table(slice::from_ref(&member))
2023 .to_string()
2024 .contains("no")
2025 );
2026 }
2027
2028 #[test]
2029 fn user_groups_table_has_header_and_lists_the_groups() {
2030 let resp = UserGroupsResponse {
2031 user_id: Uuid::now_v7(),
2032 groups: vec!["finance".to_string(), "sre".to_string()],
2033 };
2034 let output = user_groups_table(&resp).to_string();
2035 for header in ["User ID", "Groups"] {
2036 assert!(output.contains(header), "missing {header} in {output}");
2037 }
2038 assert!(output.contains(&resp.user_id.to_string()), "{output}");
2039 assert!(output.contains("finance, sre"), "{output}");
2040 }
2041
2042 #[test]
2043 fn user_groups_table_shows_a_dash_without_groups() {
2044 let resp = UserGroupsResponse {
2045 user_id: Uuid::now_v7(),
2046 groups: Vec::new(),
2047 };
2048 let output = user_groups_table(&resp).to_string();
2049 assert!(output.contains("Groups"), "{output}");
2050 assert!(output.contains(" - "), "{output}");
2051 assert!(!output.contains("finance"), "{output}");
2052 }
2053
2054 #[test]
2057 fn empty_audit_logs_table_has_header() {
2058 let output = audit_logs_table(&[]).to_string();
2059 for header in ["ID", "Type", "Run", "Step", "User", "Created"] {
2060 assert!(output.contains(header), "missing {header} in {output}");
2061 }
2062 }
2063
2064 #[test]
2065 fn audit_logs_table_omits_the_payload() {
2066 let entry = AuditLogEntry {
2067 id: Uuid::now_v7(),
2068 event_type: EventKind::RunCreated,
2069 payload: Value::Object(Map::new()),
2070 run_id: Some(Uuid::now_v7()),
2071 step_id: None,
2072 user_id: None,
2073 created_at: Utc::now(),
2074 };
2075
2076 let output = audit_logs_table(slice::from_ref(&entry)).to_string();
2077 assert!(output.contains("run_created"), "{output}");
2078 assert!(output.contains(" - "), "{output}");
2080 }
2081
2082 #[test]
2083 fn format_optional_id_shortens_and_falls_back() {
2084 assert_eq!(format_optional_id(&None), "-");
2085 let id = Uuid::now_v7();
2086 let short = format_optional_id(&Some(id));
2087 assert_eq!(short, id.to_string().split('-').next().unwrap());
2088 }
2089
2090 #[test]
2093 fn deleted_table_reports_the_kind_and_id() {
2094 let deleted = Deleted::new("secret", "db/password");
2095 let output = deleted_table(&deleted).to_string();
2096 assert!(output.contains("secret"), "{output}");
2097 assert!(output.contains("db/password"), "{output}");
2098
2099 let json = serde_json::to_string(&deleted).unwrap();
2100 assert!(json.contains(r#""deleted":true"#), "{json}");
2101 }
2102
2103 fn planned_step(name: &str, kind: &str, parallel_group: Option<&str>) -> PlannedStepResponse {
2106 PlannedStepResponse {
2107 name: name.to_string(),
2108 kind: kind.to_string(),
2109 workflow: "deploy".to_string(),
2110 depth: 0,
2111 depends_on: Vec::new(),
2112 condition: None,
2113 parallel_group: parallel_group.map(str::to_string),
2114 estimated_duration_ms: None,
2115 }
2116 }
2117
2118 fn plan_fixture(steps: Vec<PlannedStepResponse>) -> ExecutionPlanResponse {
2119 ExecutionPlanResponse {
2120 workflow: "deploy".to_string(),
2121 steps,
2122 estimated_duration_ms: None,
2123 max_depth: 3,
2124 truncated: false,
2125 incomplete_reason: None,
2126 }
2127 }
2128
2129 #[test]
2130 fn execution_plan_tree_lists_step_names_and_kinds() {
2131 let plan = plan_fixture(vec![
2132 planned_step("build", "shell", None),
2133 planned_step("deploy", "shell", None),
2134 ]);
2135
2136 let output = execution_plan_tree(&plan);
2137 assert!(output.contains("workflow deploy"), "{output}");
2138 assert!(output.contains("build [shell]"), "{output}");
2139 assert!(output.contains("deploy [shell]"), "{output}");
2140 }
2141
2142 #[test]
2143 fn execution_plan_tree_prints_a_parallel_group_header_once() {
2144 let plan = plan_fixture(vec![
2145 planned_step("build", "shell", None),
2146 planned_step("test", "shell", Some("parallel-1")),
2147 planned_step("lint", "shell", Some("parallel-1")),
2148 ]);
2149
2150 let output = execution_plan_tree(&plan);
2151 assert_eq!(output.matches("parallel-1").count(), 1, "{output}");
2152 }
2153
2154 #[test]
2155 fn execution_plan_tree_shows_the_estimate_when_present() {
2156 let mut step = planned_step("build", "shell", None);
2157 step.estimated_duration_ms = Some(5000);
2158 let mut plan = plan_fixture(vec![step]);
2159 plan.estimated_duration_ms = Some(5000);
2160
2161 let output = execution_plan_tree(&plan);
2162 assert!(output.contains("estimated ~5s"), "{output}");
2163 assert!(output.contains("build [shell] ~5s"), "{output}");
2164 }
2165
2166 #[test]
2167 fn execution_plan_tree_marks_conditions() {
2168 let mut evaluated = planned_step("deploy-prod", "shell", None);
2169 evaluated.condition = Some(ConditionResponse {
2170 state: "evaluated".to_string(),
2171 expression: Some("env == prod".to_string()),
2172 value: Some(true),
2173 reason: None,
2174 });
2175 let mut skipped = planned_step("deploy-dev", "skip", None);
2176 skipped.condition = Some(ConditionResponse {
2177 state: "skipped".to_string(),
2178 expression: None,
2179 value: None,
2180 reason: Some("not prod".to_string()),
2181 });
2182 let mut unevaluable = planned_step("notify", "http", None);
2183 unevaluable.condition = Some(ConditionResponse {
2184 state: "unevaluable".to_string(),
2185 expression: Some("build succeeded".to_string()),
2186 value: None,
2187 reason: Some("depends on a step output".to_string()),
2188 });
2189
2190 let output = execution_plan_tree(&plan_fixture(vec![evaluated, skipped, unevaluable]));
2191 assert!(output.contains("(when env == prod = true)"), "{output}");
2192 assert!(output.contains("(skipped: not prod)"), "{output}");
2193 assert!(
2194 output.contains("(condition unevaluable: build succeeded)"),
2195 "{output}"
2196 );
2197 }
2198
2199 #[test]
2200 fn execution_plan_tree_reports_an_incomplete_plan() {
2201 let mut plan = plan_fixture(vec![planned_step("build", "shell", None)]);
2202 plan.truncated = true;
2203 plan.incomplete_reason = Some("step cap of 1000 reached".to_string());
2204
2205 let output = execution_plan_tree(&plan);
2206 assert!(
2207 output.contains("plan incomplete: step cap of 1000 reached"),
2208 "{output}"
2209 );
2210 }
2211
2212 #[test]
2213 fn execution_plan_tree_indents_sub_workflow_steps() {
2214 let mut nested = planned_step("child-step", "shell", None);
2215 nested.depth = 1;
2216 let plan = plan_fixture(vec![planned_step("child", "workflow", None), nested]);
2217
2218 let output = execution_plan_tree(&plan);
2219 let nested = output
2220 .lines()
2221 .find(|l| l.contains("child-step"))
2222 .expect("nested line");
2223 assert!(nested.starts_with(" "), "{nested}");
2224 }
2225
2226 fn agent_step(agent_role: Option<&str>) -> StepResponse {
2228 from_value(json!({
2229 "id": Uuid::now_v7(),
2230 "trace_id": Uuid::now_v7(),
2231 "run_id": Uuid::now_v7(),
2232 "name": "review",
2233 "kind": "agent",
2234 "position": 0,
2235 "status": "completed",
2236 "attempt": 1,
2237 "duration_ms": 1500,
2238 "cost_usd": 0.25,
2239 "created_at": "2026-10-27T12:00:00Z",
2240 "updated_at": "2026-10-27T12:00:01Z",
2241 "dependencies": [],
2242 "agent_role": agent_role,
2243 }))
2244 .expect("valid StepResponse")
2245 }
2246
2247 #[test]
2248 fn steps_table_shows_the_role_of_each_step() {
2249 let output = steps_table(&[agent_step(Some("Reviewer"))]).to_string();
2250 assert!(output.contains("Role"), "{output}");
2251 assert!(output.contains("Reviewer"), "{output}");
2252 }
2253
2254 #[test]
2255 fn steps_table_shows_a_dash_without_role() {
2256 let mut table = steps_table(&[agent_step(None)]);
2257 table.set_width(1000);
2259 let output = table.to_string();
2260 let split = |line: &str| -> Vec<String> {
2261 line.split(['│', '┆'])
2262 .map(|cell| cell.trim().to_string())
2263 .collect()
2264 };
2265 let header = output
2266 .lines()
2267 .find(|line| line.contains("Role"))
2268 .expect("header row");
2269 let role_index = split(header)
2270 .iter()
2271 .position(|cell| cell == "Role")
2272 .expect("Role column");
2273 let row = output
2274 .lines()
2275 .find(|line| line.contains("review"))
2276 .expect("step row");
2277 assert_eq!(split(row)[role_index], "-", "{row}");
2278 }
2279
2280 #[test]
2281 fn role_steps_table_lists_the_run_of_each_step() {
2282 let step = agent_step(Some("Reviewer"));
2283 let run = short_id(step.run_id);
2284 let output = role_steps_table(slice::from_ref(&step)).to_string();
2285 assert!(output.contains(&run), "{output}");
2286 assert!(output.contains("review"), "{output}");
2287 assert!(output.contains("$0.2500"), "{output}");
2288 }
2289
2290 #[test]
2291 fn agent_role_stats_table_renders_a_row_per_role() {
2292 let stats: AgentRoleStatsResponse = from_value(json!({
2293 "period": "7d",
2294 "roles": [
2295 {
2296 "agent_role": "Reviewer",
2297 "executions": 3,
2298 "failed": 1,
2299 "total_cost_usd": 1.5,
2300 "total_duration_ms": 4500
2301 },
2302 {
2303 "agent_role": "Writer",
2304 "executions": 1,
2305 "failed": 0,
2306 "total_cost_usd": 0.1,
2307 "total_duration_ms": 900
2308 }
2309 ]
2310 }))
2311 .expect("valid AgentRoleStatsResponse");
2312
2313 let output = agent_role_stats_table(&stats).to_string();
2314 assert!(output.contains("Reviewer"), "{output}");
2315 assert!(output.contains("Writer"), "{output}");
2316 assert!(output.contains("$1.5000"), "{output}");
2317 assert!(output.contains("4s"), "{output}");
2318 }
2319}