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