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