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::types::{
13    ApiKeyResponse, ApiKeyScope, ArtifactResponse, AuditLogEntry, CreateApiKeyResponse,
14    KeyVersionsResponse, RunDetailResponse, RunResponse, RunStatus, ScopeEntry, SecretResponse,
15    StatsResponse, StepResponse, StepStatus, UserResponse, WorkflowDetailResponse, WorkflowSummary,
16};
17use serde::Serialize;
18use serde_json::to_string_pretty;
19use uuid::Uuid;
20
21/// Map a [`RunStatus`] to a terminal color.
22fn status_color(status: &RunStatus) -> Color {
23    match status {
24        RunStatus::Completed => Color::Green,
25        RunStatus::Failed => Color::Red,
26        RunStatus::Running => Color::Blue,
27        RunStatus::Pending => Color::Yellow,
28        RunStatus::Cancelled => Color::Grey,
29        RunStatus::AwaitingApproval => Color::Magenta,
30        RunStatus::Retrying => Color::Cyan,
31        RunStatus::Warning => Color::DarkYellow,
32    }
33}
34
35/// Map a [`StepStatus`] to a terminal color.
36fn step_status_color(status: &StepStatus) -> Color {
37    match status {
38        StepStatus::Completed => Color::Green,
39        StepStatus::Failed => Color::Red,
40        StepStatus::Running => Color::Blue,
41        StepStatus::Pending => Color::Yellow,
42        StepStatus::Skipped => Color::Grey,
43        StepStatus::AwaitingApproval => Color::Magenta,
44        StepStatus::Rejected => Color::Red,
45    }
46}
47
48/// Format a [`DateTime`] as `YYYY-MM-DD HH:MM:SS`.
49fn format_datetime(dt: &DateTime<Utc>) -> String {
50    dt.format("%Y-%m-%d %H:%M:%S").to_string()
51}
52
53/// Format an optional [`DateTime`].
54fn format_optional_datetime(dt: &Option<DateTime<Utc>>) -> String {
55    dt.as_ref().map_or("-".to_string(), format_datetime)
56}
57
58/// Format milliseconds as a human-readable duration.
59fn format_duration_ms(ms: i64) -> String {
60    if ms < 1000 {
61        return format!("{ms}ms");
62    }
63    let secs = ms / 1000;
64    if secs < 60 {
65        return format!("{secs}s");
66    }
67    let mins = secs / 60;
68    let remaining_secs = secs % 60;
69    if mins < 60 {
70        return format!("{mins}m {remaining_secs}s");
71    }
72    let hours = mins / 60;
73    let remaining_mins = mins % 60;
74    format!("{hours}h {remaining_mins}m")
75}
76
77/// Create a base table with UTF-8 styling.
78fn base_table() -> Table {
79    let mut table = Table::new();
80    table
81        .load_preset(UTF8_FULL)
82        .set_content_arrangement(ContentArrangement::Dynamic);
83    table
84}
85
86/// Render a value as JSON or table into the given writer.
87///
88/// # Errors
89///
90/// Returns an error if JSON serialization or writing fails.
91pub fn render_output<W: Write, T: Serialize>(
92    writer: &mut W,
93    json_mode: bool,
94    value: &T,
95    table_fn: impl FnOnce() -> Table,
96) -> Result<()> {
97    if json_mode {
98        let json = to_string_pretty(value)?;
99        writeln!(writer, "{json}")?;
100    } else {
101        writeln!(writer, "{}", table_fn())?;
102    }
103    Ok(())
104}
105
106/// Convenience wrapper: render to stdout.
107///
108/// # Errors
109///
110/// Returns an error if JSON serialization or writing fails.
111pub fn print_output<T: Serialize>(
112    json_mode: bool,
113    value: &T,
114    table_fn: impl FnOnce() -> Table,
115) -> Result<()> {
116    render_output(&mut stdout().lock(), json_mode, value, table_fn)
117}
118
119/// Render a value as pretty JSON to stdout.
120///
121/// For commands whose output is a summary the CLI builds itself, with no
122/// table equivalent.
123///
124/// # Errors
125///
126/// Returns an error if JSON serialization or writing fails.
127pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
128    let json = to_string_pretty(value)?;
129    writeln!(stdout().lock(), "{json}")?;
130    Ok(())
131}
132
133/// Render a list of runs as a table.
134/// Fraction of the cost cap above which the spend is highlighted.
135const COST_WARNING_RATIO: f64 = 0.8;
136
137/// Render a run's spend, with its cap when one is configured.
138///
139/// Without a cap this is the plain amount; with one it reads `$0.1800 / $2.00`.
140fn format_cost(cost_usd: f64, max_cost_usd: Option<f64>) -> String {
141    match max_cost_usd {
142        Some(cap) => format!("${cost_usd:.4} / ${cap:.2}"),
143        None => format!("${cost_usd:.4}"),
144    }
145}
146
147/// Highlight colour for a run's spend relative to its cap.
148///
149/// `None` means no highlight: either the run has no cap, or it is comfortably
150/// below it. Yellow past [`COST_WARNING_RATIO`] of the cap, red once the cap is
151/// reached. A zero cap has no meaningful ratio, so any spend counts as reached.
152fn cost_color(cost_usd: f64, max_cost_usd: Option<f64>) -> Option<Color> {
153    let cap = max_cost_usd?;
154
155    if cap <= 0.0 {
156        return (cost_usd > 0.0).then_some(Color::Red);
157    }
158
159    let ratio = cost_usd / cap;
160    if ratio >= 1.0 {
161        Some(Color::Red)
162    } else if ratio >= COST_WARNING_RATIO {
163        Some(Color::Yellow)
164    } else {
165        None
166    }
167}
168
169/// Build the table cell for a run's spend, highlighted when close to its cap.
170fn cost_cell(cost_usd: f64, max_cost_usd: Option<f64>) -> Cell {
171    let cell = Cell::new(format_cost(cost_usd, max_cost_usd));
172    match cost_color(cost_usd, max_cost_usd) {
173        Some(color) => cell.fg(color),
174        None => cell,
175    }
176}
177
178pub fn runs_table(runs: &[RunResponse]) -> Table {
179    let mut table = base_table();
180    table.set_header(vec![
181        "ID",
182        "Workflow",
183        "Status",
184        "Triggered by",
185        "Duration",
186        "Cost",
187        "Created",
188        "Started",
189    ]);
190
191    for run in runs {
192        let status_cell = Cell::new(run.status)
193            .fg(status_color(&run.status))
194            .set_alignment(CellAlignment::Center);
195
196        table.add_row(vec![
197            Cell::new(run.id.to_string().split('-').next().unwrap_or("")),
198            Cell::new(&run.workflow_name),
199            status_cell,
200            Cell::new(&run.created_by.label),
201            Cell::new(format_duration_ms(run.duration_ms)),
202            cost_cell(run.cost_usd, run.max_cost_usd),
203            Cell::new(format_datetime(&run.created_at)),
204            Cell::new(format_optional_datetime(&run.started_at)),
205        ]);
206    }
207
208    table
209}
210
211/// Render a single run detail as a table.
212pub fn run_detail_table(detail: &RunDetailResponse) -> Table {
213    let run = &detail.run;
214    let mut table = base_table();
215    table.set_header(vec!["Field", "Value"]);
216
217    let status_cell = Cell::new(run.status).fg(status_color(&run.status));
218
219    table.add_row(vec![Cell::new("ID"), Cell::new(run.id)]);
220    table.add_row(vec![Cell::new("Workflow"), Cell::new(&run.workflow_name)]);
221    table.add_row(vec![Cell::new("Status"), status_cell]);
222    table.add_row(vec![
223        Cell::new("Trigger"),
224        Cell::new(format!("{:?}", run.trigger)),
225    ]);
226    table.add_row(vec![
227        Cell::new("Triggered by"),
228        Cell::new(&run.created_by.label),
229    ]);
230    table.add_row(vec![
231        Cell::new("Duration"),
232        Cell::new(format_duration_ms(run.duration_ms)),
233    ]);
234    table.add_row(vec![
235        Cell::new("Cost"),
236        cost_cell(run.cost_usd, run.max_cost_usd),
237    ]);
238    table.add_row(vec![
239        Cell::new("Created"),
240        Cell::new(format_datetime(&run.created_at)),
241    ]);
242    table.add_row(vec![
243        Cell::new("Started"),
244        Cell::new(format_optional_datetime(&run.started_at)),
245    ]);
246    table.add_row(vec![
247        Cell::new("Completed"),
248        Cell::new(format_optional_datetime(&run.completed_at)),
249    ]);
250    table.add_row(vec![
251        Cell::new("Retries"),
252        Cell::new(format!("{}/{}", run.retry_count, run.max_retries)),
253    ]);
254
255    if let Some(ref error) = run.error {
256        table.add_row(vec![Cell::new("Error"), Cell::new(error).fg(Color::Red)]);
257    }
258
259    if !detail.steps.is_empty() {
260        table.add_row(vec![
261            Cell::new("Steps"),
262            Cell::new(format!("{} step(s)", detail.steps.len())),
263        ]);
264    }
265
266    table
267}
268
269/// Summarize a step's artifacts as a count and a total size.
270///
271/// A dash when the step produced none, so the column stays scannable.
272fn format_artifacts(artifacts: &[ArtifactResponse]) -> String {
273    if artifacts.is_empty() {
274        return "-".to_string();
275    }
276
277    let total: i64 = artifacts.iter().map(|artifact| artifact.size_bytes).sum();
278    format!("{} ({})", artifacts.len(), format_bytes(total))
279}
280
281/// Human-readable file size, using 1024-based units.
282fn format_bytes(bytes: i64) -> String {
283    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
284
285    if bytes < 1024 {
286        return format!("{bytes} B");
287    }
288
289    let mut value = bytes as f64;
290    let mut unit = 0;
291    while value >= 1024.0 && unit < UNITS.len() - 1 {
292        value /= 1024.0;
293        unit += 1;
294    }
295
296    let decimals = if value < 10.0 { 1 } else { 0 };
297    format!("{value:.decimals$} {}", UNITS[unit])
298}
299
300/// Render a run's steps as a table.
301pub fn steps_table(steps: &[StepResponse]) -> Table {
302    let mut table = base_table();
303    table.set_header(vec![
304        "ID",
305        "Name",
306        "Status",
307        "Attempt",
308        "Duration",
309        "Cost",
310        "Artifacts",
311        "Started",
312        "Completed",
313    ]);
314
315    for step in steps {
316        let color = step_status_color(&step.status);
317
318        table.add_row(vec![
319            Cell::new(step.id.to_string().split('-').next().unwrap_or("")),
320            Cell::new(&step.name),
321            Cell::new(step.status)
322                .fg(color)
323                .set_alignment(CellAlignment::Center),
324            Cell::new(step.attempt).set_alignment(CellAlignment::Center),
325            Cell::new(format_duration_ms(step.duration_ms)),
326            Cell::new(format!("${:.4}", step.cost_usd)),
327            Cell::new(format_artifacts(&step.artifacts)).set_alignment(CellAlignment::Center),
328            Cell::new(format_optional_datetime(&step.started_at)),
329            Cell::new(format_optional_datetime(&step.completed_at)),
330        ]);
331    }
332
333    table
334}
335
336/// Render a list of workflows as a table.
337pub fn workflows_table(workflows: &[WorkflowSummary]) -> Table {
338    let mut table = base_table();
339    table.set_header(vec!["Name", "Category", "Version"]);
340
341    for wf in workflows {
342        table.add_row(vec![
343            Cell::new(&wf.name),
344            Cell::new(wf.category.as_deref().unwrap_or("-")),
345            Cell::new(wf.version.as_deref().unwrap_or("-")),
346        ]);
347    }
348
349    table
350}
351
352/// Render a workflow detail as a table.
353pub fn workflow_detail_table(detail: &WorkflowDetailResponse) -> Table {
354    let mut table = base_table();
355    table.set_header(vec!["Field", "Value"]);
356
357    table.add_row(vec![Cell::new("Name"), Cell::new(&detail.name)]);
358    table.add_row(vec![
359        Cell::new("Description"),
360        Cell::new(&detail.description),
361    ]);
362    table.add_row(vec![
363        Cell::new("Category"),
364        Cell::new(detail.category.as_deref().unwrap_or("-")),
365    ]);
366    table.add_row(vec![
367        Cell::new("Version"),
368        Cell::new(detail.version.as_deref().unwrap_or("-")),
369    ]);
370
371    if !detail.sub_workflows.is_empty() {
372        let names: Vec<&str> = detail
373            .sub_workflows
374            .iter()
375            .map(|s| s.name.as_str())
376            .collect();
377        table.add_row(vec![
378            Cell::new("Sub-workflows"),
379            Cell::new(names.join(", ")),
380        ]);
381    }
382
383    table
384}
385
386/// Render stats as a table.
387pub fn stats_table(stats: &StatsResponse) -> Table {
388    let mut table = base_table();
389    table.set_header(vec!["Metric", "Value"]);
390
391    table.add_row(vec![Cell::new("Total runs"), Cell::new(stats.total_runs)]);
392    table.add_row(vec![
393        Cell::new("Completed"),
394        Cell::new(stats.completed_runs).fg(Color::Green),
395    ]);
396    table.add_row(vec![
397        Cell::new("Failed"),
398        Cell::new(stats.failed_runs).fg(Color::Red),
399    ]);
400    table.add_row(vec![
401        Cell::new("Cancelled"),
402        Cell::new(stats.cancelled_runs).fg(Color::Grey),
403    ]);
404    table.add_row(vec![
405        Cell::new("Active"),
406        Cell::new(stats.active_runs).fg(Color::Blue),
407    ]);
408    table.add_row(vec![
409        Cell::new("Success rate"),
410        Cell::new(format!("{:.1}%", stats.success_rate_percent)),
411    ]);
412    table.add_row(vec![
413        Cell::new("Total cost"),
414        Cell::new(format!("${:.4}", stats.total_cost_usd)),
415    ]);
416    table.add_row(vec![
417        Cell::new("Total duration"),
418        Cell::new(format_duration_ms(stats.total_duration_ms)),
419    ]);
420
421    table
422}
423
424/// Render a list of key versions as a comma-separated string.
425fn format_versions(versions: &[i32]) -> String {
426    if versions.is_empty() {
427        return "-".to_string();
428    }
429    versions
430        .iter()
431        .map(|v| v.to_string())
432        .collect::<Vec<_>>()
433        .join(", ")
434}
435
436/// Outcome of a `delete` command.
437///
438/// The API answers `204 No Content`, which serializes to nothing useful, so the
439/// CLI reports the deletion itself and keeps `--json` machine-readable.
440///
441/// # Examples
442///
443/// ```
444/// use ironflow_cli::output::Deleted;
445///
446/// let deleted = Deleted::new("secret", "db/password");
447/// assert_eq!(deleted.kind, "secret");
448/// ```
449#[derive(Debug, Serialize)]
450pub struct Deleted {
451    /// What was deleted (`secret`, `api-key`, `user`).
452    pub kind: &'static str,
453    /// Identifier of the deleted resource.
454    pub id: String,
455    /// Always `true`; present so consumers can match on a stable shape.
456    pub deleted: bool,
457}
458
459impl Deleted {
460    /// Build a deletion report.
461    pub fn new(kind: &'static str, id: impl Into<String>) -> Self {
462        Self {
463            kind,
464            id: id.into(),
465            deleted: true,
466        }
467    }
468}
469
470/// Render a deletion report as a table.
471pub fn deleted_table(deleted: &Deleted) -> Table {
472    let mut table = base_table();
473    table.set_header(vec!["Deleted", "ID"]);
474    table.add_row(vec![Cell::new(deleted.kind), Cell::new(&deleted.id)]);
475    table
476}
477
478/// Report a deletion on stdout, as a table or as JSON.
479///
480/// # Errors
481///
482/// Returns an error if JSON serialization or writing fails.
483///
484/// # Examples
485///
486/// ```no_run
487/// use ironflow_cli::output::report_deletion;
488///
489/// # fn example() -> anyhow::Result<()> {
490/// report_deletion(false, "secret", "db/password")?;
491/// # Ok(())
492/// # }
493/// ```
494pub fn report_deletion(json_mode: bool, kind: &'static str, id: impl Into<String>) -> Result<()> {
495    let deleted = Deleted::new(kind, id);
496    print_output(json_mode, &deleted, || deleted_table(&deleted))
497}
498
499/// Render a list of secrets as a table.
500///
501/// [`SecretResponse`] carries no value field, so no secret material can reach
502/// this table by construction.
503pub fn secrets_table(secrets: &[SecretResponse]) -> Table {
504    let mut table = base_table();
505    table.set_header(vec!["Key", "Created", "Updated"]);
506
507    for secret in secrets {
508        table.add_row(vec![
509            Cell::new(&secret.key),
510            Cell::new(format_datetime(&secret.created_at)),
511            Cell::new(format_datetime(&secret.updated_at)),
512        ]);
513    }
514
515    table
516}
517
518/// Join the scopes of an API key into a single cell value.
519fn format_scopes(scopes: &[ApiKeyScope]) -> String {
520    scopes
521        .iter()
522        .map(ToString::to_string)
523        .collect::<Vec<_>>()
524        .join(", ")
525}
526
527/// Render the encryption key ring status as a table.
528pub fn key_versions_table(status: &KeyVersionsResponse) -> Table {
529    let mut table = base_table();
530    table.set_header(vec!["Property", "Versions"]);
531
532    table.add_row(vec![
533        Cell::new("Active"),
534        Cell::new(status.active).fg(Color::Green),
535    ]);
536    table.add_row(vec![
537        Cell::new("Configured"),
538        Cell::new(format_versions(&status.configured)),
539    ]);
540    table.add_row(vec![
541        Cell::new("In use"),
542        Cell::new(format_versions(&status.in_use)),
543    ]);
544    table.add_row(vec![
545        Cell::new("Missing"),
546        Cell::new(format_versions(&status.missing)).fg(if status.missing.is_empty() {
547            Color::Grey
548        } else {
549            Color::Red
550        }),
551    ]);
552    table.add_row(vec![
553        Cell::new("Retirable"),
554        Cell::new(format_versions(&status.retirable)).fg(if status.retirable.is_empty() {
555            Color::Grey
556        } else {
557            Color::Yellow
558        }),
559    ]);
560
561    table
562}
563
564/// Render a list of API keys as a table.
565///
566/// [`ApiKeyResponse`] never carries the raw key, only its prefix.
567pub fn api_keys_table(keys: &[ApiKeyResponse]) -> Table {
568    let mut table = base_table();
569    table.set_header(vec![
570        "ID",
571        "Name",
572        "Prefix",
573        "Scopes",
574        "Active",
575        "Last used",
576        "Expires",
577        "Created",
578    ]);
579
580    for key in keys {
581        let active = Cell::new(if key.is_active { "yes" } else { "no" })
582            .fg(if key.is_active {
583                Color::Green
584            } else {
585                Color::Grey
586            })
587            .set_alignment(CellAlignment::Center);
588
589        table.add_row(vec![
590            Cell::new(key.id),
591            Cell::new(&key.name),
592            Cell::new(&key.key_prefix),
593            Cell::new(format_scopes(&key.scopes)),
594            active,
595            Cell::new(format_optional_datetime(&key.last_used_at)),
596            Cell::new(format_optional_datetime(&key.expires_at)),
597            Cell::new(format_datetime(&key.created_at)),
598        ]);
599    }
600
601    table
602}
603
604/// Render a freshly created API key, including its one-time raw secret.
605///
606/// This is the only place the raw key is ever rendered: the API returns it once
607/// at creation and never again, so withholding it would make the command
608/// useless.
609pub fn created_api_key_table(key: &CreateApiKeyResponse) -> Table {
610    let mut table = base_table();
611    table.set_header(vec!["Field", "Value"]);
612
613    table.add_row(vec![Cell::new("ID"), Cell::new(key.id)]);
614    table.add_row(vec![Cell::new("Name"), Cell::new(&key.name)]);
615    table.add_row(vec![
616        Cell::new("Key"),
617        Cell::new(&key.key).fg(Color::Yellow),
618    ]);
619    table.add_row(vec![Cell::new("Prefix"), Cell::new(&key.key_prefix)]);
620    table.add_row(vec![
621        Cell::new("Scopes"),
622        Cell::new(format_scopes(&key.scopes)),
623    ]);
624    table.add_row(vec![
625        Cell::new("Expires"),
626        Cell::new(format_optional_datetime(&key.expires_at)),
627    ]);
628    table.add_row(vec![
629        Cell::new("Created"),
630        Cell::new(format_datetime(&key.created_at)),
631    ]);
632
633    table
634}
635
636/// Render the available API key scopes as a table.
637pub fn scopes_table(scopes: &[ScopeEntry]) -> Table {
638    let mut table = base_table();
639    table.set_header(vec!["Value", "Label", "Description"]);
640
641    for scope in scopes {
642        table.add_row(vec![
643            Cell::new(&scope.value),
644            Cell::new(&scope.label),
645            Cell::new(&scope.description),
646        ]);
647    }
648
649    table
650}
651
652/// Render a list of users as a table.
653pub fn users_table(users: &[UserResponse]) -> Table {
654    let mut table = base_table();
655    table.set_header(vec!["ID", "Username", "Email", "Admin", "Created"]);
656
657    for user in users {
658        let admin = Cell::new(if user.is_admin { "yes" } else { "no" })
659            .fg(if user.is_admin {
660                Color::Magenta
661            } else {
662                Color::Grey
663            })
664            .set_alignment(CellAlignment::Center);
665
666        table.add_row(vec![
667            Cell::new(user.id),
668            Cell::new(&user.username),
669            Cell::new(&user.email),
670            admin,
671            Cell::new(format_datetime(&user.created_at)),
672        ]);
673    }
674
675    table
676}
677
678/// Render a UUID as its first hyphen-separated group, enough to spot a row.
679fn short_id(id: Uuid) -> String {
680    id.to_string()
681        .split('-')
682        .next()
683        .unwrap_or_default()
684        .to_string()
685}
686
687/// Render a UUID as a short prefix, or `-` when absent.
688fn format_optional_id(id: &Option<Uuid>) -> String {
689    id.map_or_else(|| "-".to_string(), short_id)
690}
691
692/// Render a list of audit log entries as a table.
693///
694/// The event payload is omitted: it is arbitrary JSON that would wreck the
695/// table layout. Use `--json` to get it.
696pub fn audit_logs_table(entries: &[AuditLogEntry]) -> Table {
697    let mut table = base_table();
698    table.set_header(vec!["ID", "Type", "Run", "Step", "User", "Created"]);
699
700    for entry in entries {
701        table.add_row(vec![
702            Cell::new(short_id(entry.id)),
703            Cell::new(entry.event_type.to_string()),
704            Cell::new(format_optional_id(&entry.run_id)),
705            Cell::new(format_optional_id(&entry.step_id)),
706            Cell::new(format_optional_id(&entry.user_id)),
707            Cell::new(format_datetime(&entry.created_at)),
708        ]);
709    }
710
711    table
712}
713
714#[cfg(test)]
715mod tests {
716    use std::collections::HashMap;
717    use std::slice;
718
719    use ironflow_sdk::types::{ApiKeyScope, CreatedBy, CreatedByKind, EventKind, TriggerKind};
720    use serde_json::{Map, Value};
721
722    use super::*;
723
724    /// Minimal run whose only meaningful field is its author.
725    fn run_fixture(created_by: CreatedBy) -> RunResponse {
726        let now = Utc::now();
727        RunResponse {
728            id: Uuid::now_v7(),
729            workflow_name: "deploy".to_string(),
730            status: RunStatus::Completed,
731            trigger: TriggerKind::Api,
732            error: None,
733            retry_count: 0,
734            max_retries: 0,
735            cost_usd: 0.0,
736            duration_ms: 0,
737            created_at: now,
738            updated_at: now,
739            started_at: None,
740            completed_at: None,
741            handler_version: None,
742            labels: HashMap::new(),
743            scheduled_at: None,
744            created_by,
745            idempotency_key: None,
746            max_cost_usd: None,
747        }
748    }
749
750    #[test]
751    fn format_cost_without_cap_shows_amount_only() {
752        assert_eq!(format_cost(0.1234, None), "$0.1234");
753    }
754
755    #[test]
756    fn format_cost_with_cap_shows_both_amounts() {
757        assert_eq!(format_cost(0.18, Some(2.0)), "$0.1800 / $2.00");
758    }
759
760    #[test]
761    fn cost_color_is_absent_without_a_cap() {
762        assert_eq!(cost_color(999.0, None), None);
763    }
764
765    #[test]
766    fn cost_color_warns_past_the_threshold_and_alerts_at_the_cap() {
767        assert_eq!(cost_color(1.0, Some(2.0)), None); // 50%
768        assert_eq!(cost_color(1.6, Some(2.0)), Some(Color::Yellow)); // 80%
769        assert_eq!(cost_color(1.99, Some(2.0)), Some(Color::Yellow));
770        assert_eq!(cost_color(2.0, Some(2.0)), Some(Color::Red)); // at cap
771        assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); // over cap
772    }
773
774    #[test]
775    fn cost_color_handles_a_zero_cap() {
776        assert_eq!(cost_color(0.0, Some(0.0)), None);
777        assert_eq!(cost_color(0.01, Some(0.0)), Some(Color::Red));
778    }
779
780    fn artifact(name: &str, size_bytes: i64) -> ArtifactResponse {
781        ArtifactResponse {
782            id: Uuid::now_v7(),
783            step_id: Uuid::now_v7(),
784            name: name.to_string(),
785            content_type: "text/plain".to_string(),
786            size_bytes,
787            sha256: "0".repeat(64),
788            created_at: Utc::now(),
789        }
790    }
791
792    #[test]
793    fn format_bytes_keeps_raw_bytes_below_one_kilobyte() {
794        assert_eq!(format_bytes(0), "0 B");
795        assert_eq!(format_bytes(1023), "1023 B");
796    }
797
798    #[test]
799    fn format_bytes_switches_units_at_each_boundary() {
800        assert_eq!(format_bytes(1024), "1.0 KB");
801        assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
802        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
803    }
804
805    #[test]
806    fn format_bytes_drops_the_decimal_past_ten() {
807        assert_eq!(format_bytes(145_408), "142 KB");
808    }
809
810    #[test]
811    fn format_artifacts_shows_a_dash_when_there_are_none() {
812        assert_eq!(format_artifacts(&[]), "-");
813    }
814
815    #[test]
816    fn format_artifacts_shows_the_count_and_total_size() {
817        let artifacts = vec![artifact("a.txt", 1024), artifact("b.txt", 1024)];
818        assert_eq!(format_artifacts(&artifacts), "2 (2.0 KB)");
819    }
820
821    #[test]
822    fn format_duration_ms_millis() {
823        assert_eq!(format_duration_ms(500), "500ms");
824        assert_eq!(format_duration_ms(0), "0ms");
825    }
826
827    #[test]
828    fn format_duration_ms_seconds() {
829        assert_eq!(format_duration_ms(5000), "5s");
830        assert_eq!(format_duration_ms(59000), "59s");
831    }
832
833    #[test]
834    fn format_duration_ms_minutes() {
835        assert_eq!(format_duration_ms(60000), "1m 0s");
836        assert_eq!(format_duration_ms(125000), "2m 5s");
837    }
838
839    #[test]
840    fn format_duration_ms_hours() {
841        assert_eq!(format_duration_ms(3_600_000), "1h 0m");
842        assert_eq!(format_duration_ms(5_400_000), "1h 30m");
843    }
844
845    #[test]
846    fn format_optional_datetime_none() {
847        assert_eq!(format_optional_datetime(&None), "-");
848    }
849
850    #[test]
851    fn format_optional_datetime_some() {
852        let dt = "2026-06-02T14:30:00Z".parse::<DateTime<Utc>>().unwrap();
853        assert_eq!(format_optional_datetime(&Some(dt)), "2026-06-02 14:30:00");
854    }
855
856    #[test]
857    fn status_colors_are_distinct() {
858        let statuses = [
859            RunStatus::Completed,
860            RunStatus::Failed,
861            RunStatus::Running,
862            RunStatus::Pending,
863            RunStatus::Cancelled,
864            RunStatus::AwaitingApproval,
865            RunStatus::Retrying,
866        ];
867
868        let colors: Vec<Color> = statuses.iter().map(status_color).collect();
869        for (i, c1) in colors.iter().enumerate() {
870            for (j, c2) in colors.iter().enumerate() {
871                if i != j {
872                    assert_ne!(c1, c2, "status colors must be distinct");
873                }
874            }
875        }
876    }
877
878    #[test]
879    fn empty_runs_table_has_header() {
880        let table = runs_table(&[]);
881        let output = table.to_string();
882        assert!(output.contains("ID"));
883        assert!(output.contains("Workflow"));
884        assert!(output.contains("Status"));
885        assert!(output.contains("Triggered by"));
886    }
887
888    #[test]
889    fn runs_table_renders_the_author_label() {
890        let run = run_fixture(CreatedBy {
891            kind: CreatedByKind::ApiKey,
892            id: Some(Uuid::now_v7()),
893            label: "ci-deploy (alice)".to_string(),
894        });
895
896        let output = runs_table(slice::from_ref(&run)).to_string();
897        assert!(
898            output.contains("ci-deploy (alice)"),
899            "author missing from:\n{output}"
900        );
901    }
902
903    #[test]
904    fn run_detail_table_renders_the_author_label() {
905        let detail = RunDetailResponse {
906            run: run_fixture(CreatedBy {
907                kind: CreatedByKind::System,
908                id: None,
909                label: "/hooks/github".to_string(),
910            }),
911            steps: Vec::new(),
912            payload: Value::Object(Map::new()),
913        };
914
915        let output = run_detail_table(&detail).to_string();
916        assert!(output.contains("Triggered by"));
917        assert!(
918            output.contains("/hooks/github"),
919            "author missing from:\n{output}"
920        );
921    }
922
923    #[test]
924    fn empty_workflows_table_has_header() {
925        let table = workflows_table(&[]);
926        let output = table.to_string();
927        assert!(output.contains("Name"));
928        assert!(output.contains("Category"));
929    }
930
931    // ── Secrets ────────────────────────────────────────────────
932
933    fn secret_fixture(key: &str) -> SecretResponse {
934        let now = Utc::now();
935        SecretResponse {
936            id: Uuid::now_v7(),
937            key: key.to_string(),
938            created_at: now,
939            updated_at: now,
940        }
941    }
942
943    #[test]
944    fn empty_secrets_table_has_header() {
945        let output = secrets_table(&[]).to_string();
946        assert!(output.contains("Key"));
947        assert!(output.contains("Created"));
948        assert!(output.contains("Updated"));
949    }
950
951    #[test]
952    fn secrets_table_renders_the_key() {
953        let secret = secret_fixture("workflows/inbox/gmail_token");
954        let output = secrets_table(slice::from_ref(&secret)).to_string();
955        assert!(output.contains("workflows/inbox/gmail_token"), "{output}");
956    }
957
958    /// The value never even reaches this layer: `SecretResponse` has no such
959    /// field. Rendering it as JSON proves the whole payload is value-free.
960    #[test]
961    fn a_secret_response_carries_no_value_at_all() {
962        let secret = secret_fixture("db/password");
963        let json = serde_json::to_string(&secret).unwrap();
964        assert!(!json.contains("value"), "{json}");
965    }
966
967    // ── API keys ───────────────────────────────────────────────
968
969    fn api_key_fixture() -> ApiKeyResponse {
970        ApiKeyResponse {
971            id: Uuid::now_v7(),
972            name: "ci-deploy".to_string(),
973            key_prefix: "ifk_abcd".to_string(),
974            scopes: vec![ApiKeyScope::RunsRead, ApiKeyScope::RunsWrite],
975            is_active: true,
976            created_at: Utc::now(),
977            expires_at: None,
978            last_used_at: None,
979        }
980    }
981
982    #[test]
983    fn empty_api_keys_table_has_header() {
984        let output = api_keys_table(&[]).to_string();
985        for header in ["ID", "Name", "Prefix", "Scopes", "Active"] {
986            assert!(output.contains(header), "missing {header} in {output}");
987        }
988    }
989
990    #[test]
991    fn api_keys_table_joins_the_scopes() {
992        let key = api_key_fixture();
993        let output = api_keys_table(slice::from_ref(&key)).to_string();
994        assert!(output.contains("runs_read, runs_write"), "{output}");
995        assert!(output.contains("ifk_abcd"), "{output}");
996    }
997
998    #[test]
999    fn created_api_key_table_shows_the_raw_key() {
1000        let created = CreateApiKeyResponse {
1001            id: Uuid::now_v7(),
1002            name: "ci-deploy".to_string(),
1003            key: "ifk_full_raw_key".to_string(),
1004            key_prefix: "ifk_full".to_string(),
1005            scopes: vec![ApiKeyScope::Admin],
1006            created_at: Utc::now(),
1007            expires_at: None,
1008        };
1009
1010        let output = created_api_key_table(&created).to_string();
1011        assert!(output.contains("ifk_full_raw_key"), "{output}");
1012    }
1013
1014    #[test]
1015    fn empty_scopes_table_has_header() {
1016        let output = scopes_table(&[]).to_string();
1017        assert!(output.contains("Value"));
1018        assert!(output.contains("Description"));
1019    }
1020
1021    // ── Users ──────────────────────────────────────────────────
1022
1023    fn user_fixture(is_admin: bool) -> UserResponse {
1024        let now = Utc::now();
1025        UserResponse {
1026            id: Uuid::now_v7(),
1027            username: "alice".to_string(),
1028            email: "alice@example.com".to_string(),
1029            is_admin,
1030            created_at: now,
1031            updated_at: now,
1032        }
1033    }
1034
1035    #[test]
1036    fn empty_users_table_has_header() {
1037        let output = users_table(&[]).to_string();
1038        for header in ["ID", "Username", "Email", "Admin", "Created"] {
1039            assert!(output.contains(header), "missing {header} in {output}");
1040        }
1041    }
1042
1043    #[test]
1044    fn users_table_spells_out_the_role() {
1045        let admin = user_fixture(true);
1046        assert!(
1047            users_table(slice::from_ref(&admin))
1048                .to_string()
1049                .contains("yes")
1050        );
1051
1052        let member = user_fixture(false);
1053        assert!(
1054            users_table(slice::from_ref(&member))
1055                .to_string()
1056                .contains("no")
1057        );
1058    }
1059
1060    // ── Audit logs ─────────────────────────────────────────────
1061
1062    #[test]
1063    fn empty_audit_logs_table_has_header() {
1064        let output = audit_logs_table(&[]).to_string();
1065        for header in ["ID", "Type", "Run", "Step", "User", "Created"] {
1066            assert!(output.contains(header), "missing {header} in {output}");
1067        }
1068    }
1069
1070    #[test]
1071    fn audit_logs_table_omits_the_payload() {
1072        let entry = AuditLogEntry {
1073            id: Uuid::now_v7(),
1074            event_type: EventKind::RunCreated,
1075            payload: Value::Object(Map::new()),
1076            run_id: Some(Uuid::now_v7()),
1077            step_id: None,
1078            user_id: None,
1079            created_at: Utc::now(),
1080        };
1081
1082        let output = audit_logs_table(slice::from_ref(&entry)).to_string();
1083        assert!(output.contains("run_created"), "{output}");
1084        // Absent IDs collapse to a dash rather than an empty cell.
1085        assert!(output.contains('-'), "{output}");
1086    }
1087
1088    #[test]
1089    fn format_optional_id_shortens_and_falls_back() {
1090        assert_eq!(format_optional_id(&None), "-");
1091        let id = Uuid::now_v7();
1092        let short = format_optional_id(&Some(id));
1093        assert_eq!(short, id.to_string().split('-').next().unwrap());
1094    }
1095
1096    // ── Deletions ──────────────────────────────────────────────
1097
1098    #[test]
1099    fn deleted_table_reports_the_kind_and_id() {
1100        let deleted = Deleted::new("secret", "db/password");
1101        let output = deleted_table(&deleted).to_string();
1102        assert!(output.contains("secret"), "{output}");
1103        assert!(output.contains("db/password"), "{output}");
1104
1105        let json = serde_json::to_string(&deleted).unwrap();
1106        assert!(json.contains(r#""deleted":true"#), "{json}");
1107    }
1108}