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