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