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