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