Skip to main content

ironflow_cli/
output.rs

1//! Output formatting for table and JSON modes.
2//!
3//! Provides helpers to render API responses as either a UTF-8 styled
4//! terminal table (with colored status) or raw JSON.
5
6use 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
23/// Map a [`RunStatus`] to a terminal color.
24fn 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
38/// Map a [`StepStatus`] to a terminal color.
39fn 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
51/// Format a [`DateTime`] as `YYYY-MM-DD HH:MM:SS`.
52fn format_datetime(dt: &DateTime<Utc>) -> String {
53    dt.format("%Y-%m-%d %H:%M:%S").to_string()
54}
55
56/// Format an optional [`DateTime`].
57fn format_optional_datetime(dt: &Option<DateTime<Utc>>) -> String {
58    dt.as_ref().map_or("-".to_string(), format_datetime)
59}
60
61/// Fraction of the original SLA window below which the countdown turns yellow.
62const SLA_WARNING_RATIO: f64 = 0.1;
63
64/// Format a countdown in seconds as a coarse duration.
65///
66/// `None` renders as `"-"` (no deadline), a non-positive count as `"expired"`.
67fn 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
98/// Colour for a countdown: red once expired, yellow in the last
99/// [`SLA_WARNING_RATIO`] of the window, plain otherwise.
100fn 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
114/// Format the remaining SLA of an approval gate.
115///
116/// Returns `"-"` for a step without a deadline, `"expired"` once the countdown
117/// reaches zero, and a coarse duration (`"45s"`, `"12m 30s"`, `"1h 12m"`)
118/// otherwise.
119fn format_sla(step: &StepResponse) -> String {
120    format_remaining_secs(step.approval_seconds_remaining)
121}
122
123/// Colour of the SLA cell.
124///
125/// The window is derived from the gate's own timestamps (`started_at` to
126/// `approval_deadline_at`), so no configuration parsing is needed.
127fn 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
135/// Format milliseconds as a human-readable duration.
136fn 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
154/// Create a base table with UTF-8 styling.
155fn 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
163/// Render a value as JSON or table into the given writer.
164///
165/// # Errors
166///
167/// Returns an error if JSON serialization or writing fails.
168pub 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
183/// Convenience wrapper: render to stdout.
184///
185/// # Errors
186///
187/// Returns an error if JSON serialization or writing fails.
188pub 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
196/// Render a value as pretty JSON to stdout.
197///
198/// For commands whose output is a summary the CLI builds itself, with no
199/// table equivalent.
200///
201/// # Errors
202///
203/// Returns an error if JSON serialization or writing fails.
204pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
205    let json = to_string_pretty(value)?;
206    writeln!(stdout().lock(), "{json}")?;
207    Ok(())
208}
209
210/// Render a list of runs as a table.
211/// Fraction of the cost cap above which the spend is highlighted.
212const COST_WARNING_RATIO: f64 = 0.8;
213
214/// Render a run's spend, with its cap when one is configured.
215///
216/// Without a cap this is the plain amount; with one it reads `$0.1800 / $2.00`.
217fn 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
224/// Highlight colour for a run's spend relative to its cap.
225///
226/// `None` means no highlight: either the run has no cap, or it is comfortably
227/// below it. Yellow past [`COST_WARNING_RATIO`] of the cap, red once the cap is
228/// reached. A zero cap has no meaningful ratio, so any spend counts as reached.
229fn 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
246/// Build the table cell for a run's spend, highlighted when close to its cap.
247fn 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
288/// Render a single run detail as a table.
289pub 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
346/// Summarize a step's artifacts as a count and a total size.
347///
348/// A dash when the step produced none, so the column stays scannable.
349fn 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
358/// Human-readable file size, using 1024-based units.
359fn 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
377/// Render a run's steps as a table.
378pub 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
420/// Render a list of workflows as a table.
421pub 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
436/// Render a workflow detail as a table.
437pub 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
470/// Render an execution plan as an indented tree.
471///
472/// One line per step. Members of a parallel wave sit under a `parallel-N`
473/// header and are indented one extra level; sub-workflow steps are indented by
474/// their depth. A step carrying a condition shows why the planner took that
475/// branch.
476///
477/// # Examples
478///
479/// ```no_run
480/// use ironflow_cli::output::execution_plan_tree;
481/// use ironflow_sdk::types::ExecutionPlanResponse;
482///
483/// # fn example(plan: &ExecutionPlanResponse) {
484/// println!("{}", execution_plan_tree(plan));
485/// # }
486/// ```
487pub 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
530/// Two spaces per sub-workflow level.
531fn indent(depth: usize) -> String {
532    "  ".repeat(depth)
533}
534
535/// Sub-workflow depth of a step as an indent level.
536fn depth_of(step: &PlannedStepResponse) -> usize {
537    usize::try_from(step.depth).unwrap_or(0)
538}
539
540/// Whether no later step sits at the same depth, making this the last branch.
541fn 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
546/// `name [kind] ~duration (condition)` for one planned step.
547fn 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
576/// Print an execution plan as JSON or as a tree.
577///
578/// # Errors
579///
580/// Returns an error if serialization or writing fails.
581pub 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
595/// Render stats as a table.
596pub 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
633/// Render historical stats as a table.
634pub 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
661/// Render a list of key versions as a comma-separated string.
662fn 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/// Outcome of a `delete` command.
674///
675/// The API answers `204 No Content`, which serializes to nothing useful, so the
676/// CLI reports the deletion itself and keeps `--json` machine-readable.
677///
678/// # Examples
679///
680/// ```
681/// use ironflow_cli::output::Deleted;
682///
683/// let deleted = Deleted::new("secret", "db/password");
684/// assert_eq!(deleted.kind, "secret");
685/// ```
686#[derive(Debug, Serialize)]
687pub struct Deleted {
688    /// What was deleted (`secret`, `api-key`, `user`).
689    pub kind: &'static str,
690    /// Identifier of the deleted resource.
691    pub id: String,
692    /// Always `true`; present so consumers can match on a stable shape.
693    pub deleted: bool,
694}
695
696impl Deleted {
697    /// Build a deletion report.
698    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
707/// Render a deletion report as a table.
708pub 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
715/// Report a deletion on stdout, as a table or as JSON.
716///
717/// # Errors
718///
719/// Returns an error if JSON serialization or writing fails.
720///
721/// # Examples
722///
723/// ```no_run
724/// use ironflow_cli::output::report_deletion;
725///
726/// # fn example() -> anyhow::Result<()> {
727/// report_deletion(false, "secret", "db/password")?;
728/// # Ok(())
729/// # }
730/// ```
731pub 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
736/// Render a list of secrets as a table.
737///
738/// [`SecretResponse`] carries no value field, so no secret material can reach
739/// this table by construction.
740pub 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
755/// Join the scopes of an API key into a single cell value.
756fn format_scopes(scopes: &[ApiKeyScope]) -> String {
757    scopes
758        .iter()
759        .map(ToString::to_string)
760        .collect::<Vec<_>>()
761        .join(", ")
762}
763
764/// Render the encryption key ring status as a table.
765pub 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
801/// Render a list of API keys as a table.
802///
803/// [`ApiKeyResponse`] never carries the raw key, only its prefix.
804pub 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
848/// Render a freshly created API key, including its one-time raw secret.
849///
850/// This is the only place the raw key is ever rendered: the API returns it once
851/// at creation and never again, so withholding it would make the command
852/// useless.
853pub 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
886/// Render the available API key scopes as a table.
887pub 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
902/// Render a list of users as a table.
903pub 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
928/// Render a side-by-side comparison of two runs of the same workflow.
929pub 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
1014/// Render a UUID as its first hyphen-separated group, enough to spot a row.
1015fn short_id(id: Uuid) -> String {
1016    id.to_string()
1017        .split('-')
1018        .next()
1019        .unwrap_or_default()
1020        .to_string()
1021}
1022
1023/// Render a UUID as a short prefix, or `-` when absent.
1024fn format_optional_id(id: &Option<Uuid>) -> String {
1025    id.map_or_else(|| "-".to_string(), short_id)
1026}
1027
1028/// Render a list of audit log entries as a table.
1029///
1030/// The event payload is omitted: it is arbitrary JSON that would wreck the
1031/// table layout. Use `--json` to get it.
1032pub 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    /// Minimal run whose only meaningful field is its author.
1063    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); // 50%
1106        assert_eq!(cost_color(1.6, Some(2.0)), Some(Color::Yellow)); // 80%
1107        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)); // at cap
1109        assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); // over cap
1110    }
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    // ── Secrets ────────────────────────────────────────────────
1317
1318    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    /// The value never even reaches this layer: `SecretResponse` has no such
1344    /// field. Rendering it as JSON proves the whole payload is value-free.
1345    #[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    // ── API keys ───────────────────────────────────────────────
1353
1354    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    // ── Users ──────────────────────────────────────────────────
1409
1410    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    // ── Audit logs ─────────────────────────────────────────────
1448
1449    #[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        // Absent IDs collapse to a dash rather than an empty cell.
1472        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    // ── Deletions ──────────────────────────────────────────────
1484
1485    #[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    // ── Execution plans ────────────────────────────────────────
1497
1498    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}