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