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