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 }
32}
33
34fn 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
47fn format_datetime(dt: &DateTime<Utc>) -> String {
49 dt.format("%Y-%m-%d %H:%M:%S").to_string()
50}
51
52fn format_optional_datetime(dt: &Option<DateTime<Utc>>) -> String {
54 dt.as_ref().map_or("-".to_string(), format_datetime)
55}
56
57fn 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
76fn 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
85pub 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
105pub 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
118pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
127 let json = to_string_pretty(value)?;
128 writeln!(stdout().lock(), "{json}")?;
129 Ok(())
130}
131
132const COST_WARNING_RATIO: f64 = 0.8;
135
136fn 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
146fn 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
168fn 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
210pub 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
268fn 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
280fn 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
299pub 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
335pub 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
351pub 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
385pub 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
423fn 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#[derive(Debug, Serialize)]
449pub struct Deleted {
450 pub kind: &'static str,
452 pub id: String,
454 pub deleted: bool,
456}
457
458impl Deleted {
459 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
469pub 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
477pub 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
498pub 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
517fn format_scopes(scopes: &[ApiKeyScope]) -> String {
519 scopes
520 .iter()
521 .map(ToString::to_string)
522 .collect::<Vec<_>>()
523 .join(", ")
524}
525
526pub 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
563pub 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
603pub 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
635pub 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
651pub 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
677fn short_id(id: Uuid) -> String {
679 id.to_string()
680 .split('-')
681 .next()
682 .unwrap_or_default()
683 .to_string()
684}
685
686fn format_optional_id(id: &Option<Uuid>) -> String {
688 id.map_or_else(|| "-".to_string(), short_id)
689}
690
691pub 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 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); assert_eq!(cost_color(1.6, Some(2.0)), Some(Color::Yellow)); 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)); assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); }
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 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 #[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 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 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 #[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 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 #[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}