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