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;
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
347 if !run.concurrency_limits.is_empty() {
348 table.add_row(vec![
349 Cell::new("Concurrency groups"),
350 Cell::new(format_concurrency_limits(&run.concurrency_limits)),
351 ]);
352 }
353
354 if !run.worker_tags.is_empty() {
355 table.add_row(vec![
356 Cell::new("Worker tags"),
357 Cell::new(run.worker_tags.join(", ")),
358 ]);
359 }
360
361 if let Some(warning) = detail.worker_routing.as_ref().and_then(routing_warning) {
362 table.add_row(vec![
363 Cell::new("Workers"),
364 Cell::new(warning).fg(Color::Yellow),
365 ]);
366 }
367
368 if let Some(ref kind) = run.capacity_wait_kind {
369 let resumes = format_optional_datetime(&run.scheduled_at);
370 let reason = format!("{kind}, resumes at {resumes}");
371 table.add_row(vec![
372 Cell::new("Waiting for capacity"),
373 Cell::new(reason).fg(Color::DarkCyan),
374 ]);
375 }
376
377 if let Some(ref error) = run.error {
378 table.add_row(vec![Cell::new("Error"), Cell::new(error).fg(Color::Red)]);
379 }
380
381 if let Some(ref output) = run.output {
382 table.add_row(vec![Cell::new("Output"), Cell::new(output)]);
383 }
384
385 if !detail.steps.is_empty() {
386 table.add_row(vec![
387 Cell::new("Steps"),
388 Cell::new(format!("{} step(s)", detail.steps.len())),
389 ]);
390 }
391
392 table
393}
394
395fn format_priority(priority: Option<i32>) -> String {
397 priority.map_or_else(|| "-".to_string(), |p| p.to_string())
398}
399
400fn routing_warning(routing: &WorkerRouting) -> Option<&'static str> {
403 if routing.seen_workers == 0 {
404 Some("No worker seen recently")
405 } else if routing.eligible_workers == 0 {
406 Some("No eligible worker seen: none registers this workflow with every required tag")
407 } else {
408 None
409 }
410}
411
412fn format_concurrency_limits(limits: &[ConcurrencyLimit]) -> String {
414 limits
415 .iter()
416 .map(|l| format!("{} ({})", l.group, l.limit))
417 .collect::<Vec<_>>()
418 .join(", ")
419}
420
421fn format_artifacts(artifacts: &[ArtifactResponse]) -> String {
425 if artifacts.is_empty() {
426 return "-".to_string();
427 }
428
429 let total: i64 = artifacts.iter().map(|artifact| artifact.size_bytes).sum();
430 format!("{} ({})", artifacts.len(), format_bytes(total))
431}
432
433fn format_bytes(bytes: i64) -> String {
435 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
436
437 if bytes < 1024 {
438 return format!("{bytes} B");
439 }
440
441 let mut value = bytes as f64;
442 let mut unit = 0;
443 while value >= 1024.0 && unit < UNITS.len() - 1 {
444 value /= 1024.0;
445 unit += 1;
446 }
447
448 let decimals = if value < 10.0 { 1 } else { 0 };
449 format!("{value:.decimals$} {}", UNITS[unit])
450}
451
452pub fn steps_table(steps: &[StepResponse]) -> Table {
454 let mut table = base_table();
455 table.set_header(vec![
456 "ID",
457 "Name",
458 "Status",
459 "SLA",
460 "Attempt",
461 "Duration",
462 "Cost",
463 "Artifacts",
464 "Started",
465 "Completed",
466 ]);
467
468 for step in steps {
469 let color = step_status_color(&step.status);
470
471 let mut sla = Cell::new(format_sla(step)).set_alignment(CellAlignment::Center);
472 if let Some(sla_fg) = sla_color(step) {
473 sla = sla.fg(sla_fg);
474 }
475
476 table.add_row(vec![
477 Cell::new(step.id.to_string().split('-').next().unwrap_or("")),
478 Cell::new(&step.name),
479 Cell::new(step.status)
480 .fg(color)
481 .set_alignment(CellAlignment::Center),
482 sla,
483 Cell::new(step.attempt).set_alignment(CellAlignment::Center),
484 Cell::new(format_duration_ms(step.duration_ms)),
485 Cell::new(format!("${:.4}", step.cost_usd)),
486 Cell::new(format_artifacts(&step.artifacts)).set_alignment(CellAlignment::Center),
487 Cell::new(format_optional_datetime(&step.started_at)),
488 Cell::new(format_optional_datetime(&step.completed_at)),
489 ]);
490 }
491
492 table
493}
494
495pub fn workflows_table(workflows: &[WorkflowSummary]) -> Table {
497 let mut table = base_table();
498 table.set_header(vec!["Name", "Category", "Version", "Paused since"]);
499
500 for wf in workflows {
501 table.add_row(vec![
502 Cell::new(&wf.name),
503 Cell::new(wf.category.as_deref().unwrap_or("-")),
504 Cell::new(wf.version.as_deref().unwrap_or("-")),
505 Cell::new(format_optional_datetime(&wf.paused_at)),
506 ]);
507 }
508
509 table
510}
511
512pub fn workflow_detail_table(detail: &WorkflowDetailResponse) -> Table {
514 let mut table = base_table();
515 table.set_header(vec!["Field", "Value"]);
516
517 table.add_row(vec![Cell::new("Name"), Cell::new(&detail.name)]);
518 table.add_row(vec![
519 Cell::new("Description"),
520 Cell::new(&detail.description),
521 ]);
522 table.add_row(vec![
523 Cell::new("Category"),
524 Cell::new(detail.category.as_deref().unwrap_or("-")),
525 ]);
526 table.add_row(vec![
527 Cell::new("Version"),
528 Cell::new(detail.version.as_deref().unwrap_or("-")),
529 ]);
530 table.add_row(vec![Cell::new("Priority"), Cell::new(detail.priority)]);
531 if let Some(paused_at) = &detail.paused_at {
532 table.add_row(vec![
533 Cell::new("Paused since"),
534 Cell::new(format_datetime(paused_at)),
535 ]);
536 }
537
538 if !detail.sub_workflows.is_empty() {
539 let names: Vec<&str> = detail
540 .sub_workflows
541 .iter()
542 .map(|s| s.name.as_str())
543 .collect();
544 table.add_row(vec![
545 Cell::new("Sub-workflows"),
546 Cell::new(names.join(", ")),
547 ]);
548 }
549
550 table
551}
552
553pub fn execution_plan_tree(plan: &ExecutionPlanResponse) -> String {
571 let mut lines = Vec::new();
572
573 let mut header = format!("workflow {}", plan.workflow);
574 if let Some(total) = plan.estimated_duration_ms {
575 header.push_str(&format!(" estimated ~{}", format_duration_ms(total)));
576 }
577 lines.push(header);
578
579 let mut current_group: Option<&str> = None;
580 for (index, step) in plan.steps.iter().enumerate() {
581 let group = step.parallel_group.as_deref();
582 if group != current_group {
583 if let Some(name) = group {
584 lines.push(format!("{}├─ {name}", indent(depth_of(step))));
585 }
586 current_group = group;
587 }
588
589 let extra = if group.is_some() { " " } else { "" };
590 let branch = if is_last_at_depth(plan, index) {
591 "└─ "
592 } else {
593 "├─ "
594 };
595 lines.push(format!(
596 "{}{extra}{branch}{}",
597 indent(depth_of(step)),
598 step_label(step)
599 ));
600 }
601
602 if plan.truncated {
603 let reason = plan
604 .incomplete_reason
605 .as_deref()
606 .unwrap_or("the plan was cut short");
607 lines.push(format!("plan incomplete: {reason}"));
608 }
609
610 lines.join("\n")
611}
612
613fn indent(depth: usize) -> String {
615 " ".repeat(depth)
616}
617
618fn depth_of(step: &PlannedStepResponse) -> usize {
620 usize::try_from(step.depth).unwrap_or(0)
621}
622
623fn is_last_at_depth(plan: &ExecutionPlanResponse, index: usize) -> bool {
625 let depth = plan.steps[index].depth;
626 !plan.steps[index + 1..].iter().any(|s| s.depth == depth)
627}
628
629fn step_label(step: &PlannedStepResponse) -> String {
631 let mut label = format!("{} [{}]", step.name, step.kind);
632
633 if let Some(ms) = step.estimated_duration_ms {
634 label.push_str(&format!(" ~{}", format_duration_ms(ms)));
635 }
636
637 if let Some(condition) = &step.condition {
638 let suffix = match condition.state.as_str() {
639 "evaluated" => format!(
640 " (when {} = {})",
641 condition.expression.as_deref().unwrap_or("?"),
642 condition.value.unwrap_or(false)
643 ),
644 "skipped" => format!(
645 " (skipped: {})",
646 condition.reason.as_deref().unwrap_or("no reason given")
647 ),
648 _ => format!(
649 " (condition unevaluable: {})",
650 condition.expression.as_deref().unwrap_or("?")
651 ),
652 };
653 label.push_str(&suffix);
654 }
655
656 label
657}
658
659pub fn render_execution_plan<W: Write>(
665 writer: &mut W,
666 json_mode: bool,
667 response: &ApiResponse<ExecutionPlanResponse>,
668) -> Result<()> {
669 if json_mode {
670 let json = to_string_pretty(response)?;
671 writeln!(writer, "{json}")?;
672 } else {
673 writeln!(writer, "{}", execution_plan_tree(&response.data))?;
674 }
675 Ok(())
676}
677
678pub fn stats_table(stats: &StatsResponse) -> Table {
680 let mut table = base_table();
681 table.set_header(vec!["Metric", "Value"]);
682
683 table.add_row(vec![Cell::new("Total runs"), Cell::new(stats.total_runs)]);
684 table.add_row(vec![
685 Cell::new("Completed"),
686 Cell::new(stats.completed_runs).fg(Color::Green),
687 ]);
688 table.add_row(vec![
689 Cell::new("Failed"),
690 Cell::new(stats.failed_runs).fg(Color::Red),
691 ]);
692 table.add_row(vec![
693 Cell::new("Cancelled"),
694 Cell::new(stats.cancelled_runs).fg(Color::Grey),
695 ]);
696 table.add_row(vec![
697 Cell::new("Active"),
698 Cell::new(stats.active_runs).fg(Color::Blue),
699 ]);
700 table.add_row(vec![
701 Cell::new("Awaiting approval"),
702 Cell::new(stats.awaiting_approval_runs).fg(Color::Magenta),
703 ]);
704 table.add_row(vec![
705 Cell::new("Success rate"),
706 Cell::new(format!("{:.1}%", stats.success_rate_percent)),
707 ]);
708 table.add_row(vec![
709 Cell::new("Total cost"),
710 Cell::new(format!("${:.4}", stats.total_cost_usd)),
711 ]);
712 table.add_row(vec![
713 Cell::new("Total duration"),
714 Cell::new(format_duration_ms(stats.total_duration_ms)),
715 ]);
716
717 table
718}
719
720pub fn stats_history_table(history: &StatsHistoryResponse) -> Table {
722 let mut table = base_table();
723 table.set_header(vec![
724 "Time",
725 "Completed",
726 "Warning",
727 "Failed",
728 "Cancelled",
729 "Active",
730 "Success %",
731 "Avg (ms)",
732 "P95 (ms)",
733 "Cost",
734 ]);
735
736 for bucket in &history.buckets {
737 let active = bucket.pending
738 + bucket.running
739 + bucket.retrying
740 + bucket.awaiting_approval
741 + bucket.sleeping
742 + bucket.paused;
743 table.add_row(vec![
744 Cell::new(bucket.time),
745 Cell::new(bucket.completed).fg(Color::Green),
746 Cell::new(bucket.warning).fg(Color::Yellow),
747 Cell::new(bucket.failed).fg(Color::Red),
748 Cell::new(bucket.cancelled).fg(Color::Grey),
749 Cell::new(active).fg(Color::Blue),
750 Cell::new(format_success_rate(bucket.success_rate_percent)),
751 Cell::new(bucket.avg_duration_ms),
752 Cell::new(bucket.p95_duration_ms),
753 Cell::new(format!("${:.4}", bucket.total_cost_usd)),
754 ]);
755 }
756
757 table
758}
759
760fn format_success_rate(rate: Option<f64>) -> String {
762 rate.map_or_else(|| "-".to_string(), |r| format!("{r:.1}%"))
763}
764
765fn format_versions(versions: &[i32]) -> String {
767 if versions.is_empty() {
768 return "-".to_string();
769 }
770 versions
771 .iter()
772 .map(|v| v.to_string())
773 .collect::<Vec<_>>()
774 .join(", ")
775}
776
777#[derive(Debug, Serialize)]
791pub struct Deleted {
792 pub kind: &'static str,
794 pub id: String,
796 pub deleted: bool,
798}
799
800impl Deleted {
801 pub fn new(kind: &'static str, id: impl Into<String>) -> Self {
803 Self {
804 kind,
805 id: id.into(),
806 deleted: true,
807 }
808 }
809}
810
811pub fn deleted_table(deleted: &Deleted) -> Table {
813 let mut table = base_table();
814 table.set_header(vec!["Deleted", "ID"]);
815 table.add_row(vec![Cell::new(deleted.kind), Cell::new(&deleted.id)]);
816 table
817}
818
819pub fn report_deletion(json_mode: bool, kind: &'static str, id: impl Into<String>) -> Result<()> {
836 let deleted = Deleted::new(kind, id);
837 print_output(json_mode, &deleted, || deleted_table(&deleted))
838}
839
840pub fn secrets_table(secrets: &[SecretResponse]) -> Table {
845 let mut table = base_table();
846 table.set_header(vec!["Key", "Created", "Updated"]);
847
848 for secret in secrets {
849 table.add_row(vec![
850 Cell::new(&secret.key),
851 Cell::new(format_datetime(&secret.created_at)),
852 Cell::new(format_datetime(&secret.updated_at)),
853 ]);
854 }
855
856 table
857}
858
859fn window_percent(windows: &[AccountWindowResponse], name: &str) -> String {
861 windows
862 .iter()
863 .find(|w| w.window == name && w.model_scope.is_none())
864 .map_or_else(
865 || "-".to_string(),
866 |w| format!("{:.0}%", w.utilization * 100.0),
867 )
868}
869
870fn account_state_color(state: &AccountState) -> Color {
872 match state {
873 AccountState::Ok => Color::Green,
874 AccountState::NearLimit => Color::Yellow,
875 AccountState::Limited | AccountState::TokenInvalid => Color::Red,
876 AccountState::NeverUsed => Color::Grey,
877 }
878}
879
880pub fn provider_accounts_table(accounts: &[ProviderAccountResponse]) -> Table {
882 let mut table = base_table();
883 table.set_header(vec![
884 "Name", "Kind", "State", "Enabled", "Priority", "Tags", "5h", "7d", "Expires",
885 ]);
886
887 for account in accounts {
888 table.add_row(vec![
889 Cell::new(&account.name),
890 Cell::new(&account.kind),
891 Cell::new(account.state.to_string()).fg(account_state_color(&account.state)),
892 Cell::new(if account.enabled { "yes" } else { "no" }),
893 Cell::new(account.priority).set_alignment(CellAlignment::Right),
894 Cell::new(account.tags.join(", ")),
895 Cell::new(window_percent(&account.windows, "five_hour"))
896 .set_alignment(CellAlignment::Right),
897 Cell::new(window_percent(&account.windows, "seven_day"))
898 .set_alignment(CellAlignment::Right),
899 Cell::new(format_datetime(&account.expires_at)),
900 ]);
901 }
902
903 table
904}
905
906pub fn provider_account_windows_table(windows: &[AccountWindowResponse]) -> Table {
908 let mut table = base_table();
909 table.set_header(vec![
910 "Window", "Scope", "Used", "Status", "Resets", "Observed",
911 ]);
912
913 for window in windows {
914 let color = match window.status {
915 AccountWindowStatus::Allowed => Color::Green,
916 AccountWindowStatus::AllowedWarning => Color::Yellow,
917 AccountWindowStatus::Rejected => Color::Red,
918 };
919 table.add_row(vec![
920 Cell::new(&window.window),
921 Cell::new(window.model_scope.as_deref().unwrap_or("-")),
922 Cell::new(format!("{:.0}%", window.utilization * 100.0))
923 .set_alignment(CellAlignment::Right),
924 Cell::new(window.status.to_string()).fg(color),
925 Cell::new(format_optional_datetime(&window.resets_at)),
926 Cell::new(format_datetime(&window.observed_at)),
927 ]);
928 }
929
930 table
931}
932
933fn format_scopes(scopes: &[ApiKeyScope]) -> String {
935 scopes
936 .iter()
937 .map(ToString::to_string)
938 .collect::<Vec<_>>()
939 .join(", ")
940}
941
942pub fn key_versions_table(status: &KeyVersionsResponse) -> Table {
944 let mut table = base_table();
945 table.set_header(vec!["Property", "Versions"]);
946
947 table.add_row(vec![
948 Cell::new("Active"),
949 Cell::new(status.active).fg(Color::Green),
950 ]);
951 table.add_row(vec![
952 Cell::new("Configured"),
953 Cell::new(format_versions(&status.configured)),
954 ]);
955 table.add_row(vec![
956 Cell::new("In use"),
957 Cell::new(format_versions(&status.in_use)),
958 ]);
959 table.add_row(vec![
960 Cell::new("Missing"),
961 Cell::new(format_versions(&status.missing)).fg(if status.missing.is_empty() {
962 Color::Grey
963 } else {
964 Color::Red
965 }),
966 ]);
967 table.add_row(vec![
968 Cell::new("Retirable"),
969 Cell::new(format_versions(&status.retirable)).fg(if status.retirable.is_empty() {
970 Color::Grey
971 } else {
972 Color::Yellow
973 }),
974 ]);
975
976 table
977}
978
979pub fn api_keys_table(keys: &[ApiKeyResponse]) -> Table {
983 let mut table = base_table();
984 table.set_header(vec![
985 "ID",
986 "Name",
987 "Prefix",
988 "Scopes",
989 "Active",
990 "Rate limit",
991 "Last used",
992 "Expires",
993 "Created",
994 ]);
995
996 for key in keys {
997 let active = Cell::new(if key.is_active { "yes" } else { "no" })
998 .fg(if key.is_active {
999 Color::Green
1000 } else {
1001 Color::Grey
1002 })
1003 .set_alignment(CellAlignment::Center);
1004
1005 let rate_limit = key
1006 .rate_limit_override
1007 .map(|v| v.to_string())
1008 .unwrap_or_else(|| "-".to_string());
1009
1010 table.add_row(vec![
1011 Cell::new(key.id),
1012 Cell::new(&key.name),
1013 Cell::new(&key.key_prefix),
1014 Cell::new(format_scopes(&key.scopes)),
1015 active,
1016 Cell::new(rate_limit),
1017 Cell::new(format_optional_datetime(&key.last_used_at)),
1018 Cell::new(format_optional_datetime(&key.expires_at)),
1019 Cell::new(format_datetime(&key.created_at)),
1020 ]);
1021 }
1022
1023 table
1024}
1025
1026pub fn created_api_key_table(key: &CreateApiKeyResponse) -> Table {
1032 let mut table = base_table();
1033 table.set_header(vec!["Field", "Value"]);
1034
1035 table.add_row(vec![Cell::new("ID"), Cell::new(key.id)]);
1036 table.add_row(vec![Cell::new("Name"), Cell::new(&key.name)]);
1037 table.add_row(vec![
1038 Cell::new("Key"),
1039 Cell::new(&key.key).fg(Color::Yellow),
1040 ]);
1041 table.add_row(vec![Cell::new("Prefix"), Cell::new(&key.key_prefix)]);
1042 table.add_row(vec![
1043 Cell::new("Scopes"),
1044 Cell::new(format_scopes(&key.scopes)),
1045 ]);
1046 if let Some(override_val) = key.rate_limit_override {
1047 table.add_row(vec![
1048 Cell::new("Rate limit"),
1049 Cell::new(format!("{override_val} req/min")),
1050 ]);
1051 }
1052 table.add_row(vec![
1053 Cell::new("Expires"),
1054 Cell::new(format_optional_datetime(&key.expires_at)),
1055 ]);
1056 table.add_row(vec![
1057 Cell::new("Created"),
1058 Cell::new(format_datetime(&key.created_at)),
1059 ]);
1060
1061 table
1062}
1063
1064pub fn scopes_table(scopes: &[ScopeEntry]) -> Table {
1066 let mut table = base_table();
1067 table.set_header(vec!["Value", "Label", "Description"]);
1068
1069 for scope in scopes {
1070 table.add_row(vec![
1071 Cell::new(&scope.value),
1072 Cell::new(&scope.label),
1073 Cell::new(&scope.description),
1074 ]);
1075 }
1076
1077 table
1078}
1079
1080pub fn users_table(users: &[UserResponse]) -> Table {
1082 let mut table = base_table();
1083 table.set_header(vec!["ID", "Username", "Email", "Admin", "Created"]);
1084
1085 for user in users {
1086 let admin = Cell::new(if user.is_admin { "yes" } else { "no" })
1087 .fg(if user.is_admin {
1088 Color::Magenta
1089 } else {
1090 Color::Grey
1091 })
1092 .set_alignment(CellAlignment::Center);
1093
1094 table.add_row(vec![
1095 Cell::new(user.id),
1096 Cell::new(&user.username),
1097 Cell::new(&user.email),
1098 admin,
1099 Cell::new(format_datetime(&user.created_at)),
1100 ]);
1101 }
1102
1103 table
1104}
1105
1106pub fn user_groups_table(resp: &UserGroupsResponse) -> Table {
1108 let mut table = base_table();
1109 table.set_header(vec!["User ID", "Groups"]);
1110
1111 let groups = if resp.groups.is_empty() {
1112 "-".to_string()
1113 } else {
1114 resp.groups.join(", ")
1115 };
1116 table.add_row(vec![Cell::new(resp.user_id), Cell::new(groups)]);
1117
1118 table
1119}
1120
1121pub fn run_diff_table(a: &RunDetailResponse, b: &RunDetailResponse) -> Table {
1123 let (ra, rb) = (&a.run, &b.run);
1124 let mut table = base_table();
1125 table.set_header(vec![
1126 "Field",
1127 &format!("Run {}", short_id(ra.id)),
1128 &format!("Run {}", short_id(rb.id)),
1129 ]);
1130
1131 let row = |f: &str, va: String, vb: String| -> Vec<Cell> {
1132 let hl = va != vb;
1133 vec![
1134 Cell::new(f),
1135 if hl {
1136 Cell::new(&va).fg(Color::Yellow)
1137 } else {
1138 Cell::new(&va)
1139 },
1140 if hl {
1141 Cell::new(&vb).fg(Color::Yellow)
1142 } else {
1143 Cell::new(&vb)
1144 },
1145 ]
1146 };
1147
1148 table.add_row(row("Status", ra.status.to_string(), rb.status.to_string()));
1149 table.add_row(row(
1150 "Duration",
1151 format_duration_ms(ra.duration_ms),
1152 format_duration_ms(rb.duration_ms),
1153 ));
1154 table.add_row(row(
1155 "Cost",
1156 format_cost(ra.cost_usd, ra.max_cost_usd),
1157 format_cost(rb.cost_usd, rb.max_cost_usd),
1158 ));
1159 table.add_row(row(
1160 "Started",
1161 format_optional_datetime(&ra.started_at),
1162 format_optional_datetime(&rb.started_at),
1163 ));
1164 table.add_row(row(
1165 "Completed",
1166 format_optional_datetime(&ra.completed_at),
1167 format_optional_datetime(&rb.completed_at),
1168 ));
1169 table.add_row(row(
1170 "Error",
1171 ra.error.clone().unwrap_or("-".into()),
1172 rb.error.clone().unwrap_or("-".into()),
1173 ));
1174 if a.payload != b.payload {
1175 table.add_row(row(
1176 "Payload",
1177 serde_json::to_string(&a.payload).unwrap_or_default(),
1178 serde_json::to_string(&b.payload).unwrap_or_default(),
1179 ));
1180 }
1181 for i in 0..a.steps.len().max(b.steps.len()) {
1182 let (sa, sb) = (a.steps.get(i), b.steps.get(i));
1183 let name = sa.or(sb).map(|s| s.name.as_str()).unwrap_or("-");
1184 table.add_row(row(
1185 &format!("{name} status"),
1186 sa.map(|s| s.status.to_string()).unwrap_or("-".into()),
1187 sb.map(|s| s.status.to_string()).unwrap_or("-".into()),
1188 ));
1189 table.add_row(row(
1190 &format!("{name} duration"),
1191 sa.map(|s| format_duration_ms(s.duration_ms))
1192 .unwrap_or("-".into()),
1193 sb.map(|s| format_duration_ms(s.duration_ms))
1194 .unwrap_or("-".into()),
1195 ));
1196 table.add_row(row(
1197 &format!("{name} cost"),
1198 sa.map(|s| format!("${:.4}", s.cost_usd))
1199 .unwrap_or("-".into()),
1200 sb.map(|s| format!("${:.4}", s.cost_usd))
1201 .unwrap_or("-".into()),
1202 ));
1203 }
1204 table
1205}
1206
1207fn short_id(id: Uuid) -> String {
1209 id.to_string()
1210 .split('-')
1211 .next()
1212 .unwrap_or_default()
1213 .to_string()
1214}
1215
1216fn format_optional_id(id: &Option<Uuid>) -> String {
1218 id.map_or_else(|| "-".to_string(), short_id)
1219}
1220
1221pub fn audit_logs_table(entries: &[AuditLogEntry]) -> Table {
1226 let mut table = base_table();
1227 table.set_header(vec!["ID", "Type", "Run", "Step", "User", "Created"]);
1228
1229 for entry in entries {
1230 table.add_row(vec![
1231 Cell::new(short_id(entry.id)),
1232 Cell::new(entry.event_type.to_string()),
1233 Cell::new(format_optional_id(&entry.run_id)),
1234 Cell::new(format_optional_id(&entry.step_id)),
1235 Cell::new(format_optional_id(&entry.user_id)),
1236 Cell::new(format_datetime(&entry.created_at)),
1237 ]);
1238 }
1239
1240 table
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245 use std::collections::HashMap;
1246 use std::slice;
1247
1248 use ironflow_sdk::types::{
1249 ApiKeyScope, ConditionResponse, CreatedBy, CreatedByKind, EventKind, TriggerKind,
1250 };
1251 use serde_json::{Map, Value, from_value, json};
1252
1253 use super::*;
1254
1255 fn run_fixture(created_by: CreatedBy) -> RunResponse {
1257 let now = Utc::now();
1258 RunResponse {
1259 id: Uuid::now_v7(),
1260 workflow_name: "deploy".to_string(),
1261 status: RunStatus::Completed,
1262 trigger: TriggerKind::Api,
1263 error: None,
1264 retry_count: 0,
1265 max_retries: 0,
1266 cost_usd: 0.0,
1267 duration_ms: 0,
1268 created_at: now,
1269 updated_at: now,
1270 started_at: None,
1271 completed_at: None,
1272 handler_version: None,
1273 labels: HashMap::new(),
1274 scheduled_at: None,
1275 capacity_wait_kind: None,
1276 resume_status: None,
1277 created_by,
1278 idempotency_key: None,
1279 concurrency_key: None,
1280 priority: Some(0),
1281 concurrency_limits: Vec::new(),
1282 max_cost_usd: None,
1283 output: None,
1284 worker_tags: Vec::new(),
1285 }
1286 }
1287
1288 #[test]
1289 fn format_success_rate_renders_dash_when_absent() {
1290 assert_eq!(format_success_rate(None), "-");
1291 }
1292
1293 #[test]
1294 fn format_success_rate_renders_one_decimal() {
1295 assert_eq!(format_success_rate(Some(100.0)), "100.0%");
1296 assert_eq!(format_success_rate(Some(200.0 / 3.0)), "66.7%");
1297 assert_eq!(format_success_rate(Some(0.0)), "0.0%");
1298 }
1299
1300 #[test]
1301 fn format_cost_without_cap_shows_amount_only() {
1302 assert_eq!(format_cost(0.1234, None), "$0.1234");
1303 }
1304
1305 #[test]
1306 fn format_cost_with_cap_shows_both_amounts() {
1307 assert_eq!(format_cost(0.18, Some(2.0)), "$0.1800 / $2.00");
1308 }
1309
1310 #[test]
1311 fn cost_color_is_absent_without_a_cap() {
1312 assert_eq!(cost_color(999.0, None), None);
1313 }
1314
1315 #[test]
1316 fn cost_color_warns_past_the_threshold_and_alerts_at_the_cap() {
1317 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));
1320 assert_eq!(cost_color(2.0, Some(2.0)), Some(Color::Red)); assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); }
1323
1324 #[test]
1325 fn cost_color_handles_a_zero_cap() {
1326 assert_eq!(cost_color(0.0, Some(0.0)), None);
1327 assert_eq!(cost_color(0.01, Some(0.0)), Some(Color::Red));
1328 }
1329
1330 fn artifact(name: &str, size_bytes: i64) -> ArtifactResponse {
1331 ArtifactResponse {
1332 id: Uuid::now_v7(),
1333 step_id: Uuid::now_v7(),
1334 name: name.to_string(),
1335 content_type: "text/plain".to_string(),
1336 size_bytes,
1337 sha256: "0".repeat(64),
1338 created_at: Utc::now(),
1339 }
1340 }
1341
1342 #[test]
1343 fn format_bytes_keeps_raw_bytes_below_one_kilobyte() {
1344 assert_eq!(format_bytes(0), "0 B");
1345 assert_eq!(format_bytes(1023), "1023 B");
1346 }
1347
1348 #[test]
1349 fn format_bytes_switches_units_at_each_boundary() {
1350 assert_eq!(format_bytes(1024), "1.0 KB");
1351 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
1352 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
1353 }
1354
1355 #[test]
1356 fn format_bytes_drops_the_decimal_past_ten() {
1357 assert_eq!(format_bytes(145_408), "142 KB");
1358 }
1359
1360 #[test]
1361 fn format_artifacts_shows_a_dash_when_there_are_none() {
1362 assert_eq!(format_artifacts(&[]), "-");
1363 }
1364
1365 #[test]
1366 fn format_artifacts_shows_the_count_and_total_size() {
1367 let artifacts = vec![artifact("a.txt", 1024), artifact("b.txt", 1024)];
1368 assert_eq!(format_artifacts(&artifacts), "2 (2.0 KB)");
1369 }
1370
1371 #[test]
1372 fn format_duration_ms_millis() {
1373 assert_eq!(format_duration_ms(500), "500ms");
1374 assert_eq!(format_duration_ms(0), "0ms");
1375 }
1376
1377 #[test]
1378 fn format_duration_ms_seconds() {
1379 assert_eq!(format_duration_ms(5000), "5s");
1380 assert_eq!(format_duration_ms(59000), "59s");
1381 }
1382
1383 #[test]
1384 fn format_duration_ms_minutes() {
1385 assert_eq!(format_duration_ms(60000), "1m 0s");
1386 assert_eq!(format_duration_ms(125000), "2m 5s");
1387 }
1388
1389 #[test]
1390 fn format_duration_ms_hours() {
1391 assert_eq!(format_duration_ms(3_600_000), "1h 0m");
1392 assert_eq!(format_duration_ms(5_400_000), "1h 30m");
1393 }
1394
1395 #[test]
1396 fn format_sla_without_a_deadline_is_a_dash() {
1397 assert_eq!(format_remaining_secs(None), "-");
1398 }
1399
1400 #[test]
1401 fn format_sla_reports_an_elapsed_deadline_as_expired() {
1402 assert_eq!(format_remaining_secs(Some(0)), "expired");
1403 assert_eq!(format_remaining_secs(Some(-30)), "expired");
1404 }
1405
1406 #[test]
1407 fn format_sla_uses_coarse_units() {
1408 assert_eq!(format_remaining_secs(Some(45)), "45s");
1409 assert_eq!(format_remaining_secs(Some(59)), "59s");
1410 assert_eq!(format_remaining_secs(Some(60)), "1m");
1411 assert_eq!(format_remaining_secs(Some(750)), "12m 30s");
1412 assert_eq!(format_remaining_secs(Some(3599)), "59m 59s");
1413 assert_eq!(format_remaining_secs(Some(3600)), "1h");
1414 assert_eq!(format_remaining_secs(Some(4320)), "1h 12m");
1415 }
1416
1417 #[test]
1418 fn sla_has_no_colour_without_a_deadline() {
1419 assert_eq!(remaining_color(None, None), None);
1420 assert_eq!(remaining_color(None, Some(3600)), None);
1421 }
1422
1423 #[test]
1424 fn sla_turns_red_once_expired() {
1425 assert_eq!(remaining_color(Some(0), Some(3600)), Some(Color::Red));
1426 assert_eq!(remaining_color(Some(-1), None), Some(Color::Red));
1427 }
1428
1429 #[test]
1430 fn sla_turns_yellow_in_the_last_tenth_of_the_window() {
1431 assert_eq!(remaining_color(Some(359), Some(3600)), Some(Color::Yellow));
1432 assert_eq!(remaining_color(Some(360), Some(3600)), None);
1433 assert_eq!(remaining_color(Some(3000), Some(3600)), None);
1434 }
1435
1436 #[test]
1437 fn sla_has_no_colour_without_a_measurable_window() {
1438 assert_eq!(remaining_color(Some(120), None), None);
1439 assert_eq!(remaining_color(Some(120), Some(0)), None);
1440 }
1441
1442 #[test]
1443 fn format_optional_datetime_none() {
1444 assert_eq!(format_optional_datetime(&None), "-");
1445 }
1446
1447 #[test]
1448 fn format_optional_datetime_some() {
1449 let dt = "2026-06-02T14:30:00Z".parse::<DateTime<Utc>>().unwrap();
1450 assert_eq!(format_optional_datetime(&Some(dt)), "2026-06-02 14:30:00");
1451 }
1452
1453 #[test]
1454 fn status_colors_are_distinct() {
1455 let statuses = [
1456 RunStatus::Completed,
1457 RunStatus::Failed,
1458 RunStatus::Running,
1459 RunStatus::Pending,
1460 RunStatus::Cancelled,
1461 RunStatus::AwaitingApproval,
1462 RunStatus::Retrying,
1463 ];
1464
1465 let colors: Vec<Color> = statuses.iter().map(status_color).collect();
1466 for (i, c1) in colors.iter().enumerate() {
1467 for (j, c2) in colors.iter().enumerate() {
1468 if i != j {
1469 assert_ne!(c1, c2, "status colors must be distinct");
1470 }
1471 }
1472 }
1473 }
1474
1475 #[test]
1476 fn empty_runs_table_has_header() {
1477 let table = runs_table(&[]);
1478 let output = table.to_string();
1479 assert!(output.contains("ID"));
1480 assert!(output.contains("Workflow"));
1481 assert!(output.contains("Status"));
1482 assert!(output.contains("Triggered by"));
1483 assert!(output.contains("Priority"));
1484 }
1485
1486 #[test]
1487 fn runs_table_renders_the_author_label() {
1488 let run = run_fixture(CreatedBy {
1489 kind: CreatedByKind::ApiKey,
1490 id: Some(Uuid::now_v7()),
1491 label: "ci-deploy (alice)".to_string(),
1492 });
1493
1494 let output = runs_table(slice::from_ref(&run)).to_string();
1495 assert!(
1496 output.contains("ci-deploy (alice)"),
1497 "author missing from:\n{output}"
1498 );
1499 }
1500
1501 #[test]
1502 fn format_priority_renders_the_value_or_a_dash() {
1503 assert_eq!(format_priority(Some(-40)), "-40");
1504 assert_eq!(format_priority(Some(0)), "0");
1505 assert_eq!(format_priority(None), "-");
1506 }
1507
1508 #[test]
1509 fn runs_table_renders_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(-73);
1516
1517 let output = runs_table(slice::from_ref(&run)).to_string();
1518 assert!(output.contains("-73"), "priority missing from:\n{output}");
1519 }
1520
1521 #[test]
1522 fn run_detail_table_shows_the_priority() {
1523 let mut run = run_fixture(CreatedBy {
1524 kind: CreatedByKind::System,
1525 id: None,
1526 label: "api".to_string(),
1527 });
1528 run.priority = Some(64);
1529 let detail = RunDetailResponse {
1530 run,
1531 steps: Vec::new(),
1532 payload: Value::Object(Map::new()),
1533 active_descendant_count: 0,
1534 worker_routing: None,
1535 };
1536
1537 let output = run_detail_table(&detail).to_string();
1538 assert!(output.contains("Priority"), "row missing from:\n{output}");
1539 assert!(output.contains("64"), "priority missing from:\n{output}");
1540 }
1541
1542 #[test]
1543 fn workflow_detail_table_shows_the_priority() {
1544 let detail: WorkflowDetailResponse = from_value(json!({
1545 "name": "prio-workflow",
1546 "description": "A workflow",
1547 "source_code": null,
1548 "sub_workflows": [],
1549 "category": null,
1550 "version": null,
1551 "priority": -40,
1552 }))
1553 .unwrap();
1554
1555 let output = workflow_detail_table(&detail).to_string();
1556 assert!(output.contains("Priority"), "row missing from:\n{output}");
1557 assert!(output.contains("-40"), "priority missing from:\n{output}");
1558 }
1559
1560 #[test]
1561 fn run_detail_table_renders_the_run_output() {
1562 let mut run = run_fixture(CreatedBy {
1563 kind: CreatedByKind::System,
1564 id: None,
1565 label: "cron".to_string(),
1566 });
1567 run.output = Some(json!({"verdict": "approved"}));
1568 let detail = RunDetailResponse {
1569 run,
1570 steps: Vec::new(),
1571 payload: Value::Object(Map::new()),
1572 active_descendant_count: 0,
1573 worker_routing: None,
1574 };
1575
1576 let output = run_detail_table(&detail).to_string();
1577 assert!(
1578 output.contains("Output"),
1579 "output row missing from:\n{output}"
1580 );
1581 assert!(output.contains(r#"{"verdict":"approved"}"#), "{output}");
1582 }
1583
1584 #[test]
1585 fn run_detail_table_has_no_output_row_without_an_output() {
1586 let detail = RunDetailResponse {
1587 run: run_fixture(CreatedBy {
1588 kind: CreatedByKind::System,
1589 id: None,
1590 label: "cron".to_string(),
1591 }),
1592 steps: Vec::new(),
1593 payload: Value::Object(Map::new()),
1594 active_descendant_count: 0,
1595 worker_routing: None,
1596 };
1597
1598 let output = run_detail_table(&detail).to_string();
1599 assert!(!output.contains("Output"), "{output}");
1600 }
1601
1602 #[test]
1603 fn run_detail_table_renders_the_author_label() {
1604 let detail = RunDetailResponse {
1605 run: run_fixture(CreatedBy {
1606 kind: CreatedByKind::System,
1607 id: None,
1608 label: "/hooks/github".to_string(),
1609 }),
1610 steps: Vec::new(),
1611 payload: Value::Object(Map::new()),
1612 active_descendant_count: 0,
1613 worker_routing: None,
1614 };
1615
1616 let output = run_detail_table(&detail).to_string();
1617 assert!(output.contains("Triggered by"));
1618 assert!(
1619 output.contains("/hooks/github"),
1620 "author missing from:\n{output}"
1621 );
1622 }
1623
1624 #[test]
1625 fn format_concurrency_limits_lists_each_group_with_its_limit() {
1626 let limits = [
1627 ConcurrencyLimit {
1628 group: "repo:acme".to_string(),
1629 limit: 2,
1630 },
1631 ConcurrencyLimit {
1632 group: "tenant:42".to_string(),
1633 limit: 1,
1634 },
1635 ];
1636 assert_eq!(
1637 format_concurrency_limits(&limits),
1638 "repo:acme (2), tenant:42 (1)"
1639 );
1640 }
1641
1642 #[test]
1643 fn run_detail_table_shows_concurrency_groups_only_when_present() {
1644 let mut detail = RunDetailResponse {
1645 run: run_fixture(CreatedBy {
1646 kind: CreatedByKind::System,
1647 id: None,
1648 label: "api".to_string(),
1649 }),
1650 steps: Vec::new(),
1651 payload: Value::Object(Map::new()),
1652 active_descendant_count: 0,
1653 worker_routing: None,
1654 };
1655 let output = run_detail_table(&detail).to_string();
1656 assert!(
1657 !output.contains("Concurrency groups"),
1658 "unexpected row in:\n{output}"
1659 );
1660
1661 detail.run.concurrency_limits = vec![ConcurrencyLimit {
1662 group: "repo:acme".to_string(),
1663 limit: 2,
1664 }];
1665 let output = run_detail_table(&detail).to_string();
1666 assert!(
1667 output.contains("Concurrency groups"),
1668 "row missing from:\n{output}"
1669 );
1670 assert!(
1671 output.contains("repo:acme (2)"),
1672 "group missing from:\n{output}"
1673 );
1674 }
1675
1676 #[test]
1677 fn run_detail_table_shows_the_capacity_wait_only_when_waiting() {
1678 let mut detail = RunDetailResponse {
1679 run: run_fixture(CreatedBy {
1680 kind: CreatedByKind::System,
1681 id: None,
1682 label: "api".to_string(),
1683 }),
1684 steps: Vec::new(),
1685 payload: Value::Object(Map::new()),
1686 active_descendant_count: 0,
1687 worker_routing: None,
1688 };
1689 let output = run_detail_table(&detail).to_string();
1690 assert!(
1691 !output.contains("Waiting for capacity"),
1692 "unexpected row in:\n{output}"
1693 );
1694
1695 let wake_at = Utc::now();
1696 detail.run.status = RunStatus::Sleeping;
1697 detail.run.scheduled_at = Some(wake_at);
1698 detail.run.capacity_wait_kind = Some("claude_subscription".to_string());
1699 let output = run_detail_table(&detail).to_string();
1700 assert!(
1701 output.contains("Waiting for capacity"),
1702 "row missing from:\n{output}"
1703 );
1704 let expected = format!(
1705 "claude_subscription, resumes at {}",
1706 format_datetime(&wake_at)
1707 );
1708 assert!(
1709 output.contains(&expected),
1710 "{expected} missing from:\n{output}"
1711 );
1712 }
1713
1714 #[test]
1715 fn routing_warning_covers_each_case() {
1716 let none_seen = WorkerRouting {
1717 seen_workers: 0,
1718 eligible_workers: 0,
1719 };
1720 let warning = routing_warning(&none_seen);
1721 assert_eq!(warning, Some("No worker seen recently"));
1722
1723 let none_eligible = WorkerRouting {
1724 seen_workers: 3,
1725 eligible_workers: 0,
1726 };
1727 let warning = routing_warning(&none_eligible).expect("a warning");
1728 assert!(warning.starts_with("No eligible worker seen"), "{warning}");
1729
1730 let eligible = WorkerRouting {
1731 seen_workers: 3,
1732 eligible_workers: 1,
1733 };
1734 assert_eq!(routing_warning(&eligible), None);
1735 }
1736
1737 #[test]
1738 fn run_detail_table_shows_worker_tags_only_when_present() {
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!(
1752 !output.contains("Worker tags"),
1753 "unexpected row in:\n{output}"
1754 );
1755
1756 detail.run.worker_tags = vec!["gpu".to_string(), "region:eu".to_string()];
1757 let output = run_detail_table(&detail).to_string();
1758 assert!(
1759 output.contains("Worker tags"),
1760 "row missing from:\n{output}"
1761 );
1762 assert!(
1763 output.contains("gpu, region:eu"),
1764 "tags missing from:\n{output}"
1765 );
1766 }
1767
1768 #[test]
1769 fn run_detail_table_warns_when_no_worker_can_take_the_run() {
1770 let mut detail = RunDetailResponse {
1771 run: run_fixture(CreatedBy {
1772 kind: CreatedByKind::System,
1773 id: None,
1774 label: "api".to_string(),
1775 }),
1776 steps: Vec::new(),
1777 payload: Value::Object(Map::new()),
1778 active_descendant_count: 0,
1779 worker_routing: None,
1780 };
1781 let output = run_detail_table(&detail).to_string();
1782 assert!(!output.contains("Workers"), "unexpected row in:\n{output}");
1783
1784 detail.worker_routing = Some(WorkerRouting {
1785 seen_workers: 2,
1786 eligible_workers: 1,
1787 });
1788 let output = run_detail_table(&detail).to_string();
1789 assert!(!output.contains("Workers"), "unexpected row in:\n{output}");
1790
1791 detail.worker_routing = Some(WorkerRouting {
1792 seen_workers: 2,
1793 eligible_workers: 0,
1794 });
1795 let output = run_detail_table(&detail).to_string();
1796 assert!(output.contains("Workers"), "row missing from:\n{output}");
1797 assert!(
1798 output.contains("No eligible worker seen"),
1799 "warning missing from:\n{output}"
1800 );
1801 }
1802
1803 #[test]
1804 fn empty_workflows_table_has_header() {
1805 let table = workflows_table(&[]);
1806 let output = table.to_string();
1807 assert!(output.contains("Name"));
1808 assert!(output.contains("Category"));
1809 assert!(output.contains("Paused since"));
1810 }
1811
1812 fn secret_fixture(key: &str) -> SecretResponse {
1815 let now = Utc::now();
1816 SecretResponse {
1817 id: Uuid::now_v7(),
1818 key: key.to_string(),
1819 created_at: now,
1820 updated_at: now,
1821 }
1822 }
1823
1824 #[test]
1825 fn empty_secrets_table_has_header() {
1826 let output = secrets_table(&[]).to_string();
1827 assert!(output.contains("Key"));
1828 assert!(output.contains("Created"));
1829 assert!(output.contains("Updated"));
1830 }
1831
1832 #[test]
1833 fn secrets_table_renders_the_key() {
1834 let secret = secret_fixture("workflows/inbox/gmail_token");
1835 let output = secrets_table(slice::from_ref(&secret)).to_string();
1836 assert!(output.contains("workflows/inbox/gmail_token"), "{output}");
1837 }
1838
1839 #[test]
1842 fn a_secret_response_carries_no_value_at_all() {
1843 let secret = secret_fixture("db/password");
1844 let json = serde_json::to_string(&secret).unwrap();
1845 assert!(!json.contains("value"), "{json}");
1846 }
1847
1848 fn api_key_fixture() -> ApiKeyResponse {
1851 ApiKeyResponse {
1852 id: Uuid::now_v7(),
1853 name: "ci-deploy".to_string(),
1854 key_prefix: "ifk_abcd".to_string(),
1855 scopes: vec![ApiKeyScope::RunsRead, ApiKeyScope::RunsWrite],
1856 is_active: true,
1857 created_at: Utc::now(),
1858 expires_at: None,
1859 last_used_at: None,
1860 rate_limit_override: None,
1861 }
1862 }
1863
1864 #[test]
1865 fn empty_api_keys_table_has_header() {
1866 let output = api_keys_table(&[]).to_string();
1867 for header in ["ID", "Name", "Prefix", "Scopes", "Active"] {
1868 assert!(output.contains(header), "missing {header} in {output}");
1869 }
1870 }
1871
1872 #[test]
1873 fn api_keys_table_joins_the_scopes() {
1874 let key = api_key_fixture();
1875 let output = api_keys_table(slice::from_ref(&key)).to_string();
1876 assert!(output.contains("runs_read, runs_write"), "{output}");
1877 assert!(output.contains("ifk_abcd"), "{output}");
1878 }
1879
1880 #[test]
1881 fn created_api_key_table_shows_the_raw_key() {
1882 let created = CreateApiKeyResponse {
1883 id: Uuid::now_v7(),
1884 name: "ci-deploy".to_string(),
1885 key: "ifk_full_raw_key".to_string(),
1886 key_prefix: "ifk_full".to_string(),
1887 scopes: vec![ApiKeyScope::Admin],
1888 created_at: Utc::now(),
1889 expires_at: None,
1890 rate_limit_override: None,
1891 };
1892
1893 let output = created_api_key_table(&created).to_string();
1894 assert!(output.contains("ifk_full_raw_key"), "{output}");
1895 }
1896
1897 #[test]
1898 fn empty_scopes_table_has_header() {
1899 let output = scopes_table(&[]).to_string();
1900 assert!(output.contains("Value"));
1901 assert!(output.contains("Description"));
1902 }
1903
1904 fn user_fixture(is_admin: bool) -> UserResponse {
1907 let now = Utc::now();
1908 UserResponse {
1909 id: Uuid::now_v7(),
1910 username: "alice".to_string(),
1911 email: "alice@example.com".to_string(),
1912 is_admin,
1913 created_at: now,
1914 updated_at: now,
1915 }
1916 }
1917
1918 #[test]
1919 fn empty_users_table_has_header() {
1920 let output = users_table(&[]).to_string();
1921 for header in ["ID", "Username", "Email", "Admin", "Created"] {
1922 assert!(output.contains(header), "missing {header} in {output}");
1923 }
1924 }
1925
1926 #[test]
1927 fn users_table_spells_out_the_role() {
1928 let admin = user_fixture(true);
1929 assert!(
1930 users_table(slice::from_ref(&admin))
1931 .to_string()
1932 .contains("yes")
1933 );
1934
1935 let member = user_fixture(false);
1936 assert!(
1937 users_table(slice::from_ref(&member))
1938 .to_string()
1939 .contains("no")
1940 );
1941 }
1942
1943 #[test]
1944 fn user_groups_table_has_header_and_lists_the_groups() {
1945 let resp = UserGroupsResponse {
1946 user_id: Uuid::now_v7(),
1947 groups: vec!["finance".to_string(), "sre".to_string()],
1948 };
1949 let output = user_groups_table(&resp).to_string();
1950 for header in ["User ID", "Groups"] {
1951 assert!(output.contains(header), "missing {header} in {output}");
1952 }
1953 assert!(output.contains(&resp.user_id.to_string()), "{output}");
1954 assert!(output.contains("finance, sre"), "{output}");
1955 }
1956
1957 #[test]
1958 fn user_groups_table_shows_a_dash_without_groups() {
1959 let resp = UserGroupsResponse {
1960 user_id: Uuid::now_v7(),
1961 groups: Vec::new(),
1962 };
1963 let output = user_groups_table(&resp).to_string();
1964 assert!(output.contains("Groups"), "{output}");
1965 assert!(output.contains(" - "), "{output}");
1966 assert!(!output.contains("finance"), "{output}");
1967 }
1968
1969 #[test]
1972 fn empty_audit_logs_table_has_header() {
1973 let output = audit_logs_table(&[]).to_string();
1974 for header in ["ID", "Type", "Run", "Step", "User", "Created"] {
1975 assert!(output.contains(header), "missing {header} in {output}");
1976 }
1977 }
1978
1979 #[test]
1980 fn audit_logs_table_omits_the_payload() {
1981 let entry = AuditLogEntry {
1982 id: Uuid::now_v7(),
1983 event_type: EventKind::RunCreated,
1984 payload: Value::Object(Map::new()),
1985 run_id: Some(Uuid::now_v7()),
1986 step_id: None,
1987 user_id: None,
1988 created_at: Utc::now(),
1989 };
1990
1991 let output = audit_logs_table(slice::from_ref(&entry)).to_string();
1992 assert!(output.contains("run_created"), "{output}");
1993 assert!(output.contains(" - "), "{output}");
1995 }
1996
1997 #[test]
1998 fn format_optional_id_shortens_and_falls_back() {
1999 assert_eq!(format_optional_id(&None), "-");
2000 let id = Uuid::now_v7();
2001 let short = format_optional_id(&Some(id));
2002 assert_eq!(short, id.to_string().split('-').next().unwrap());
2003 }
2004
2005 #[test]
2008 fn deleted_table_reports_the_kind_and_id() {
2009 let deleted = Deleted::new("secret", "db/password");
2010 let output = deleted_table(&deleted).to_string();
2011 assert!(output.contains("secret"), "{output}");
2012 assert!(output.contains("db/password"), "{output}");
2013
2014 let json = serde_json::to_string(&deleted).unwrap();
2015 assert!(json.contains(r#""deleted":true"#), "{json}");
2016 }
2017
2018 fn planned_step(name: &str, kind: &str, parallel_group: Option<&str>) -> PlannedStepResponse {
2021 PlannedStepResponse {
2022 name: name.to_string(),
2023 kind: kind.to_string(),
2024 workflow: "deploy".to_string(),
2025 depth: 0,
2026 depends_on: Vec::new(),
2027 condition: None,
2028 parallel_group: parallel_group.map(str::to_string),
2029 estimated_duration_ms: None,
2030 }
2031 }
2032
2033 fn plan_fixture(steps: Vec<PlannedStepResponse>) -> ExecutionPlanResponse {
2034 ExecutionPlanResponse {
2035 workflow: "deploy".to_string(),
2036 steps,
2037 estimated_duration_ms: None,
2038 max_depth: 3,
2039 truncated: false,
2040 incomplete_reason: None,
2041 }
2042 }
2043
2044 #[test]
2045 fn execution_plan_tree_lists_step_names_and_kinds() {
2046 let plan = plan_fixture(vec![
2047 planned_step("build", "shell", None),
2048 planned_step("deploy", "shell", None),
2049 ]);
2050
2051 let output = execution_plan_tree(&plan);
2052 assert!(output.contains("workflow deploy"), "{output}");
2053 assert!(output.contains("build [shell]"), "{output}");
2054 assert!(output.contains("deploy [shell]"), "{output}");
2055 }
2056
2057 #[test]
2058 fn execution_plan_tree_prints_a_parallel_group_header_once() {
2059 let plan = plan_fixture(vec![
2060 planned_step("build", "shell", None),
2061 planned_step("test", "shell", Some("parallel-1")),
2062 planned_step("lint", "shell", Some("parallel-1")),
2063 ]);
2064
2065 let output = execution_plan_tree(&plan);
2066 assert_eq!(output.matches("parallel-1").count(), 1, "{output}");
2067 }
2068
2069 #[test]
2070 fn execution_plan_tree_shows_the_estimate_when_present() {
2071 let mut step = planned_step("build", "shell", None);
2072 step.estimated_duration_ms = Some(5000);
2073 let mut plan = plan_fixture(vec![step]);
2074 plan.estimated_duration_ms = Some(5000);
2075
2076 let output = execution_plan_tree(&plan);
2077 assert!(output.contains("estimated ~5s"), "{output}");
2078 assert!(output.contains("build [shell] ~5s"), "{output}");
2079 }
2080
2081 #[test]
2082 fn execution_plan_tree_marks_conditions() {
2083 let mut evaluated = planned_step("deploy-prod", "shell", None);
2084 evaluated.condition = Some(ConditionResponse {
2085 state: "evaluated".to_string(),
2086 expression: Some("env == prod".to_string()),
2087 value: Some(true),
2088 reason: None,
2089 });
2090 let mut skipped = planned_step("deploy-dev", "skip", None);
2091 skipped.condition = Some(ConditionResponse {
2092 state: "skipped".to_string(),
2093 expression: None,
2094 value: None,
2095 reason: Some("not prod".to_string()),
2096 });
2097 let mut unevaluable = planned_step("notify", "http", None);
2098 unevaluable.condition = Some(ConditionResponse {
2099 state: "unevaluable".to_string(),
2100 expression: Some("build succeeded".to_string()),
2101 value: None,
2102 reason: Some("depends on a step output".to_string()),
2103 });
2104
2105 let output = execution_plan_tree(&plan_fixture(vec![evaluated, skipped, unevaluable]));
2106 assert!(output.contains("(when env == prod = true)"), "{output}");
2107 assert!(output.contains("(skipped: not prod)"), "{output}");
2108 assert!(
2109 output.contains("(condition unevaluable: build succeeded)"),
2110 "{output}"
2111 );
2112 }
2113
2114 #[test]
2115 fn execution_plan_tree_reports_an_incomplete_plan() {
2116 let mut plan = plan_fixture(vec![planned_step("build", "shell", None)]);
2117 plan.truncated = true;
2118 plan.incomplete_reason = Some("step cap of 1000 reached".to_string());
2119
2120 let output = execution_plan_tree(&plan);
2121 assert!(
2122 output.contains("plan incomplete: step cap of 1000 reached"),
2123 "{output}"
2124 );
2125 }
2126
2127 #[test]
2128 fn execution_plan_tree_indents_sub_workflow_steps() {
2129 let mut nested = planned_step("child-step", "shell", None);
2130 nested.depth = 1;
2131 let plan = plan_fixture(vec![planned_step("child", "workflow", None), nested]);
2132
2133 let output = execution_plan_tree(&plan);
2134 let nested = output
2135 .lines()
2136 .find(|l| l.contains("child-step"))
2137 .expect("nested line");
2138 assert!(nested.starts_with(" "), "{nested}");
2139 }
2140}