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