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, 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
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
268pub 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
302pub 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
318pub 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
352pub 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
390fn 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#[derive(Debug, Serialize)]
416pub struct Deleted {
417 pub kind: &'static str,
419 pub id: String,
421 pub deleted: bool,
423}
424
425impl Deleted {
426 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
436pub 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
444pub 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
465pub 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
484fn format_scopes(scopes: &[ApiKeyScope]) -> String {
486 scopes
487 .iter()
488 .map(ToString::to_string)
489 .collect::<Vec<_>>()
490 .join(", ")
491}
492
493pub 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
530pub 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
570pub 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
602pub 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
618pub 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
644fn short_id(id: Uuid) -> String {
646 id.to_string()
647 .split('-')
648 .next()
649 .unwrap_or_default()
650 .to_string()
651}
652
653fn format_optional_id(id: &Option<Uuid>) -> String {
655 id.map_or_else(|| "-".to_string(), short_id)
656}
657
658pub 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 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); assert_eq!(cost_color(1.6, Some(2.0)), Some(Color::Yellow)); 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)); assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); }
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 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 #[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 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 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 #[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 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 #[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}