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 ApiKeyResponse, ApiKeyScope, ArtifactResponse, AuditLogEntry, CreateApiKeyResponse,
15 ExecutionPlanResponse, KeyVersionsResponse, PlannedStepResponse, RunDetailResponse,
16 RunResponse, RunStatus, ScopeEntry, SecretResponse, StatsHistoryResponse, StatsResponse,
17 StepResponse, StepStatus, UserResponse, WorkflowDetailResponse, WorkflowSummary,
18};
19use serde::Serialize;
20use serde_json::to_string_pretty;
21use uuid::Uuid;
22
23fn status_color(status: &RunStatus) -> Color {
25 match status {
26 RunStatus::Completed => Color::Green,
27 RunStatus::Failed => Color::Red,
28 RunStatus::Running => Color::Blue,
29 RunStatus::Pending => Color::Yellow,
30 RunStatus::Cancelled => Color::Grey,
31 RunStatus::AwaitingApproval => Color::Magenta,
32 RunStatus::Retrying => Color::Cyan,
33 RunStatus::Warning => Color::DarkYellow,
34 RunStatus::Sleeping => Color::DarkCyan,
35 }
36}
37
38fn step_status_color(status: &StepStatus) -> Color {
40 match status {
41 StepStatus::Completed => Color::Green,
42 StepStatus::Failed => Color::Red,
43 StepStatus::Running => Color::Blue,
44 StepStatus::Pending => Color::Yellow,
45 StepStatus::Skipped => Color::Grey,
46 StepStatus::AwaitingApproval => Color::Magenta,
47 StepStatus::Rejected => Color::Red,
48 }
49}
50
51fn format_datetime(dt: &DateTime<Utc>) -> String {
53 dt.format("%Y-%m-%d %H:%M:%S").to_string()
54}
55
56fn format_optional_datetime(dt: &Option<DateTime<Utc>>) -> String {
58 dt.as_ref().map_or("-".to_string(), format_datetime)
59}
60
61const SLA_WARNING_RATIO: f64 = 0.1;
63
64fn format_remaining_secs(remaining: Option<i64>) -> String {
68 let Some(remaining) = remaining else {
69 return "-".to_string();
70 };
71 if remaining <= 0 {
72 return "expired".to_string();
73 }
74
75 if remaining < 60 {
76 return format!("{remaining}s");
77 }
78
79 let minutes = remaining / 60;
80 if minutes < 60 {
81 let rest = remaining % 60;
82 return if rest == 0 {
83 format!("{minutes}m")
84 } else {
85 format!("{minutes}m {rest}s")
86 };
87 }
88
89 let hours = minutes / 60;
90 let rest = minutes % 60;
91 if rest == 0 {
92 format!("{hours}h")
93 } else {
94 format!("{hours}h {rest}m")
95 }
96}
97
98fn remaining_color(remaining: Option<i64>, window_secs: Option<i64>) -> Option<Color> {
101 let remaining = remaining?;
102 if remaining <= 0 {
103 return Some(Color::Red);
104 }
105
106 let window = window_secs?;
107 if window > 0 && (remaining as f64) < (window as f64) * SLA_WARNING_RATIO {
108 return Some(Color::Yellow);
109 }
110
111 None
112}
113
114fn format_sla(step: &StepResponse) -> String {
120 format_remaining_secs(step.approval_seconds_remaining)
121}
122
123fn sla_color(step: &StepResponse) -> Option<Color> {
128 let window = match (step.approval_deadline_at, step.started_at) {
129 (Some(deadline), Some(started)) => Some((deadline - started).num_seconds()),
130 _ => None,
131 };
132 remaining_color(step.approval_seconds_remaining, window)
133}
134
135fn format_duration_ms(ms: i64) -> String {
137 if ms < 1000 {
138 return format!("{ms}ms");
139 }
140 let secs = ms / 1000;
141 if secs < 60 {
142 return format!("{secs}s");
143 }
144 let mins = secs / 60;
145 let remaining_secs = secs % 60;
146 if mins < 60 {
147 return format!("{mins}m {remaining_secs}s");
148 }
149 let hours = mins / 60;
150 let remaining_mins = mins % 60;
151 format!("{hours}h {remaining_mins}m")
152}
153
154fn base_table() -> Table {
156 let mut table = Table::new();
157 table
158 .load_preset(UTF8_FULL)
159 .set_content_arrangement(ContentArrangement::Dynamic);
160 table
161}
162
163pub fn render_output<W: Write, T: Serialize>(
169 writer: &mut W,
170 json_mode: bool,
171 value: &T,
172 table_fn: impl FnOnce() -> Table,
173) -> Result<()> {
174 if json_mode {
175 let json = to_string_pretty(value)?;
176 writeln!(writer, "{json}")?;
177 } else {
178 writeln!(writer, "{}", table_fn())?;
179 }
180 Ok(())
181}
182
183pub fn print_output<T: Serialize>(
189 json_mode: bool,
190 value: &T,
191 table_fn: impl FnOnce() -> Table,
192) -> Result<()> {
193 render_output(&mut stdout().lock(), json_mode, value, table_fn)
194}
195
196pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
205 let json = to_string_pretty(value)?;
206 writeln!(stdout().lock(), "{json}")?;
207 Ok(())
208}
209
210const COST_WARNING_RATIO: f64 = 0.8;
213
214fn format_cost(cost_usd: f64, max_cost_usd: Option<f64>) -> String {
218 match max_cost_usd {
219 Some(cap) => format!("${cost_usd:.4} / ${cap:.2}"),
220 None => format!("${cost_usd:.4}"),
221 }
222}
223
224fn cost_color(cost_usd: f64, max_cost_usd: Option<f64>) -> Option<Color> {
230 let cap = max_cost_usd?;
231
232 if cap <= 0.0 {
233 return (cost_usd > 0.0).then_some(Color::Red);
234 }
235
236 let ratio = cost_usd / cap;
237 if ratio >= 1.0 {
238 Some(Color::Red)
239 } else if ratio >= COST_WARNING_RATIO {
240 Some(Color::Yellow)
241 } else {
242 None
243 }
244}
245
246fn cost_cell(cost_usd: f64, max_cost_usd: Option<f64>) -> Cell {
248 let cell = Cell::new(format_cost(cost_usd, max_cost_usd));
249 match cost_color(cost_usd, max_cost_usd) {
250 Some(color) => cell.fg(color),
251 None => cell,
252 }
253}
254
255pub fn runs_table(runs: &[RunResponse]) -> Table {
256 let mut table = base_table();
257 table.set_header(vec![
258 "ID",
259 "Workflow",
260 "Status",
261 "Triggered by",
262 "Duration",
263 "Cost",
264 "Created",
265 "Started",
266 ]);
267
268 for run in runs {
269 let status_cell = Cell::new(run.status)
270 .fg(status_color(&run.status))
271 .set_alignment(CellAlignment::Center);
272
273 table.add_row(vec![
274 Cell::new(run.id.to_string().split('-').next().unwrap_or("")),
275 Cell::new(&run.workflow_name),
276 status_cell,
277 Cell::new(&run.created_by.label),
278 Cell::new(format_duration_ms(run.duration_ms)),
279 cost_cell(run.cost_usd, run.max_cost_usd),
280 Cell::new(format_datetime(&run.created_at)),
281 Cell::new(format_optional_datetime(&run.started_at)),
282 ]);
283 }
284
285 table
286}
287
288pub fn run_detail_table(detail: &RunDetailResponse) -> Table {
290 let run = &detail.run;
291 let mut table = base_table();
292 table.set_header(vec!["Field", "Value"]);
293
294 let status_cell = Cell::new(run.status).fg(status_color(&run.status));
295
296 table.add_row(vec![Cell::new("ID"), Cell::new(run.id)]);
297 table.add_row(vec![Cell::new("Workflow"), Cell::new(&run.workflow_name)]);
298 table.add_row(vec![Cell::new("Status"), status_cell]);
299 table.add_row(vec![
300 Cell::new("Trigger"),
301 Cell::new(format!("{:?}", run.trigger)),
302 ]);
303 table.add_row(vec![
304 Cell::new("Triggered by"),
305 Cell::new(&run.created_by.label),
306 ]);
307 table.add_row(vec![
308 Cell::new("Duration"),
309 Cell::new(format_duration_ms(run.duration_ms)),
310 ]);
311 table.add_row(vec![
312 Cell::new("Cost"),
313 cost_cell(run.cost_usd, run.max_cost_usd),
314 ]);
315 table.add_row(vec![
316 Cell::new("Created"),
317 Cell::new(format_datetime(&run.created_at)),
318 ]);
319 table.add_row(vec![
320 Cell::new("Started"),
321 Cell::new(format_optional_datetime(&run.started_at)),
322 ]);
323 table.add_row(vec![
324 Cell::new("Completed"),
325 Cell::new(format_optional_datetime(&run.completed_at)),
326 ]);
327 table.add_row(vec![
328 Cell::new("Retries"),
329 Cell::new(format!("{}/{}", run.retry_count, run.max_retries)),
330 ]);
331
332 if let Some(ref error) = run.error {
333 table.add_row(vec![Cell::new("Error"), Cell::new(error).fg(Color::Red)]);
334 }
335
336 if !detail.steps.is_empty() {
337 table.add_row(vec![
338 Cell::new("Steps"),
339 Cell::new(format!("{} step(s)", detail.steps.len())),
340 ]);
341 }
342
343 table
344}
345
346fn format_artifacts(artifacts: &[ArtifactResponse]) -> String {
350 if artifacts.is_empty() {
351 return "-".to_string();
352 }
353
354 let total: i64 = artifacts.iter().map(|artifact| artifact.size_bytes).sum();
355 format!("{} ({})", artifacts.len(), format_bytes(total))
356}
357
358fn format_bytes(bytes: i64) -> String {
360 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
361
362 if bytes < 1024 {
363 return format!("{bytes} B");
364 }
365
366 let mut value = bytes as f64;
367 let mut unit = 0;
368 while value >= 1024.0 && unit < UNITS.len() - 1 {
369 value /= 1024.0;
370 unit += 1;
371 }
372
373 let decimals = if value < 10.0 { 1 } else { 0 };
374 format!("{value:.decimals$} {}", UNITS[unit])
375}
376
377pub fn steps_table(steps: &[StepResponse]) -> Table {
379 let mut table = base_table();
380 table.set_header(vec![
381 "ID",
382 "Name",
383 "Status",
384 "SLA",
385 "Attempt",
386 "Duration",
387 "Cost",
388 "Artifacts",
389 "Started",
390 "Completed",
391 ]);
392
393 for step in steps {
394 let color = step_status_color(&step.status);
395
396 let mut sla = Cell::new(format_sla(step)).set_alignment(CellAlignment::Center);
397 if let Some(sla_fg) = sla_color(step) {
398 sla = sla.fg(sla_fg);
399 }
400
401 table.add_row(vec![
402 Cell::new(step.id.to_string().split('-').next().unwrap_or("")),
403 Cell::new(&step.name),
404 Cell::new(step.status)
405 .fg(color)
406 .set_alignment(CellAlignment::Center),
407 sla,
408 Cell::new(step.attempt).set_alignment(CellAlignment::Center),
409 Cell::new(format_duration_ms(step.duration_ms)),
410 Cell::new(format!("${:.4}", step.cost_usd)),
411 Cell::new(format_artifacts(&step.artifacts)).set_alignment(CellAlignment::Center),
412 Cell::new(format_optional_datetime(&step.started_at)),
413 Cell::new(format_optional_datetime(&step.completed_at)),
414 ]);
415 }
416
417 table
418}
419
420pub fn workflows_table(workflows: &[WorkflowSummary]) -> Table {
422 let mut table = base_table();
423 table.set_header(vec!["Name", "Category", "Version"]);
424
425 for wf in workflows {
426 table.add_row(vec![
427 Cell::new(&wf.name),
428 Cell::new(wf.category.as_deref().unwrap_or("-")),
429 Cell::new(wf.version.as_deref().unwrap_or("-")),
430 ]);
431 }
432
433 table
434}
435
436pub fn workflow_detail_table(detail: &WorkflowDetailResponse) -> Table {
438 let mut table = base_table();
439 table.set_header(vec!["Field", "Value"]);
440
441 table.add_row(vec![Cell::new("Name"), Cell::new(&detail.name)]);
442 table.add_row(vec![
443 Cell::new("Description"),
444 Cell::new(&detail.description),
445 ]);
446 table.add_row(vec![
447 Cell::new("Category"),
448 Cell::new(detail.category.as_deref().unwrap_or("-")),
449 ]);
450 table.add_row(vec![
451 Cell::new("Version"),
452 Cell::new(detail.version.as_deref().unwrap_or("-")),
453 ]);
454
455 if !detail.sub_workflows.is_empty() {
456 let names: Vec<&str> = detail
457 .sub_workflows
458 .iter()
459 .map(|s| s.name.as_str())
460 .collect();
461 table.add_row(vec![
462 Cell::new("Sub-workflows"),
463 Cell::new(names.join(", ")),
464 ]);
465 }
466
467 table
468}
469
470pub fn execution_plan_tree(plan: &ExecutionPlanResponse) -> String {
488 let mut lines = Vec::new();
489
490 let mut header = format!("workflow {}", plan.workflow);
491 if let Some(total) = plan.estimated_duration_ms {
492 header.push_str(&format!(" estimated ~{}", format_duration_ms(total)));
493 }
494 lines.push(header);
495
496 let mut current_group: Option<&str> = None;
497 for (index, step) in plan.steps.iter().enumerate() {
498 let group = step.parallel_group.as_deref();
499 if group != current_group {
500 if let Some(name) = group {
501 lines.push(format!("{}├─ {name}", indent(depth_of(step))));
502 }
503 current_group = group;
504 }
505
506 let extra = if group.is_some() { " " } else { "" };
507 let branch = if is_last_at_depth(plan, index) {
508 "└─ "
509 } else {
510 "├─ "
511 };
512 lines.push(format!(
513 "{}{extra}{branch}{}",
514 indent(depth_of(step)),
515 step_label(step)
516 ));
517 }
518
519 if plan.truncated {
520 let reason = plan
521 .incomplete_reason
522 .as_deref()
523 .unwrap_or("the plan was cut short");
524 lines.push(format!("plan incomplete: {reason}"));
525 }
526
527 lines.join("\n")
528}
529
530fn indent(depth: usize) -> String {
532 " ".repeat(depth)
533}
534
535fn depth_of(step: &PlannedStepResponse) -> usize {
537 usize::try_from(step.depth).unwrap_or(0)
538}
539
540fn is_last_at_depth(plan: &ExecutionPlanResponse, index: usize) -> bool {
542 let depth = plan.steps[index].depth;
543 !plan.steps[index + 1..].iter().any(|s| s.depth == depth)
544}
545
546fn step_label(step: &PlannedStepResponse) -> String {
548 let mut label = format!("{} [{}]", step.name, step.kind);
549
550 if let Some(ms) = step.estimated_duration_ms {
551 label.push_str(&format!(" ~{}", format_duration_ms(ms)));
552 }
553
554 if let Some(condition) = &step.condition {
555 let suffix = match condition.state.as_str() {
556 "evaluated" => format!(
557 " (when {} = {})",
558 condition.expression.as_deref().unwrap_or("?"),
559 condition.value.unwrap_or(false)
560 ),
561 "skipped" => format!(
562 " (skipped: {})",
563 condition.reason.as_deref().unwrap_or("no reason given")
564 ),
565 _ => format!(
566 " (condition unevaluable: {})",
567 condition.expression.as_deref().unwrap_or("?")
568 ),
569 };
570 label.push_str(&suffix);
571 }
572
573 label
574}
575
576pub fn render_execution_plan<W: Write>(
582 writer: &mut W,
583 json_mode: bool,
584 response: &ApiResponse<ExecutionPlanResponse>,
585) -> Result<()> {
586 if json_mode {
587 let json = to_string_pretty(response)?;
588 writeln!(writer, "{json}")?;
589 } else {
590 writeln!(writer, "{}", execution_plan_tree(&response.data))?;
591 }
592 Ok(())
593}
594
595pub fn stats_table(stats: &StatsResponse) -> Table {
597 let mut table = base_table();
598 table.set_header(vec!["Metric", "Value"]);
599
600 table.add_row(vec![Cell::new("Total runs"), Cell::new(stats.total_runs)]);
601 table.add_row(vec![
602 Cell::new("Completed"),
603 Cell::new(stats.completed_runs).fg(Color::Green),
604 ]);
605 table.add_row(vec![
606 Cell::new("Failed"),
607 Cell::new(stats.failed_runs).fg(Color::Red),
608 ]);
609 table.add_row(vec![
610 Cell::new("Cancelled"),
611 Cell::new(stats.cancelled_runs).fg(Color::Grey),
612 ]);
613 table.add_row(vec![
614 Cell::new("Active"),
615 Cell::new(stats.active_runs).fg(Color::Blue),
616 ]);
617 table.add_row(vec![
618 Cell::new("Success rate"),
619 Cell::new(format!("{:.1}%", stats.success_rate_percent)),
620 ]);
621 table.add_row(vec![
622 Cell::new("Total cost"),
623 Cell::new(format!("${:.4}", stats.total_cost_usd)),
624 ]);
625 table.add_row(vec![
626 Cell::new("Total duration"),
627 Cell::new(format_duration_ms(stats.total_duration_ms)),
628 ]);
629
630 table
631}
632
633pub fn stats_history_table(history: &StatsHistoryResponse) -> Table {
635 let mut table = base_table();
636 table.set_header(vec![
637 "Time",
638 "Completed",
639 "Failed",
640 "Cancelled",
641 "Avg (ms)",
642 "P95 (ms)",
643 "Cost",
644 ]);
645
646 for bucket in &history.buckets {
647 table.add_row(vec![
648 Cell::new(bucket.time),
649 Cell::new(bucket.completed).fg(Color::Green),
650 Cell::new(bucket.failed).fg(Color::Red),
651 Cell::new(bucket.cancelled).fg(Color::Grey),
652 Cell::new(bucket.avg_duration_ms),
653 Cell::new(bucket.p95_duration_ms),
654 Cell::new(format!("${:.4}", bucket.total_cost_usd)),
655 ]);
656 }
657
658 table
659}
660
661fn format_versions(versions: &[i32]) -> String {
663 if versions.is_empty() {
664 return "-".to_string();
665 }
666 versions
667 .iter()
668 .map(|v| v.to_string())
669 .collect::<Vec<_>>()
670 .join(", ")
671}
672
673#[derive(Debug, Serialize)]
687pub struct Deleted {
688 pub kind: &'static str,
690 pub id: String,
692 pub deleted: bool,
694}
695
696impl Deleted {
697 pub fn new(kind: &'static str, id: impl Into<String>) -> Self {
699 Self {
700 kind,
701 id: id.into(),
702 deleted: true,
703 }
704 }
705}
706
707pub fn deleted_table(deleted: &Deleted) -> Table {
709 let mut table = base_table();
710 table.set_header(vec!["Deleted", "ID"]);
711 table.add_row(vec![Cell::new(deleted.kind), Cell::new(&deleted.id)]);
712 table
713}
714
715pub fn report_deletion(json_mode: bool, kind: &'static str, id: impl Into<String>) -> Result<()> {
732 let deleted = Deleted::new(kind, id);
733 print_output(json_mode, &deleted, || deleted_table(&deleted))
734}
735
736pub fn secrets_table(secrets: &[SecretResponse]) -> Table {
741 let mut table = base_table();
742 table.set_header(vec!["Key", "Created", "Updated"]);
743
744 for secret in secrets {
745 table.add_row(vec![
746 Cell::new(&secret.key),
747 Cell::new(format_datetime(&secret.created_at)),
748 Cell::new(format_datetime(&secret.updated_at)),
749 ]);
750 }
751
752 table
753}
754
755fn format_scopes(scopes: &[ApiKeyScope]) -> String {
757 scopes
758 .iter()
759 .map(ToString::to_string)
760 .collect::<Vec<_>>()
761 .join(", ")
762}
763
764pub fn key_versions_table(status: &KeyVersionsResponse) -> Table {
766 let mut table = base_table();
767 table.set_header(vec!["Property", "Versions"]);
768
769 table.add_row(vec![
770 Cell::new("Active"),
771 Cell::new(status.active).fg(Color::Green),
772 ]);
773 table.add_row(vec![
774 Cell::new("Configured"),
775 Cell::new(format_versions(&status.configured)),
776 ]);
777 table.add_row(vec![
778 Cell::new("In use"),
779 Cell::new(format_versions(&status.in_use)),
780 ]);
781 table.add_row(vec![
782 Cell::new("Missing"),
783 Cell::new(format_versions(&status.missing)).fg(if status.missing.is_empty() {
784 Color::Grey
785 } else {
786 Color::Red
787 }),
788 ]);
789 table.add_row(vec![
790 Cell::new("Retirable"),
791 Cell::new(format_versions(&status.retirable)).fg(if status.retirable.is_empty() {
792 Color::Grey
793 } else {
794 Color::Yellow
795 }),
796 ]);
797
798 table
799}
800
801pub fn api_keys_table(keys: &[ApiKeyResponse]) -> Table {
805 let mut table = base_table();
806 table.set_header(vec![
807 "ID",
808 "Name",
809 "Prefix",
810 "Scopes",
811 "Active",
812 "Rate limit",
813 "Last used",
814 "Expires",
815 "Created",
816 ]);
817
818 for key in keys {
819 let active = Cell::new(if key.is_active { "yes" } else { "no" })
820 .fg(if key.is_active {
821 Color::Green
822 } else {
823 Color::Grey
824 })
825 .set_alignment(CellAlignment::Center);
826
827 let rate_limit = key
828 .rate_limit_override
829 .map(|v| v.to_string())
830 .unwrap_or_else(|| "-".to_string());
831
832 table.add_row(vec![
833 Cell::new(key.id),
834 Cell::new(&key.name),
835 Cell::new(&key.key_prefix),
836 Cell::new(format_scopes(&key.scopes)),
837 active,
838 Cell::new(rate_limit),
839 Cell::new(format_optional_datetime(&key.last_used_at)),
840 Cell::new(format_optional_datetime(&key.expires_at)),
841 Cell::new(format_datetime(&key.created_at)),
842 ]);
843 }
844
845 table
846}
847
848pub fn created_api_key_table(key: &CreateApiKeyResponse) -> Table {
854 let mut table = base_table();
855 table.set_header(vec!["Field", "Value"]);
856
857 table.add_row(vec![Cell::new("ID"), Cell::new(key.id)]);
858 table.add_row(vec![Cell::new("Name"), Cell::new(&key.name)]);
859 table.add_row(vec![
860 Cell::new("Key"),
861 Cell::new(&key.key).fg(Color::Yellow),
862 ]);
863 table.add_row(vec![Cell::new("Prefix"), Cell::new(&key.key_prefix)]);
864 table.add_row(vec![
865 Cell::new("Scopes"),
866 Cell::new(format_scopes(&key.scopes)),
867 ]);
868 if let Some(override_val) = key.rate_limit_override {
869 table.add_row(vec![
870 Cell::new("Rate limit"),
871 Cell::new(format!("{override_val} req/min")),
872 ]);
873 }
874 table.add_row(vec![
875 Cell::new("Expires"),
876 Cell::new(format_optional_datetime(&key.expires_at)),
877 ]);
878 table.add_row(vec![
879 Cell::new("Created"),
880 Cell::new(format_datetime(&key.created_at)),
881 ]);
882
883 table
884}
885
886pub fn scopes_table(scopes: &[ScopeEntry]) -> Table {
888 let mut table = base_table();
889 table.set_header(vec!["Value", "Label", "Description"]);
890
891 for scope in scopes {
892 table.add_row(vec![
893 Cell::new(&scope.value),
894 Cell::new(&scope.label),
895 Cell::new(&scope.description),
896 ]);
897 }
898
899 table
900}
901
902pub fn users_table(users: &[UserResponse]) -> Table {
904 let mut table = base_table();
905 table.set_header(vec!["ID", "Username", "Email", "Admin", "Created"]);
906
907 for user in users {
908 let admin = Cell::new(if user.is_admin { "yes" } else { "no" })
909 .fg(if user.is_admin {
910 Color::Magenta
911 } else {
912 Color::Grey
913 })
914 .set_alignment(CellAlignment::Center);
915
916 table.add_row(vec![
917 Cell::new(user.id),
918 Cell::new(&user.username),
919 Cell::new(&user.email),
920 admin,
921 Cell::new(format_datetime(&user.created_at)),
922 ]);
923 }
924
925 table
926}
927
928pub fn run_diff_table(a: &RunDetailResponse, b: &RunDetailResponse) -> Table {
930 let (ra, rb) = (&a.run, &b.run);
931 let mut table = base_table();
932 table.set_header(vec![
933 "Field",
934 &format!("Run {}", short_id(ra.id)),
935 &format!("Run {}", short_id(rb.id)),
936 ]);
937
938 let row = |f: &str, va: String, vb: String| -> Vec<Cell> {
939 let hl = va != vb;
940 vec![
941 Cell::new(f),
942 if hl {
943 Cell::new(&va).fg(Color::Yellow)
944 } else {
945 Cell::new(&va)
946 },
947 if hl {
948 Cell::new(&vb).fg(Color::Yellow)
949 } else {
950 Cell::new(&vb)
951 },
952 ]
953 };
954
955 table.add_row(row("Status", ra.status.to_string(), rb.status.to_string()));
956 table.add_row(row(
957 "Duration",
958 format_duration_ms(ra.duration_ms),
959 format_duration_ms(rb.duration_ms),
960 ));
961 table.add_row(row(
962 "Cost",
963 format_cost(ra.cost_usd, ra.max_cost_usd),
964 format_cost(rb.cost_usd, rb.max_cost_usd),
965 ));
966 table.add_row(row(
967 "Started",
968 format_optional_datetime(&ra.started_at),
969 format_optional_datetime(&rb.started_at),
970 ));
971 table.add_row(row(
972 "Completed",
973 format_optional_datetime(&ra.completed_at),
974 format_optional_datetime(&rb.completed_at),
975 ));
976 table.add_row(row(
977 "Error",
978 ra.error.clone().unwrap_or("-".into()),
979 rb.error.clone().unwrap_or("-".into()),
980 ));
981 if a.payload != b.payload {
982 table.add_row(row(
983 "Payload",
984 serde_json::to_string(&a.payload).unwrap_or_default(),
985 serde_json::to_string(&b.payload).unwrap_or_default(),
986 ));
987 }
988 for i in 0..a.steps.len().max(b.steps.len()) {
989 let (sa, sb) = (a.steps.get(i), b.steps.get(i));
990 let name = sa.or(sb).map(|s| s.name.as_str()).unwrap_or("-");
991 table.add_row(row(
992 &format!("{name} status"),
993 sa.map(|s| s.status.to_string()).unwrap_or("-".into()),
994 sb.map(|s| s.status.to_string()).unwrap_or("-".into()),
995 ));
996 table.add_row(row(
997 &format!("{name} duration"),
998 sa.map(|s| format_duration_ms(s.duration_ms))
999 .unwrap_or("-".into()),
1000 sb.map(|s| format_duration_ms(s.duration_ms))
1001 .unwrap_or("-".into()),
1002 ));
1003 table.add_row(row(
1004 &format!("{name} cost"),
1005 sa.map(|s| format!("${:.4}", s.cost_usd))
1006 .unwrap_or("-".into()),
1007 sb.map(|s| format!("${:.4}", s.cost_usd))
1008 .unwrap_or("-".into()),
1009 ));
1010 }
1011 table
1012}
1013
1014fn short_id(id: Uuid) -> String {
1016 id.to_string()
1017 .split('-')
1018 .next()
1019 .unwrap_or_default()
1020 .to_string()
1021}
1022
1023fn format_optional_id(id: &Option<Uuid>) -> String {
1025 id.map_or_else(|| "-".to_string(), short_id)
1026}
1027
1028pub fn audit_logs_table(entries: &[AuditLogEntry]) -> Table {
1033 let mut table = base_table();
1034 table.set_header(vec!["ID", "Type", "Run", "Step", "User", "Created"]);
1035
1036 for entry in entries {
1037 table.add_row(vec![
1038 Cell::new(short_id(entry.id)),
1039 Cell::new(entry.event_type.to_string()),
1040 Cell::new(format_optional_id(&entry.run_id)),
1041 Cell::new(format_optional_id(&entry.step_id)),
1042 Cell::new(format_optional_id(&entry.user_id)),
1043 Cell::new(format_datetime(&entry.created_at)),
1044 ]);
1045 }
1046
1047 table
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052 use std::collections::HashMap;
1053 use std::slice;
1054
1055 use ironflow_sdk::types::{
1056 ApiKeyScope, ConditionResponse, CreatedBy, CreatedByKind, EventKind, TriggerKind,
1057 };
1058 use serde_json::{Map, Value};
1059
1060 use super::*;
1061
1062 fn run_fixture(created_by: CreatedBy) -> RunResponse {
1064 let now = Utc::now();
1065 RunResponse {
1066 id: Uuid::now_v7(),
1067 workflow_name: "deploy".to_string(),
1068 status: RunStatus::Completed,
1069 trigger: TriggerKind::Api,
1070 error: None,
1071 retry_count: 0,
1072 max_retries: 0,
1073 cost_usd: 0.0,
1074 duration_ms: 0,
1075 created_at: now,
1076 updated_at: now,
1077 started_at: None,
1078 completed_at: None,
1079 handler_version: None,
1080 labels: HashMap::new(),
1081 scheduled_at: None,
1082 created_by,
1083 idempotency_key: None,
1084 max_cost_usd: None,
1085 }
1086 }
1087
1088 #[test]
1089 fn format_cost_without_cap_shows_amount_only() {
1090 assert_eq!(format_cost(0.1234, None), "$0.1234");
1091 }
1092
1093 #[test]
1094 fn format_cost_with_cap_shows_both_amounts() {
1095 assert_eq!(format_cost(0.18, Some(2.0)), "$0.1800 / $2.00");
1096 }
1097
1098 #[test]
1099 fn cost_color_is_absent_without_a_cap() {
1100 assert_eq!(cost_color(999.0, None), None);
1101 }
1102
1103 #[test]
1104 fn cost_color_warns_past_the_threshold_and_alerts_at_the_cap() {
1105 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));
1108 assert_eq!(cost_color(2.0, Some(2.0)), Some(Color::Red)); assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); }
1111
1112 #[test]
1113 fn cost_color_handles_a_zero_cap() {
1114 assert_eq!(cost_color(0.0, Some(0.0)), None);
1115 assert_eq!(cost_color(0.01, Some(0.0)), Some(Color::Red));
1116 }
1117
1118 fn artifact(name: &str, size_bytes: i64) -> ArtifactResponse {
1119 ArtifactResponse {
1120 id: Uuid::now_v7(),
1121 step_id: Uuid::now_v7(),
1122 name: name.to_string(),
1123 content_type: "text/plain".to_string(),
1124 size_bytes,
1125 sha256: "0".repeat(64),
1126 created_at: Utc::now(),
1127 }
1128 }
1129
1130 #[test]
1131 fn format_bytes_keeps_raw_bytes_below_one_kilobyte() {
1132 assert_eq!(format_bytes(0), "0 B");
1133 assert_eq!(format_bytes(1023), "1023 B");
1134 }
1135
1136 #[test]
1137 fn format_bytes_switches_units_at_each_boundary() {
1138 assert_eq!(format_bytes(1024), "1.0 KB");
1139 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
1140 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
1141 }
1142
1143 #[test]
1144 fn format_bytes_drops_the_decimal_past_ten() {
1145 assert_eq!(format_bytes(145_408), "142 KB");
1146 }
1147
1148 #[test]
1149 fn format_artifacts_shows_a_dash_when_there_are_none() {
1150 assert_eq!(format_artifacts(&[]), "-");
1151 }
1152
1153 #[test]
1154 fn format_artifacts_shows_the_count_and_total_size() {
1155 let artifacts = vec![artifact("a.txt", 1024), artifact("b.txt", 1024)];
1156 assert_eq!(format_artifacts(&artifacts), "2 (2.0 KB)");
1157 }
1158
1159 #[test]
1160 fn format_duration_ms_millis() {
1161 assert_eq!(format_duration_ms(500), "500ms");
1162 assert_eq!(format_duration_ms(0), "0ms");
1163 }
1164
1165 #[test]
1166 fn format_duration_ms_seconds() {
1167 assert_eq!(format_duration_ms(5000), "5s");
1168 assert_eq!(format_duration_ms(59000), "59s");
1169 }
1170
1171 #[test]
1172 fn format_duration_ms_minutes() {
1173 assert_eq!(format_duration_ms(60000), "1m 0s");
1174 assert_eq!(format_duration_ms(125000), "2m 5s");
1175 }
1176
1177 #[test]
1178 fn format_duration_ms_hours() {
1179 assert_eq!(format_duration_ms(3_600_000), "1h 0m");
1180 assert_eq!(format_duration_ms(5_400_000), "1h 30m");
1181 }
1182
1183 #[test]
1184 fn format_sla_without_a_deadline_is_a_dash() {
1185 assert_eq!(format_remaining_secs(None), "-");
1186 }
1187
1188 #[test]
1189 fn format_sla_reports_an_elapsed_deadline_as_expired() {
1190 assert_eq!(format_remaining_secs(Some(0)), "expired");
1191 assert_eq!(format_remaining_secs(Some(-30)), "expired");
1192 }
1193
1194 #[test]
1195 fn format_sla_uses_coarse_units() {
1196 assert_eq!(format_remaining_secs(Some(45)), "45s");
1197 assert_eq!(format_remaining_secs(Some(59)), "59s");
1198 assert_eq!(format_remaining_secs(Some(60)), "1m");
1199 assert_eq!(format_remaining_secs(Some(750)), "12m 30s");
1200 assert_eq!(format_remaining_secs(Some(3599)), "59m 59s");
1201 assert_eq!(format_remaining_secs(Some(3600)), "1h");
1202 assert_eq!(format_remaining_secs(Some(4320)), "1h 12m");
1203 }
1204
1205 #[test]
1206 fn sla_has_no_colour_without_a_deadline() {
1207 assert_eq!(remaining_color(None, None), None);
1208 assert_eq!(remaining_color(None, Some(3600)), None);
1209 }
1210
1211 #[test]
1212 fn sla_turns_red_once_expired() {
1213 assert_eq!(remaining_color(Some(0), Some(3600)), Some(Color::Red));
1214 assert_eq!(remaining_color(Some(-1), None), Some(Color::Red));
1215 }
1216
1217 #[test]
1218 fn sla_turns_yellow_in_the_last_tenth_of_the_window() {
1219 assert_eq!(remaining_color(Some(359), Some(3600)), Some(Color::Yellow));
1220 assert_eq!(remaining_color(Some(360), Some(3600)), None);
1221 assert_eq!(remaining_color(Some(3000), Some(3600)), None);
1222 }
1223
1224 #[test]
1225 fn sla_has_no_colour_without_a_measurable_window() {
1226 assert_eq!(remaining_color(Some(120), None), None);
1227 assert_eq!(remaining_color(Some(120), Some(0)), None);
1228 }
1229
1230 #[test]
1231 fn format_optional_datetime_none() {
1232 assert_eq!(format_optional_datetime(&None), "-");
1233 }
1234
1235 #[test]
1236 fn format_optional_datetime_some() {
1237 let dt = "2026-06-02T14:30:00Z".parse::<DateTime<Utc>>().unwrap();
1238 assert_eq!(format_optional_datetime(&Some(dt)), "2026-06-02 14:30:00");
1239 }
1240
1241 #[test]
1242 fn status_colors_are_distinct() {
1243 let statuses = [
1244 RunStatus::Completed,
1245 RunStatus::Failed,
1246 RunStatus::Running,
1247 RunStatus::Pending,
1248 RunStatus::Cancelled,
1249 RunStatus::AwaitingApproval,
1250 RunStatus::Retrying,
1251 ];
1252
1253 let colors: Vec<Color> = statuses.iter().map(status_color).collect();
1254 for (i, c1) in colors.iter().enumerate() {
1255 for (j, c2) in colors.iter().enumerate() {
1256 if i != j {
1257 assert_ne!(c1, c2, "status colors must be distinct");
1258 }
1259 }
1260 }
1261 }
1262
1263 #[test]
1264 fn empty_runs_table_has_header() {
1265 let table = runs_table(&[]);
1266 let output = table.to_string();
1267 assert!(output.contains("ID"));
1268 assert!(output.contains("Workflow"));
1269 assert!(output.contains("Status"));
1270 assert!(output.contains("Triggered by"));
1271 }
1272
1273 #[test]
1274 fn runs_table_renders_the_author_label() {
1275 let run = run_fixture(CreatedBy {
1276 kind: CreatedByKind::ApiKey,
1277 id: Some(Uuid::now_v7()),
1278 label: "ci-deploy (alice)".to_string(),
1279 });
1280
1281 let output = runs_table(slice::from_ref(&run)).to_string();
1282 assert!(
1283 output.contains("ci-deploy (alice)"),
1284 "author missing from:\n{output}"
1285 );
1286 }
1287
1288 #[test]
1289 fn run_detail_table_renders_the_author_label() {
1290 let detail = RunDetailResponse {
1291 run: run_fixture(CreatedBy {
1292 kind: CreatedByKind::System,
1293 id: None,
1294 label: "/hooks/github".to_string(),
1295 }),
1296 steps: Vec::new(),
1297 payload: Value::Object(Map::new()),
1298 };
1299
1300 let output = run_detail_table(&detail).to_string();
1301 assert!(output.contains("Triggered by"));
1302 assert!(
1303 output.contains("/hooks/github"),
1304 "author missing from:\n{output}"
1305 );
1306 }
1307
1308 #[test]
1309 fn empty_workflows_table_has_header() {
1310 let table = workflows_table(&[]);
1311 let output = table.to_string();
1312 assert!(output.contains("Name"));
1313 assert!(output.contains("Category"));
1314 }
1315
1316 fn secret_fixture(key: &str) -> SecretResponse {
1319 let now = Utc::now();
1320 SecretResponse {
1321 id: Uuid::now_v7(),
1322 key: key.to_string(),
1323 created_at: now,
1324 updated_at: now,
1325 }
1326 }
1327
1328 #[test]
1329 fn empty_secrets_table_has_header() {
1330 let output = secrets_table(&[]).to_string();
1331 assert!(output.contains("Key"));
1332 assert!(output.contains("Created"));
1333 assert!(output.contains("Updated"));
1334 }
1335
1336 #[test]
1337 fn secrets_table_renders_the_key() {
1338 let secret = secret_fixture("workflows/inbox/gmail_token");
1339 let output = secrets_table(slice::from_ref(&secret)).to_string();
1340 assert!(output.contains("workflows/inbox/gmail_token"), "{output}");
1341 }
1342
1343 #[test]
1346 fn a_secret_response_carries_no_value_at_all() {
1347 let secret = secret_fixture("db/password");
1348 let json = serde_json::to_string(&secret).unwrap();
1349 assert!(!json.contains("value"), "{json}");
1350 }
1351
1352 fn api_key_fixture() -> ApiKeyResponse {
1355 ApiKeyResponse {
1356 id: Uuid::now_v7(),
1357 name: "ci-deploy".to_string(),
1358 key_prefix: "ifk_abcd".to_string(),
1359 scopes: vec![ApiKeyScope::RunsRead, ApiKeyScope::RunsWrite],
1360 is_active: true,
1361 created_at: Utc::now(),
1362 expires_at: None,
1363 last_used_at: None,
1364 rate_limit_override: None,
1365 }
1366 }
1367
1368 #[test]
1369 fn empty_api_keys_table_has_header() {
1370 let output = api_keys_table(&[]).to_string();
1371 for header in ["ID", "Name", "Prefix", "Scopes", "Active"] {
1372 assert!(output.contains(header), "missing {header} in {output}");
1373 }
1374 }
1375
1376 #[test]
1377 fn api_keys_table_joins_the_scopes() {
1378 let key = api_key_fixture();
1379 let output = api_keys_table(slice::from_ref(&key)).to_string();
1380 assert!(output.contains("runs_read, runs_write"), "{output}");
1381 assert!(output.contains("ifk_abcd"), "{output}");
1382 }
1383
1384 #[test]
1385 fn created_api_key_table_shows_the_raw_key() {
1386 let created = CreateApiKeyResponse {
1387 id: Uuid::now_v7(),
1388 name: "ci-deploy".to_string(),
1389 key: "ifk_full_raw_key".to_string(),
1390 key_prefix: "ifk_full".to_string(),
1391 scopes: vec![ApiKeyScope::Admin],
1392 created_at: Utc::now(),
1393 expires_at: None,
1394 rate_limit_override: None,
1395 };
1396
1397 let output = created_api_key_table(&created).to_string();
1398 assert!(output.contains("ifk_full_raw_key"), "{output}");
1399 }
1400
1401 #[test]
1402 fn empty_scopes_table_has_header() {
1403 let output = scopes_table(&[]).to_string();
1404 assert!(output.contains("Value"));
1405 assert!(output.contains("Description"));
1406 }
1407
1408 fn user_fixture(is_admin: bool) -> UserResponse {
1411 let now = Utc::now();
1412 UserResponse {
1413 id: Uuid::now_v7(),
1414 username: "alice".to_string(),
1415 email: "alice@example.com".to_string(),
1416 is_admin,
1417 created_at: now,
1418 updated_at: now,
1419 }
1420 }
1421
1422 #[test]
1423 fn empty_users_table_has_header() {
1424 let output = users_table(&[]).to_string();
1425 for header in ["ID", "Username", "Email", "Admin", "Created"] {
1426 assert!(output.contains(header), "missing {header} in {output}");
1427 }
1428 }
1429
1430 #[test]
1431 fn users_table_spells_out_the_role() {
1432 let admin = user_fixture(true);
1433 assert!(
1434 users_table(slice::from_ref(&admin))
1435 .to_string()
1436 .contains("yes")
1437 );
1438
1439 let member = user_fixture(false);
1440 assert!(
1441 users_table(slice::from_ref(&member))
1442 .to_string()
1443 .contains("no")
1444 );
1445 }
1446
1447 #[test]
1450 fn empty_audit_logs_table_has_header() {
1451 let output = audit_logs_table(&[]).to_string();
1452 for header in ["ID", "Type", "Run", "Step", "User", "Created"] {
1453 assert!(output.contains(header), "missing {header} in {output}");
1454 }
1455 }
1456
1457 #[test]
1458 fn audit_logs_table_omits_the_payload() {
1459 let entry = AuditLogEntry {
1460 id: Uuid::now_v7(),
1461 event_type: EventKind::RunCreated,
1462 payload: Value::Object(Map::new()),
1463 run_id: Some(Uuid::now_v7()),
1464 step_id: None,
1465 user_id: None,
1466 created_at: Utc::now(),
1467 };
1468
1469 let output = audit_logs_table(slice::from_ref(&entry)).to_string();
1470 assert!(output.contains("run_created"), "{output}");
1471 assert!(output.contains('-'), "{output}");
1473 }
1474
1475 #[test]
1476 fn format_optional_id_shortens_and_falls_back() {
1477 assert_eq!(format_optional_id(&None), "-");
1478 let id = Uuid::now_v7();
1479 let short = format_optional_id(&Some(id));
1480 assert_eq!(short, id.to_string().split('-').next().unwrap());
1481 }
1482
1483 #[test]
1486 fn deleted_table_reports_the_kind_and_id() {
1487 let deleted = Deleted::new("secret", "db/password");
1488 let output = deleted_table(&deleted).to_string();
1489 assert!(output.contains("secret"), "{output}");
1490 assert!(output.contains("db/password"), "{output}");
1491
1492 let json = serde_json::to_string(&deleted).unwrap();
1493 assert!(json.contains(r#""deleted":true"#), "{json}");
1494 }
1495
1496 fn planned_step(name: &str, kind: &str, parallel_group: Option<&str>) -> PlannedStepResponse {
1499 PlannedStepResponse {
1500 name: name.to_string(),
1501 kind: kind.to_string(),
1502 workflow: "deploy".to_string(),
1503 depth: 0,
1504 depends_on: Vec::new(),
1505 condition: None,
1506 parallel_group: parallel_group.map(str::to_string),
1507 estimated_duration_ms: None,
1508 }
1509 }
1510
1511 fn plan_fixture(steps: Vec<PlannedStepResponse>) -> ExecutionPlanResponse {
1512 ExecutionPlanResponse {
1513 workflow: "deploy".to_string(),
1514 steps,
1515 estimated_duration_ms: None,
1516 max_depth: 3,
1517 truncated: false,
1518 incomplete_reason: None,
1519 }
1520 }
1521
1522 #[test]
1523 fn execution_plan_tree_lists_step_names_and_kinds() {
1524 let plan = plan_fixture(vec![
1525 planned_step("build", "shell", None),
1526 planned_step("deploy", "shell", None),
1527 ]);
1528
1529 let output = execution_plan_tree(&plan);
1530 assert!(output.contains("workflow deploy"), "{output}");
1531 assert!(output.contains("build [shell]"), "{output}");
1532 assert!(output.contains("deploy [shell]"), "{output}");
1533 }
1534
1535 #[test]
1536 fn execution_plan_tree_prints_a_parallel_group_header_once() {
1537 let plan = plan_fixture(vec![
1538 planned_step("build", "shell", None),
1539 planned_step("test", "shell", Some("parallel-1")),
1540 planned_step("lint", "shell", Some("parallel-1")),
1541 ]);
1542
1543 let output = execution_plan_tree(&plan);
1544 assert_eq!(output.matches("parallel-1").count(), 1, "{output}");
1545 }
1546
1547 #[test]
1548 fn execution_plan_tree_shows_the_estimate_when_present() {
1549 let mut step = planned_step("build", "shell", None);
1550 step.estimated_duration_ms = Some(5000);
1551 let mut plan = plan_fixture(vec![step]);
1552 plan.estimated_duration_ms = Some(5000);
1553
1554 let output = execution_plan_tree(&plan);
1555 assert!(output.contains("estimated ~5s"), "{output}");
1556 assert!(output.contains("build [shell] ~5s"), "{output}");
1557 }
1558
1559 #[test]
1560 fn execution_plan_tree_marks_conditions() {
1561 let mut evaluated = planned_step("deploy-prod", "shell", None);
1562 evaluated.condition = Some(ConditionResponse {
1563 state: "evaluated".to_string(),
1564 expression: Some("env == prod".to_string()),
1565 value: Some(true),
1566 reason: None,
1567 });
1568 let mut skipped = planned_step("deploy-dev", "skip", None);
1569 skipped.condition = Some(ConditionResponse {
1570 state: "skipped".to_string(),
1571 expression: None,
1572 value: None,
1573 reason: Some("not prod".to_string()),
1574 });
1575 let mut unevaluable = planned_step("notify", "http", None);
1576 unevaluable.condition = Some(ConditionResponse {
1577 state: "unevaluable".to_string(),
1578 expression: Some("build succeeded".to_string()),
1579 value: None,
1580 reason: Some("depends on a step output".to_string()),
1581 });
1582
1583 let output = execution_plan_tree(&plan_fixture(vec![evaluated, skipped, unevaluable]));
1584 assert!(output.contains("(when env == prod = true)"), "{output}");
1585 assert!(output.contains("(skipped: not prod)"), "{output}");
1586 assert!(
1587 output.contains("(condition unevaluable: build succeeded)"),
1588 "{output}"
1589 );
1590 }
1591
1592 #[test]
1593 fn execution_plan_tree_reports_an_incomplete_plan() {
1594 let mut plan = plan_fixture(vec![planned_step("build", "shell", None)]);
1595 plan.truncated = true;
1596 plan.incomplete_reason = Some("step cap of 1000 reached".to_string());
1597
1598 let output = execution_plan_tree(&plan);
1599 assert!(
1600 output.contains("plan incomplete: step cap of 1000 reached"),
1601 "{output}"
1602 );
1603 }
1604
1605 #[test]
1606 fn execution_plan_tree_indents_sub_workflow_steps() {
1607 let mut nested = planned_step("child-step", "shell", None);
1608 nested.depth = 1;
1609 let plan = plan_fixture(vec![planned_step("child", "workflow", None), nested]);
1610
1611 let output = execution_plan_tree(&plan);
1612 let nested = output
1613 .lines()
1614 .find(|l| l.contains("child-step"))
1615 .expect("nested line");
1616 assert!(nested.starts_with(" "), "{nested}");
1617 }
1618}