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