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