1use 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
21fn 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
35fn 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
48fn format_datetime(dt: &DateTime<Utc>) -> String {
50 dt.format("%Y-%m-%d %H:%M:%S").to_string()
51}
52
53fn format_optional_datetime(dt: &Option<DateTime<Utc>>) -> String {
55 dt.as_ref().map_or("-".to_string(), format_datetime)
56}
57
58fn 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
77fn 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
86pub 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
106pub 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
119pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
128 let json = to_string_pretty(value)?;
129 writeln!(stdout().lock(), "{json}")?;
130 Ok(())
131}
132
133const COST_WARNING_RATIO: f64 = 0.8;
136
137fn 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
147fn 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
169fn 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
211pub 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
269fn 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
281fn 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
300pub 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
336pub 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
352pub 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
386pub 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
424fn 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#[derive(Debug, Serialize)]
450pub struct Deleted {
451 pub kind: &'static str,
453 pub id: String,
455 pub deleted: bool,
457}
458
459impl Deleted {
460 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
470pub 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
478pub 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
499pub 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
518fn format_scopes(scopes: &[ApiKeyScope]) -> String {
520 scopes
521 .iter()
522 .map(ToString::to_string)
523 .collect::<Vec<_>>()
524 .join(", ")
525}
526
527pub 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
564pub 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 "Rate limit",
576 "Last used",
577 "Expires",
578 "Created",
579 ]);
580
581 for key in keys {
582 let active = Cell::new(if key.is_active { "yes" } else { "no" })
583 .fg(if key.is_active {
584 Color::Green
585 } else {
586 Color::Grey
587 })
588 .set_alignment(CellAlignment::Center);
589
590 let rate_limit = key
591 .rate_limit_override
592 .map(|v| v.to_string())
593 .unwrap_or_else(|| "-".to_string());
594
595 table.add_row(vec![
596 Cell::new(key.id),
597 Cell::new(&key.name),
598 Cell::new(&key.key_prefix),
599 Cell::new(format_scopes(&key.scopes)),
600 active,
601 Cell::new(rate_limit),
602 Cell::new(format_optional_datetime(&key.last_used_at)),
603 Cell::new(format_optional_datetime(&key.expires_at)),
604 Cell::new(format_datetime(&key.created_at)),
605 ]);
606 }
607
608 table
609}
610
611pub fn created_api_key_table(key: &CreateApiKeyResponse) -> Table {
617 let mut table = base_table();
618 table.set_header(vec!["Field", "Value"]);
619
620 table.add_row(vec![Cell::new("ID"), Cell::new(key.id)]);
621 table.add_row(vec![Cell::new("Name"), Cell::new(&key.name)]);
622 table.add_row(vec![
623 Cell::new("Key"),
624 Cell::new(&key.key).fg(Color::Yellow),
625 ]);
626 table.add_row(vec![Cell::new("Prefix"), Cell::new(&key.key_prefix)]);
627 table.add_row(vec![
628 Cell::new("Scopes"),
629 Cell::new(format_scopes(&key.scopes)),
630 ]);
631 if let Some(override_val) = key.rate_limit_override {
632 table.add_row(vec![
633 Cell::new("Rate limit"),
634 Cell::new(format!("{override_val} req/min")),
635 ]);
636 }
637 table.add_row(vec![
638 Cell::new("Expires"),
639 Cell::new(format_optional_datetime(&key.expires_at)),
640 ]);
641 table.add_row(vec![
642 Cell::new("Created"),
643 Cell::new(format_datetime(&key.created_at)),
644 ]);
645
646 table
647}
648
649pub fn scopes_table(scopes: &[ScopeEntry]) -> Table {
651 let mut table = base_table();
652 table.set_header(vec!["Value", "Label", "Description"]);
653
654 for scope in scopes {
655 table.add_row(vec![
656 Cell::new(&scope.value),
657 Cell::new(&scope.label),
658 Cell::new(&scope.description),
659 ]);
660 }
661
662 table
663}
664
665pub fn users_table(users: &[UserResponse]) -> Table {
667 let mut table = base_table();
668 table.set_header(vec!["ID", "Username", "Email", "Admin", "Created"]);
669
670 for user in users {
671 let admin = Cell::new(if user.is_admin { "yes" } else { "no" })
672 .fg(if user.is_admin {
673 Color::Magenta
674 } else {
675 Color::Grey
676 })
677 .set_alignment(CellAlignment::Center);
678
679 table.add_row(vec![
680 Cell::new(user.id),
681 Cell::new(&user.username),
682 Cell::new(&user.email),
683 admin,
684 Cell::new(format_datetime(&user.created_at)),
685 ]);
686 }
687
688 table
689}
690
691fn short_id(id: Uuid) -> String {
693 id.to_string()
694 .split('-')
695 .next()
696 .unwrap_or_default()
697 .to_string()
698}
699
700fn format_optional_id(id: &Option<Uuid>) -> String {
702 id.map_or_else(|| "-".to_string(), short_id)
703}
704
705pub fn audit_logs_table(entries: &[AuditLogEntry]) -> Table {
710 let mut table = base_table();
711 table.set_header(vec!["ID", "Type", "Run", "Step", "User", "Created"]);
712
713 for entry in entries {
714 table.add_row(vec![
715 Cell::new(short_id(entry.id)),
716 Cell::new(entry.event_type.to_string()),
717 Cell::new(format_optional_id(&entry.run_id)),
718 Cell::new(format_optional_id(&entry.step_id)),
719 Cell::new(format_optional_id(&entry.user_id)),
720 Cell::new(format_datetime(&entry.created_at)),
721 ]);
722 }
723
724 table
725}
726
727#[cfg(test)]
728mod tests {
729 use std::collections::HashMap;
730 use std::slice;
731
732 use ironflow_sdk::types::{ApiKeyScope, CreatedBy, CreatedByKind, EventKind, TriggerKind};
733 use serde_json::{Map, Value};
734
735 use super::*;
736
737 fn run_fixture(created_by: CreatedBy) -> RunResponse {
739 let now = Utc::now();
740 RunResponse {
741 id: Uuid::now_v7(),
742 workflow_name: "deploy".to_string(),
743 status: RunStatus::Completed,
744 trigger: TriggerKind::Api,
745 error: None,
746 retry_count: 0,
747 max_retries: 0,
748 cost_usd: 0.0,
749 duration_ms: 0,
750 created_at: now,
751 updated_at: now,
752 started_at: None,
753 completed_at: None,
754 handler_version: None,
755 labels: HashMap::new(),
756 scheduled_at: None,
757 created_by,
758 idempotency_key: None,
759 max_cost_usd: None,
760 }
761 }
762
763 #[test]
764 fn format_cost_without_cap_shows_amount_only() {
765 assert_eq!(format_cost(0.1234, None), "$0.1234");
766 }
767
768 #[test]
769 fn format_cost_with_cap_shows_both_amounts() {
770 assert_eq!(format_cost(0.18, Some(2.0)), "$0.1800 / $2.00");
771 }
772
773 #[test]
774 fn cost_color_is_absent_without_a_cap() {
775 assert_eq!(cost_color(999.0, None), None);
776 }
777
778 #[test]
779 fn cost_color_warns_past_the_threshold_and_alerts_at_the_cap() {
780 assert_eq!(cost_color(1.0, Some(2.0)), None); assert_eq!(cost_color(1.6, Some(2.0)), Some(Color::Yellow)); assert_eq!(cost_color(1.99, Some(2.0)), Some(Color::Yellow));
783 assert_eq!(cost_color(2.0, Some(2.0)), Some(Color::Red)); assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); }
786
787 #[test]
788 fn cost_color_handles_a_zero_cap() {
789 assert_eq!(cost_color(0.0, Some(0.0)), None);
790 assert_eq!(cost_color(0.01, Some(0.0)), Some(Color::Red));
791 }
792
793 fn artifact(name: &str, size_bytes: i64) -> ArtifactResponse {
794 ArtifactResponse {
795 id: Uuid::now_v7(),
796 step_id: Uuid::now_v7(),
797 name: name.to_string(),
798 content_type: "text/plain".to_string(),
799 size_bytes,
800 sha256: "0".repeat(64),
801 created_at: Utc::now(),
802 }
803 }
804
805 #[test]
806 fn format_bytes_keeps_raw_bytes_below_one_kilobyte() {
807 assert_eq!(format_bytes(0), "0 B");
808 assert_eq!(format_bytes(1023), "1023 B");
809 }
810
811 #[test]
812 fn format_bytes_switches_units_at_each_boundary() {
813 assert_eq!(format_bytes(1024), "1.0 KB");
814 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
815 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
816 }
817
818 #[test]
819 fn format_bytes_drops_the_decimal_past_ten() {
820 assert_eq!(format_bytes(145_408), "142 KB");
821 }
822
823 #[test]
824 fn format_artifacts_shows_a_dash_when_there_are_none() {
825 assert_eq!(format_artifacts(&[]), "-");
826 }
827
828 #[test]
829 fn format_artifacts_shows_the_count_and_total_size() {
830 let artifacts = vec![artifact("a.txt", 1024), artifact("b.txt", 1024)];
831 assert_eq!(format_artifacts(&artifacts), "2 (2.0 KB)");
832 }
833
834 #[test]
835 fn format_duration_ms_millis() {
836 assert_eq!(format_duration_ms(500), "500ms");
837 assert_eq!(format_duration_ms(0), "0ms");
838 }
839
840 #[test]
841 fn format_duration_ms_seconds() {
842 assert_eq!(format_duration_ms(5000), "5s");
843 assert_eq!(format_duration_ms(59000), "59s");
844 }
845
846 #[test]
847 fn format_duration_ms_minutes() {
848 assert_eq!(format_duration_ms(60000), "1m 0s");
849 assert_eq!(format_duration_ms(125000), "2m 5s");
850 }
851
852 #[test]
853 fn format_duration_ms_hours() {
854 assert_eq!(format_duration_ms(3_600_000), "1h 0m");
855 assert_eq!(format_duration_ms(5_400_000), "1h 30m");
856 }
857
858 #[test]
859 fn format_optional_datetime_none() {
860 assert_eq!(format_optional_datetime(&None), "-");
861 }
862
863 #[test]
864 fn format_optional_datetime_some() {
865 let dt = "2026-06-02T14:30:00Z".parse::<DateTime<Utc>>().unwrap();
866 assert_eq!(format_optional_datetime(&Some(dt)), "2026-06-02 14:30:00");
867 }
868
869 #[test]
870 fn status_colors_are_distinct() {
871 let statuses = [
872 RunStatus::Completed,
873 RunStatus::Failed,
874 RunStatus::Running,
875 RunStatus::Pending,
876 RunStatus::Cancelled,
877 RunStatus::AwaitingApproval,
878 RunStatus::Retrying,
879 ];
880
881 let colors: Vec<Color> = statuses.iter().map(status_color).collect();
882 for (i, c1) in colors.iter().enumerate() {
883 for (j, c2) in colors.iter().enumerate() {
884 if i != j {
885 assert_ne!(c1, c2, "status colors must be distinct");
886 }
887 }
888 }
889 }
890
891 #[test]
892 fn empty_runs_table_has_header() {
893 let table = runs_table(&[]);
894 let output = table.to_string();
895 assert!(output.contains("ID"));
896 assert!(output.contains("Workflow"));
897 assert!(output.contains("Status"));
898 assert!(output.contains("Triggered by"));
899 }
900
901 #[test]
902 fn runs_table_renders_the_author_label() {
903 let run = run_fixture(CreatedBy {
904 kind: CreatedByKind::ApiKey,
905 id: Some(Uuid::now_v7()),
906 label: "ci-deploy (alice)".to_string(),
907 });
908
909 let output = runs_table(slice::from_ref(&run)).to_string();
910 assert!(
911 output.contains("ci-deploy (alice)"),
912 "author missing from:\n{output}"
913 );
914 }
915
916 #[test]
917 fn run_detail_table_renders_the_author_label() {
918 let detail = RunDetailResponse {
919 run: run_fixture(CreatedBy {
920 kind: CreatedByKind::System,
921 id: None,
922 label: "/hooks/github".to_string(),
923 }),
924 steps: Vec::new(),
925 payload: Value::Object(Map::new()),
926 };
927
928 let output = run_detail_table(&detail).to_string();
929 assert!(output.contains("Triggered by"));
930 assert!(
931 output.contains("/hooks/github"),
932 "author missing from:\n{output}"
933 );
934 }
935
936 #[test]
937 fn empty_workflows_table_has_header() {
938 let table = workflows_table(&[]);
939 let output = table.to_string();
940 assert!(output.contains("Name"));
941 assert!(output.contains("Category"));
942 }
943
944 fn secret_fixture(key: &str) -> SecretResponse {
947 let now = Utc::now();
948 SecretResponse {
949 id: Uuid::now_v7(),
950 key: key.to_string(),
951 created_at: now,
952 updated_at: now,
953 }
954 }
955
956 #[test]
957 fn empty_secrets_table_has_header() {
958 let output = secrets_table(&[]).to_string();
959 assert!(output.contains("Key"));
960 assert!(output.contains("Created"));
961 assert!(output.contains("Updated"));
962 }
963
964 #[test]
965 fn secrets_table_renders_the_key() {
966 let secret = secret_fixture("workflows/inbox/gmail_token");
967 let output = secrets_table(slice::from_ref(&secret)).to_string();
968 assert!(output.contains("workflows/inbox/gmail_token"), "{output}");
969 }
970
971 #[test]
974 fn a_secret_response_carries_no_value_at_all() {
975 let secret = secret_fixture("db/password");
976 let json = serde_json::to_string(&secret).unwrap();
977 assert!(!json.contains("value"), "{json}");
978 }
979
980 fn api_key_fixture() -> ApiKeyResponse {
983 ApiKeyResponse {
984 id: Uuid::now_v7(),
985 name: "ci-deploy".to_string(),
986 key_prefix: "ifk_abcd".to_string(),
987 scopes: vec![ApiKeyScope::RunsRead, ApiKeyScope::RunsWrite],
988 is_active: true,
989 created_at: Utc::now(),
990 expires_at: None,
991 last_used_at: None,
992 rate_limit_override: None,
993 }
994 }
995
996 #[test]
997 fn empty_api_keys_table_has_header() {
998 let output = api_keys_table(&[]).to_string();
999 for header in ["ID", "Name", "Prefix", "Scopes", "Active"] {
1000 assert!(output.contains(header), "missing {header} in {output}");
1001 }
1002 }
1003
1004 #[test]
1005 fn api_keys_table_joins_the_scopes() {
1006 let key = api_key_fixture();
1007 let output = api_keys_table(slice::from_ref(&key)).to_string();
1008 assert!(output.contains("runs_read, runs_write"), "{output}");
1009 assert!(output.contains("ifk_abcd"), "{output}");
1010 }
1011
1012 #[test]
1013 fn created_api_key_table_shows_the_raw_key() {
1014 let created = CreateApiKeyResponse {
1015 id: Uuid::now_v7(),
1016 name: "ci-deploy".to_string(),
1017 key: "ifk_full_raw_key".to_string(),
1018 key_prefix: "ifk_full".to_string(),
1019 scopes: vec![ApiKeyScope::Admin],
1020 created_at: Utc::now(),
1021 expires_at: None,
1022 rate_limit_override: None,
1023 };
1024
1025 let output = created_api_key_table(&created).to_string();
1026 assert!(output.contains("ifk_full_raw_key"), "{output}");
1027 }
1028
1029 #[test]
1030 fn empty_scopes_table_has_header() {
1031 let output = scopes_table(&[]).to_string();
1032 assert!(output.contains("Value"));
1033 assert!(output.contains("Description"));
1034 }
1035
1036 fn user_fixture(is_admin: bool) -> UserResponse {
1039 let now = Utc::now();
1040 UserResponse {
1041 id: Uuid::now_v7(),
1042 username: "alice".to_string(),
1043 email: "alice@example.com".to_string(),
1044 is_admin,
1045 created_at: now,
1046 updated_at: now,
1047 }
1048 }
1049
1050 #[test]
1051 fn empty_users_table_has_header() {
1052 let output = users_table(&[]).to_string();
1053 for header in ["ID", "Username", "Email", "Admin", "Created"] {
1054 assert!(output.contains(header), "missing {header} in {output}");
1055 }
1056 }
1057
1058 #[test]
1059 fn users_table_spells_out_the_role() {
1060 let admin = user_fixture(true);
1061 assert!(
1062 users_table(slice::from_ref(&admin))
1063 .to_string()
1064 .contains("yes")
1065 );
1066
1067 let member = user_fixture(false);
1068 assert!(
1069 users_table(slice::from_ref(&member))
1070 .to_string()
1071 .contains("no")
1072 );
1073 }
1074
1075 #[test]
1078 fn empty_audit_logs_table_has_header() {
1079 let output = audit_logs_table(&[]).to_string();
1080 for header in ["ID", "Type", "Run", "Step", "User", "Created"] {
1081 assert!(output.contains(header), "missing {header} in {output}");
1082 }
1083 }
1084
1085 #[test]
1086 fn audit_logs_table_omits_the_payload() {
1087 let entry = AuditLogEntry {
1088 id: Uuid::now_v7(),
1089 event_type: EventKind::RunCreated,
1090 payload: Value::Object(Map::new()),
1091 run_id: Some(Uuid::now_v7()),
1092 step_id: None,
1093 user_id: None,
1094 created_at: Utc::now(),
1095 };
1096
1097 let output = audit_logs_table(slice::from_ref(&entry)).to_string();
1098 assert!(output.contains("run_created"), "{output}");
1099 assert!(output.contains('-'), "{output}");
1101 }
1102
1103 #[test]
1104 fn format_optional_id_shortens_and_falls_back() {
1105 assert_eq!(format_optional_id(&None), "-");
1106 let id = Uuid::now_v7();
1107 let short = format_optional_id(&Some(id));
1108 assert_eq!(short, id.to_string().split('-').next().unwrap());
1109 }
1110
1111 #[test]
1114 fn deleted_table_reports_the_kind_and_id() {
1115 let deleted = Deleted::new("secret", "db/password");
1116 let output = deleted_table(&deleted).to_string();
1117 assert!(output.contains("secret"), "{output}");
1118 assert!(output.contains("db/password"), "{output}");
1119
1120 let json = serde_json::to_string(&deleted).unwrap();
1121 assert!(json.contains(r#""deleted":true"#), "{json}");
1122 }
1123}