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