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, UserGroupsResponse, UserResponse, WorkflowDetailResponse,
18    WorkflowSummary,
19};
20use serde::Serialize;
21use serde_json::to_string_pretty;
22use uuid::Uuid;
23
24/// Map a [`RunStatus`] to a terminal color.
25fn 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
39/// Map a [`StepStatus`] to a terminal color.
40fn 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
52/// Format a [`DateTime`] as `YYYY-MM-DD HH:MM:SS`.
53fn format_datetime(dt: &DateTime<Utc>) -> String {
54    dt.format("%Y-%m-%d %H:%M:%S").to_string()
55}
56
57/// Format an optional [`DateTime`].
58fn format_optional_datetime(dt: &Option<DateTime<Utc>>) -> String {
59    dt.as_ref().map_or("-".to_string(), format_datetime)
60}
61
62/// Fraction of the original SLA window below which the countdown turns yellow.
63const SLA_WARNING_RATIO: f64 = 0.1;
64
65/// Format a countdown in seconds as a coarse duration.
66///
67/// `None` renders as `"-"` (no deadline), a non-positive count as `"expired"`.
68fn 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
99/// Colour for a countdown: red once expired, yellow in the last
100/// [`SLA_WARNING_RATIO`] of the window, plain otherwise.
101fn 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
115/// Format the remaining SLA of an approval gate.
116///
117/// Returns `"-"` for a step without a deadline, `"expired"` once the countdown
118/// reaches zero, and a coarse duration (`"45s"`, `"12m 30s"`, `"1h 12m"`)
119/// otherwise.
120fn format_sla(step: &StepResponse) -> String {
121    format_remaining_secs(step.approval_seconds_remaining)
122}
123
124/// Colour of the SLA cell.
125///
126/// The window is derived from the gate's own timestamps (`started_at` to
127/// `approval_deadline_at`), so no configuration parsing is needed.
128fn 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
136/// Format milliseconds as a human-readable duration.
137fn 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
155/// Create a base table with UTF-8 styling.
156fn 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
164/// Render a value as JSON or table into the given writer.
165///
166/// # Errors
167///
168/// Returns an error if JSON serialization or writing fails.
169pub 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
184/// Convenience wrapper: render to stdout.
185///
186/// # Errors
187///
188/// Returns an error if JSON serialization or writing fails.
189pub 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
197/// Render a value as pretty JSON to stdout.
198///
199/// For commands whose output is a summary the CLI builds itself, with no
200/// table equivalent.
201///
202/// # Errors
203///
204/// Returns an error if JSON serialization or writing fails.
205pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
206    let json = to_string_pretty(value)?;
207    writeln!(stdout().lock(), "{json}")?;
208    Ok(())
209}
210
211/// Render a list of runs as a table.
212/// Fraction of the cost cap above which the spend is highlighted.
213const COST_WARNING_RATIO: f64 = 0.8;
214
215/// Render a run's spend, with its cap when one is configured.
216///
217/// Without a cap this is the plain amount; with one it reads `$0.1800 / $2.00`.
218fn 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
225/// Highlight colour for a run's spend relative to its cap.
226///
227/// `None` means no highlight: either the run has no cap, or it is comfortably
228/// below it. Yellow past [`COST_WARNING_RATIO`] of the cap, red once the cap is
229/// reached. A zero cap has no meaningful ratio, so any spend counts as reached.
230fn 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
247/// Build the table cell for a run's spend, highlighted when close to its cap.
248fn 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
289/// Render a single run detail as a table.
290pub 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
347/// Summarize a step's artifacts as a count and a total size.
348///
349/// A dash when the step produced none, so the column stays scannable.
350fn 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
359/// Human-readable file size, using 1024-based units.
360fn 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
378/// Render a run's steps as a table.
379pub 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
421/// Render a list of workflows as a table.
422pub 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
437/// Render a workflow detail as a table.
438pub 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
471/// Render an execution plan as an indented tree.
472///
473/// One line per step. Members of a parallel wave sit under a `parallel-N`
474/// header and are indented one extra level; sub-workflow steps are indented by
475/// their depth. A step carrying a condition shows why the planner took that
476/// branch.
477///
478/// # Examples
479///
480/// ```no_run
481/// use ironflow_cli::output::execution_plan_tree;
482/// use ironflow_sdk::types::ExecutionPlanResponse;
483///
484/// # fn example(plan: &ExecutionPlanResponse) {
485/// println!("{}", execution_plan_tree(plan));
486/// # }
487/// ```
488pub 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
531/// Two spaces per sub-workflow level.
532fn indent(depth: usize) -> String {
533    "  ".repeat(depth)
534}
535
536/// Sub-workflow depth of a step as an indent level.
537fn depth_of(step: &PlannedStepResponse) -> usize {
538    usize::try_from(step.depth).unwrap_or(0)
539}
540
541/// Whether no later step sits at the same depth, making this the last branch.
542fn 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
547/// `name [kind] ~duration (condition)` for one planned step.
548fn 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
577/// Print an execution plan as JSON or as a tree.
578///
579/// # Errors
580///
581/// Returns an error if serialization or writing fails.
582pub 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
596/// Render stats as a table.
597pub 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
638/// Render historical stats as a table.
639pub 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
677/// Render an optional success rate: `-` when the bucket has no finished run.
678fn format_success_rate(rate: Option<f64>) -> String {
679    rate.map_or_else(|| "-".to_string(), |r| format!("{r:.1}%"))
680}
681
682/// Render a list of key versions as a comma-separated string.
683fn 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/// Outcome of a `delete` command.
695///
696/// The API answers `204 No Content`, which serializes to nothing useful, so the
697/// CLI reports the deletion itself and keeps `--json` machine-readable.
698///
699/// # Examples
700///
701/// ```
702/// use ironflow_cli::output::Deleted;
703///
704/// let deleted = Deleted::new("secret", "db/password");
705/// assert_eq!(deleted.kind, "secret");
706/// ```
707#[derive(Debug, Serialize)]
708pub struct Deleted {
709    /// What was deleted (`secret`, `api-key`, `user`).
710    pub kind: &'static str,
711    /// Identifier of the deleted resource.
712    pub id: String,
713    /// Always `true`; present so consumers can match on a stable shape.
714    pub deleted: bool,
715}
716
717impl Deleted {
718    /// Build a deletion report.
719    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
728/// Render a deletion report as a table.
729pub 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
736/// Report a deletion on stdout, as a table or as JSON.
737///
738/// # Errors
739///
740/// Returns an error if JSON serialization or writing fails.
741///
742/// # Examples
743///
744/// ```no_run
745/// use ironflow_cli::output::report_deletion;
746///
747/// # fn example() -> anyhow::Result<()> {
748/// report_deletion(false, "secret", "db/password")?;
749/// # Ok(())
750/// # }
751/// ```
752pub 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
757/// Render a list of secrets as a table.
758///
759/// [`SecretResponse`] carries no value field, so no secret material can reach
760/// this table by construction.
761pub 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
776/// Join the scopes of an API key into a single cell value.
777fn format_scopes(scopes: &[ApiKeyScope]) -> String {
778    scopes
779        .iter()
780        .map(ToString::to_string)
781        .collect::<Vec<_>>()
782        .join(", ")
783}
784
785/// Render the encryption key ring status as a table.
786pub 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
822/// Render a list of API keys as a table.
823///
824/// [`ApiKeyResponse`] never carries the raw key, only its prefix.
825pub 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
869/// Render a freshly created API key, including its one-time raw secret.
870///
871/// This is the only place the raw key is ever rendered: the API returns it once
872/// at creation and never again, so withholding it would make the command
873/// useless.
874pub 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
907/// Render the available API key scopes as a table.
908pub 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
923/// Render a list of users as a table.
924pub 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
949/// Render a user's group memberships.
950pub 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
964/// Render a side-by-side comparison of two runs of the same workflow.
965pub 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
1050/// Render a UUID as its first hyphen-separated group, enough to spot a row.
1051fn short_id(id: Uuid) -> String {
1052    id.to_string()
1053        .split('-')
1054        .next()
1055        .unwrap_or_default()
1056        .to_string()
1057}
1058
1059/// Render a UUID as a short prefix, or `-` when absent.
1060fn format_optional_id(id: &Option<Uuid>) -> String {
1061    id.map_or_else(|| "-".to_string(), short_id)
1062}
1063
1064/// Render a list of audit log entries as a table.
1065///
1066/// The event payload is omitted: it is arbitrary JSON that would wreck the
1067/// table layout. Use `--json` to get it.
1068pub 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    /// Minimal run whose only meaningful field is its author.
1099    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); // 50%
1154        assert_eq!(cost_color(1.6, Some(2.0)), Some(Color::Yellow)); // 80%
1155        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)); // at cap
1157        assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); // over cap
1158    }
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    // ── Secrets ────────────────────────────────────────────────
1365
1366    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    /// The value never even reaches this layer: `SecretResponse` has no such
1392    /// field. Rendering it as JSON proves the whole payload is value-free.
1393    #[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    // ── API keys ───────────────────────────────────────────────
1401
1402    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    // ── Users ──────────────────────────────────────────────────
1457
1458    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    // ── Audit logs ─────────────────────────────────────────────
1522
1523    #[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        // Absent IDs collapse to a dash rather than an empty cell.
1546        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    // ── Deletions ──────────────────────────────────────────────
1558
1559    #[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    // ── Execution plans ────────────────────────────────────────
1571
1572    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}