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 StatsHistoryResponse, StatsResponse, StepResponse, StepStatus, UserResponse,
16 WorkflowDetailResponse, WorkflowSummary,
17};
18use serde::Serialize;
19use serde_json::to_string_pretty;
20use uuid::Uuid;
21
22fn status_color(status: &RunStatus) -> Color {
24 match status {
25 RunStatus::Completed => Color::Green,
26 RunStatus::Failed => Color::Red,
27 RunStatus::Running => Color::Blue,
28 RunStatus::Pending => Color::Yellow,
29 RunStatus::Cancelled => Color::Grey,
30 RunStatus::AwaitingApproval => Color::Magenta,
31 RunStatus::Retrying => Color::Cyan,
32 RunStatus::Warning => Color::DarkYellow,
33 RunStatus::Sleeping => Color::DarkCyan,
34 }
35}
36
37fn step_status_color(status: &StepStatus) -> Color {
39 match status {
40 StepStatus::Completed => Color::Green,
41 StepStatus::Failed => Color::Red,
42 StepStatus::Running => Color::Blue,
43 StepStatus::Pending => Color::Yellow,
44 StepStatus::Skipped => Color::Grey,
45 StepStatus::AwaitingApproval => Color::Magenta,
46 StepStatus::Rejected => Color::Red,
47 }
48}
49
50fn format_datetime(dt: &DateTime<Utc>) -> String {
52 dt.format("%Y-%m-%d %H:%M:%S").to_string()
53}
54
55fn format_optional_datetime(dt: &Option<DateTime<Utc>>) -> String {
57 dt.as_ref().map_or("-".to_string(), format_datetime)
58}
59
60fn format_duration_ms(ms: i64) -> String {
62 if ms < 1000 {
63 return format!("{ms}ms");
64 }
65 let secs = ms / 1000;
66 if secs < 60 {
67 return format!("{secs}s");
68 }
69 let mins = secs / 60;
70 let remaining_secs = secs % 60;
71 if mins < 60 {
72 return format!("{mins}m {remaining_secs}s");
73 }
74 let hours = mins / 60;
75 let remaining_mins = mins % 60;
76 format!("{hours}h {remaining_mins}m")
77}
78
79fn base_table() -> Table {
81 let mut table = Table::new();
82 table
83 .load_preset(UTF8_FULL)
84 .set_content_arrangement(ContentArrangement::Dynamic);
85 table
86}
87
88pub fn render_output<W: Write, T: Serialize>(
94 writer: &mut W,
95 json_mode: bool,
96 value: &T,
97 table_fn: impl FnOnce() -> Table,
98) -> Result<()> {
99 if json_mode {
100 let json = to_string_pretty(value)?;
101 writeln!(writer, "{json}")?;
102 } else {
103 writeln!(writer, "{}", table_fn())?;
104 }
105 Ok(())
106}
107
108pub fn print_output<T: Serialize>(
114 json_mode: bool,
115 value: &T,
116 table_fn: impl FnOnce() -> Table,
117) -> Result<()> {
118 render_output(&mut stdout().lock(), json_mode, value, table_fn)
119}
120
121pub fn print_json<T: Serialize>(value: &T) -> Result<()> {
130 let json = to_string_pretty(value)?;
131 writeln!(stdout().lock(), "{json}")?;
132 Ok(())
133}
134
135const COST_WARNING_RATIO: f64 = 0.8;
138
139fn format_cost(cost_usd: f64, max_cost_usd: Option<f64>) -> String {
143 match max_cost_usd {
144 Some(cap) => format!("${cost_usd:.4} / ${cap:.2}"),
145 None => format!("${cost_usd:.4}"),
146 }
147}
148
149fn cost_color(cost_usd: f64, max_cost_usd: Option<f64>) -> Option<Color> {
155 let cap = max_cost_usd?;
156
157 if cap <= 0.0 {
158 return (cost_usd > 0.0).then_some(Color::Red);
159 }
160
161 let ratio = cost_usd / cap;
162 if ratio >= 1.0 {
163 Some(Color::Red)
164 } else if ratio >= COST_WARNING_RATIO {
165 Some(Color::Yellow)
166 } else {
167 None
168 }
169}
170
171fn cost_cell(cost_usd: f64, max_cost_usd: Option<f64>) -> Cell {
173 let cell = Cell::new(format_cost(cost_usd, max_cost_usd));
174 match cost_color(cost_usd, max_cost_usd) {
175 Some(color) => cell.fg(color),
176 None => cell,
177 }
178}
179
180pub fn runs_table(runs: &[RunResponse]) -> Table {
181 let mut table = base_table();
182 table.set_header(vec![
183 "ID",
184 "Workflow",
185 "Status",
186 "Triggered by",
187 "Duration",
188 "Cost",
189 "Created",
190 "Started",
191 ]);
192
193 for run in runs {
194 let status_cell = Cell::new(run.status)
195 .fg(status_color(&run.status))
196 .set_alignment(CellAlignment::Center);
197
198 table.add_row(vec![
199 Cell::new(run.id.to_string().split('-').next().unwrap_or("")),
200 Cell::new(&run.workflow_name),
201 status_cell,
202 Cell::new(&run.created_by.label),
203 Cell::new(format_duration_ms(run.duration_ms)),
204 cost_cell(run.cost_usd, run.max_cost_usd),
205 Cell::new(format_datetime(&run.created_at)),
206 Cell::new(format_optional_datetime(&run.started_at)),
207 ]);
208 }
209
210 table
211}
212
213pub fn run_detail_table(detail: &RunDetailResponse) -> Table {
215 let run = &detail.run;
216 let mut table = base_table();
217 table.set_header(vec!["Field", "Value"]);
218
219 let status_cell = Cell::new(run.status).fg(status_color(&run.status));
220
221 table.add_row(vec![Cell::new("ID"), Cell::new(run.id)]);
222 table.add_row(vec![Cell::new("Workflow"), Cell::new(&run.workflow_name)]);
223 table.add_row(vec![Cell::new("Status"), status_cell]);
224 table.add_row(vec![
225 Cell::new("Trigger"),
226 Cell::new(format!("{:?}", run.trigger)),
227 ]);
228 table.add_row(vec![
229 Cell::new("Triggered by"),
230 Cell::new(&run.created_by.label),
231 ]);
232 table.add_row(vec![
233 Cell::new("Duration"),
234 Cell::new(format_duration_ms(run.duration_ms)),
235 ]);
236 table.add_row(vec![
237 Cell::new("Cost"),
238 cost_cell(run.cost_usd, run.max_cost_usd),
239 ]);
240 table.add_row(vec![
241 Cell::new("Created"),
242 Cell::new(format_datetime(&run.created_at)),
243 ]);
244 table.add_row(vec![
245 Cell::new("Started"),
246 Cell::new(format_optional_datetime(&run.started_at)),
247 ]);
248 table.add_row(vec![
249 Cell::new("Completed"),
250 Cell::new(format_optional_datetime(&run.completed_at)),
251 ]);
252 table.add_row(vec![
253 Cell::new("Retries"),
254 Cell::new(format!("{}/{}", run.retry_count, run.max_retries)),
255 ]);
256
257 if let Some(ref error) = run.error {
258 table.add_row(vec![Cell::new("Error"), Cell::new(error).fg(Color::Red)]);
259 }
260
261 if !detail.steps.is_empty() {
262 table.add_row(vec![
263 Cell::new("Steps"),
264 Cell::new(format!("{} step(s)", detail.steps.len())),
265 ]);
266 }
267
268 table
269}
270
271fn format_artifacts(artifacts: &[ArtifactResponse]) -> String {
275 if artifacts.is_empty() {
276 return "-".to_string();
277 }
278
279 let total: i64 = artifacts.iter().map(|artifact| artifact.size_bytes).sum();
280 format!("{} ({})", artifacts.len(), format_bytes(total))
281}
282
283fn format_bytes(bytes: i64) -> String {
285 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
286
287 if bytes < 1024 {
288 return format!("{bytes} B");
289 }
290
291 let mut value = bytes as f64;
292 let mut unit = 0;
293 while value >= 1024.0 && unit < UNITS.len() - 1 {
294 value /= 1024.0;
295 unit += 1;
296 }
297
298 let decimals = if value < 10.0 { 1 } else { 0 };
299 format!("{value:.decimals$} {}", UNITS[unit])
300}
301
302pub fn steps_table(steps: &[StepResponse]) -> Table {
304 let mut table = base_table();
305 table.set_header(vec![
306 "ID",
307 "Name",
308 "Status",
309 "Attempt",
310 "Duration",
311 "Cost",
312 "Artifacts",
313 "Started",
314 "Completed",
315 ]);
316
317 for step in steps {
318 let color = step_status_color(&step.status);
319
320 table.add_row(vec![
321 Cell::new(step.id.to_string().split('-').next().unwrap_or("")),
322 Cell::new(&step.name),
323 Cell::new(step.status)
324 .fg(color)
325 .set_alignment(CellAlignment::Center),
326 Cell::new(step.attempt).set_alignment(CellAlignment::Center),
327 Cell::new(format_duration_ms(step.duration_ms)),
328 Cell::new(format!("${:.4}", step.cost_usd)),
329 Cell::new(format_artifacts(&step.artifacts)).set_alignment(CellAlignment::Center),
330 Cell::new(format_optional_datetime(&step.started_at)),
331 Cell::new(format_optional_datetime(&step.completed_at)),
332 ]);
333 }
334
335 table
336}
337
338pub fn workflows_table(workflows: &[WorkflowSummary]) -> Table {
340 let mut table = base_table();
341 table.set_header(vec!["Name", "Category", "Version"]);
342
343 for wf in workflows {
344 table.add_row(vec![
345 Cell::new(&wf.name),
346 Cell::new(wf.category.as_deref().unwrap_or("-")),
347 Cell::new(wf.version.as_deref().unwrap_or("-")),
348 ]);
349 }
350
351 table
352}
353
354pub fn workflow_detail_table(detail: &WorkflowDetailResponse) -> Table {
356 let mut table = base_table();
357 table.set_header(vec!["Field", "Value"]);
358
359 table.add_row(vec![Cell::new("Name"), Cell::new(&detail.name)]);
360 table.add_row(vec![
361 Cell::new("Description"),
362 Cell::new(&detail.description),
363 ]);
364 table.add_row(vec![
365 Cell::new("Category"),
366 Cell::new(detail.category.as_deref().unwrap_or("-")),
367 ]);
368 table.add_row(vec![
369 Cell::new("Version"),
370 Cell::new(detail.version.as_deref().unwrap_or("-")),
371 ]);
372
373 if !detail.sub_workflows.is_empty() {
374 let names: Vec<&str> = detail
375 .sub_workflows
376 .iter()
377 .map(|s| s.name.as_str())
378 .collect();
379 table.add_row(vec![
380 Cell::new("Sub-workflows"),
381 Cell::new(names.join(", ")),
382 ]);
383 }
384
385 table
386}
387
388pub fn stats_table(stats: &StatsResponse) -> Table {
390 let mut table = base_table();
391 table.set_header(vec!["Metric", "Value"]);
392
393 table.add_row(vec![Cell::new("Total runs"), Cell::new(stats.total_runs)]);
394 table.add_row(vec![
395 Cell::new("Completed"),
396 Cell::new(stats.completed_runs).fg(Color::Green),
397 ]);
398 table.add_row(vec![
399 Cell::new("Failed"),
400 Cell::new(stats.failed_runs).fg(Color::Red),
401 ]);
402 table.add_row(vec![
403 Cell::new("Cancelled"),
404 Cell::new(stats.cancelled_runs).fg(Color::Grey),
405 ]);
406 table.add_row(vec![
407 Cell::new("Active"),
408 Cell::new(stats.active_runs).fg(Color::Blue),
409 ]);
410 table.add_row(vec![
411 Cell::new("Success rate"),
412 Cell::new(format!("{:.1}%", stats.success_rate_percent)),
413 ]);
414 table.add_row(vec![
415 Cell::new("Total cost"),
416 Cell::new(format!("${:.4}", stats.total_cost_usd)),
417 ]);
418 table.add_row(vec![
419 Cell::new("Total duration"),
420 Cell::new(format_duration_ms(stats.total_duration_ms)),
421 ]);
422
423 table
424}
425
426pub fn stats_history_table(history: &StatsHistoryResponse) -> Table {
428 let mut table = base_table();
429 table.set_header(vec![
430 "Time",
431 "Completed",
432 "Failed",
433 "Cancelled",
434 "Avg (ms)",
435 "P95 (ms)",
436 "Cost",
437 ]);
438
439 for bucket in &history.buckets {
440 table.add_row(vec![
441 Cell::new(bucket.time),
442 Cell::new(bucket.completed).fg(Color::Green),
443 Cell::new(bucket.failed).fg(Color::Red),
444 Cell::new(bucket.cancelled).fg(Color::Grey),
445 Cell::new(bucket.avg_duration_ms),
446 Cell::new(bucket.p95_duration_ms),
447 Cell::new(format!("${:.4}", bucket.total_cost_usd)),
448 ]);
449 }
450
451 table
452}
453
454fn format_versions(versions: &[i32]) -> String {
456 if versions.is_empty() {
457 return "-".to_string();
458 }
459 versions
460 .iter()
461 .map(|v| v.to_string())
462 .collect::<Vec<_>>()
463 .join(", ")
464}
465
466#[derive(Debug, Serialize)]
480pub struct Deleted {
481 pub kind: &'static str,
483 pub id: String,
485 pub deleted: bool,
487}
488
489impl Deleted {
490 pub fn new(kind: &'static str, id: impl Into<String>) -> Self {
492 Self {
493 kind,
494 id: id.into(),
495 deleted: true,
496 }
497 }
498}
499
500pub fn deleted_table(deleted: &Deleted) -> Table {
502 let mut table = base_table();
503 table.set_header(vec!["Deleted", "ID"]);
504 table.add_row(vec![Cell::new(deleted.kind), Cell::new(&deleted.id)]);
505 table
506}
507
508pub fn report_deletion(json_mode: bool, kind: &'static str, id: impl Into<String>) -> Result<()> {
525 let deleted = Deleted::new(kind, id);
526 print_output(json_mode, &deleted, || deleted_table(&deleted))
527}
528
529pub fn secrets_table(secrets: &[SecretResponse]) -> Table {
534 let mut table = base_table();
535 table.set_header(vec!["Key", "Created", "Updated"]);
536
537 for secret in secrets {
538 table.add_row(vec![
539 Cell::new(&secret.key),
540 Cell::new(format_datetime(&secret.created_at)),
541 Cell::new(format_datetime(&secret.updated_at)),
542 ]);
543 }
544
545 table
546}
547
548fn format_scopes(scopes: &[ApiKeyScope]) -> String {
550 scopes
551 .iter()
552 .map(ToString::to_string)
553 .collect::<Vec<_>>()
554 .join(", ")
555}
556
557pub fn key_versions_table(status: &KeyVersionsResponse) -> Table {
559 let mut table = base_table();
560 table.set_header(vec!["Property", "Versions"]);
561
562 table.add_row(vec![
563 Cell::new("Active"),
564 Cell::new(status.active).fg(Color::Green),
565 ]);
566 table.add_row(vec![
567 Cell::new("Configured"),
568 Cell::new(format_versions(&status.configured)),
569 ]);
570 table.add_row(vec![
571 Cell::new("In use"),
572 Cell::new(format_versions(&status.in_use)),
573 ]);
574 table.add_row(vec![
575 Cell::new("Missing"),
576 Cell::new(format_versions(&status.missing)).fg(if status.missing.is_empty() {
577 Color::Grey
578 } else {
579 Color::Red
580 }),
581 ]);
582 table.add_row(vec![
583 Cell::new("Retirable"),
584 Cell::new(format_versions(&status.retirable)).fg(if status.retirable.is_empty() {
585 Color::Grey
586 } else {
587 Color::Yellow
588 }),
589 ]);
590
591 table
592}
593
594pub fn api_keys_table(keys: &[ApiKeyResponse]) -> Table {
598 let mut table = base_table();
599 table.set_header(vec![
600 "ID",
601 "Name",
602 "Prefix",
603 "Scopes",
604 "Active",
605 "Rate limit",
606 "Last used",
607 "Expires",
608 "Created",
609 ]);
610
611 for key in keys {
612 let active = Cell::new(if key.is_active { "yes" } else { "no" })
613 .fg(if key.is_active {
614 Color::Green
615 } else {
616 Color::Grey
617 })
618 .set_alignment(CellAlignment::Center);
619
620 let rate_limit = key
621 .rate_limit_override
622 .map(|v| v.to_string())
623 .unwrap_or_else(|| "-".to_string());
624
625 table.add_row(vec![
626 Cell::new(key.id),
627 Cell::new(&key.name),
628 Cell::new(&key.key_prefix),
629 Cell::new(format_scopes(&key.scopes)),
630 active,
631 Cell::new(rate_limit),
632 Cell::new(format_optional_datetime(&key.last_used_at)),
633 Cell::new(format_optional_datetime(&key.expires_at)),
634 Cell::new(format_datetime(&key.created_at)),
635 ]);
636 }
637
638 table
639}
640
641pub fn created_api_key_table(key: &CreateApiKeyResponse) -> Table {
647 let mut table = base_table();
648 table.set_header(vec!["Field", "Value"]);
649
650 table.add_row(vec![Cell::new("ID"), Cell::new(key.id)]);
651 table.add_row(vec![Cell::new("Name"), Cell::new(&key.name)]);
652 table.add_row(vec![
653 Cell::new("Key"),
654 Cell::new(&key.key).fg(Color::Yellow),
655 ]);
656 table.add_row(vec![Cell::new("Prefix"), Cell::new(&key.key_prefix)]);
657 table.add_row(vec![
658 Cell::new("Scopes"),
659 Cell::new(format_scopes(&key.scopes)),
660 ]);
661 if let Some(override_val) = key.rate_limit_override {
662 table.add_row(vec![
663 Cell::new("Rate limit"),
664 Cell::new(format!("{override_val} req/min")),
665 ]);
666 }
667 table.add_row(vec![
668 Cell::new("Expires"),
669 Cell::new(format_optional_datetime(&key.expires_at)),
670 ]);
671 table.add_row(vec![
672 Cell::new("Created"),
673 Cell::new(format_datetime(&key.created_at)),
674 ]);
675
676 table
677}
678
679pub fn scopes_table(scopes: &[ScopeEntry]) -> Table {
681 let mut table = base_table();
682 table.set_header(vec!["Value", "Label", "Description"]);
683
684 for scope in scopes {
685 table.add_row(vec![
686 Cell::new(&scope.value),
687 Cell::new(&scope.label),
688 Cell::new(&scope.description),
689 ]);
690 }
691
692 table
693}
694
695pub fn users_table(users: &[UserResponse]) -> Table {
697 let mut table = base_table();
698 table.set_header(vec!["ID", "Username", "Email", "Admin", "Created"]);
699
700 for user in users {
701 let admin = Cell::new(if user.is_admin { "yes" } else { "no" })
702 .fg(if user.is_admin {
703 Color::Magenta
704 } else {
705 Color::Grey
706 })
707 .set_alignment(CellAlignment::Center);
708
709 table.add_row(vec![
710 Cell::new(user.id),
711 Cell::new(&user.username),
712 Cell::new(&user.email),
713 admin,
714 Cell::new(format_datetime(&user.created_at)),
715 ]);
716 }
717
718 table
719}
720
721pub fn run_diff_table(a: &RunDetailResponse, b: &RunDetailResponse) -> Table {
723 let (ra, rb) = (&a.run, &b.run);
724 let mut table = base_table();
725 table.set_header(vec![
726 "Field",
727 &format!("Run {}", short_id(ra.id)),
728 &format!("Run {}", short_id(rb.id)),
729 ]);
730
731 let row = |f: &str, va: String, vb: String| -> Vec<Cell> {
732 let hl = va != vb;
733 vec![
734 Cell::new(f),
735 if hl {
736 Cell::new(&va).fg(Color::Yellow)
737 } else {
738 Cell::new(&va)
739 },
740 if hl {
741 Cell::new(&vb).fg(Color::Yellow)
742 } else {
743 Cell::new(&vb)
744 },
745 ]
746 };
747
748 table.add_row(row("Status", ra.status.to_string(), rb.status.to_string()));
749 table.add_row(row(
750 "Duration",
751 format_duration_ms(ra.duration_ms),
752 format_duration_ms(rb.duration_ms),
753 ));
754 table.add_row(row(
755 "Cost",
756 format_cost(ra.cost_usd, ra.max_cost_usd),
757 format_cost(rb.cost_usd, rb.max_cost_usd),
758 ));
759 table.add_row(row(
760 "Started",
761 format_optional_datetime(&ra.started_at),
762 format_optional_datetime(&rb.started_at),
763 ));
764 table.add_row(row(
765 "Completed",
766 format_optional_datetime(&ra.completed_at),
767 format_optional_datetime(&rb.completed_at),
768 ));
769 table.add_row(row(
770 "Error",
771 ra.error.clone().unwrap_or("-".into()),
772 rb.error.clone().unwrap_or("-".into()),
773 ));
774 if a.payload != b.payload {
775 table.add_row(row(
776 "Payload",
777 serde_json::to_string(&a.payload).unwrap_or_default(),
778 serde_json::to_string(&b.payload).unwrap_or_default(),
779 ));
780 }
781 for i in 0..a.steps.len().max(b.steps.len()) {
782 let (sa, sb) = (a.steps.get(i), b.steps.get(i));
783 let name = sa.or(sb).map(|s| s.name.as_str()).unwrap_or("-");
784 table.add_row(row(
785 &format!("{name} status"),
786 sa.map(|s| s.status.to_string()).unwrap_or("-".into()),
787 sb.map(|s| s.status.to_string()).unwrap_or("-".into()),
788 ));
789 table.add_row(row(
790 &format!("{name} duration"),
791 sa.map(|s| format_duration_ms(s.duration_ms))
792 .unwrap_or("-".into()),
793 sb.map(|s| format_duration_ms(s.duration_ms))
794 .unwrap_or("-".into()),
795 ));
796 table.add_row(row(
797 &format!("{name} cost"),
798 sa.map(|s| format!("${:.4}", s.cost_usd))
799 .unwrap_or("-".into()),
800 sb.map(|s| format!("${:.4}", s.cost_usd))
801 .unwrap_or("-".into()),
802 ));
803 }
804 table
805}
806
807fn short_id(id: Uuid) -> String {
809 id.to_string()
810 .split('-')
811 .next()
812 .unwrap_or_default()
813 .to_string()
814}
815
816fn format_optional_id(id: &Option<Uuid>) -> String {
818 id.map_or_else(|| "-".to_string(), short_id)
819}
820
821pub fn audit_logs_table(entries: &[AuditLogEntry]) -> Table {
826 let mut table = base_table();
827 table.set_header(vec!["ID", "Type", "Run", "Step", "User", "Created"]);
828
829 for entry in entries {
830 table.add_row(vec![
831 Cell::new(short_id(entry.id)),
832 Cell::new(entry.event_type.to_string()),
833 Cell::new(format_optional_id(&entry.run_id)),
834 Cell::new(format_optional_id(&entry.step_id)),
835 Cell::new(format_optional_id(&entry.user_id)),
836 Cell::new(format_datetime(&entry.created_at)),
837 ]);
838 }
839
840 table
841}
842
843#[cfg(test)]
844mod tests {
845 use std::collections::HashMap;
846 use std::slice;
847
848 use ironflow_sdk::types::{ApiKeyScope, CreatedBy, CreatedByKind, EventKind, TriggerKind};
849 use serde_json::{Map, Value};
850
851 use super::*;
852
853 fn run_fixture(created_by: CreatedBy) -> RunResponse {
855 let now = Utc::now();
856 RunResponse {
857 id: Uuid::now_v7(),
858 workflow_name: "deploy".to_string(),
859 status: RunStatus::Completed,
860 trigger: TriggerKind::Api,
861 error: None,
862 retry_count: 0,
863 max_retries: 0,
864 cost_usd: 0.0,
865 duration_ms: 0,
866 created_at: now,
867 updated_at: now,
868 started_at: None,
869 completed_at: None,
870 handler_version: None,
871 labels: HashMap::new(),
872 scheduled_at: None,
873 created_by,
874 idempotency_key: None,
875 max_cost_usd: None,
876 }
877 }
878
879 #[test]
880 fn format_cost_without_cap_shows_amount_only() {
881 assert_eq!(format_cost(0.1234, None), "$0.1234");
882 }
883
884 #[test]
885 fn format_cost_with_cap_shows_both_amounts() {
886 assert_eq!(format_cost(0.18, Some(2.0)), "$0.1800 / $2.00");
887 }
888
889 #[test]
890 fn cost_color_is_absent_without_a_cap() {
891 assert_eq!(cost_color(999.0, None), None);
892 }
893
894 #[test]
895 fn cost_color_warns_past_the_threshold_and_alerts_at_the_cap() {
896 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));
899 assert_eq!(cost_color(2.0, Some(2.0)), Some(Color::Red)); assert_eq!(cost_color(2.5, Some(2.0)), Some(Color::Red)); }
902
903 #[test]
904 fn cost_color_handles_a_zero_cap() {
905 assert_eq!(cost_color(0.0, Some(0.0)), None);
906 assert_eq!(cost_color(0.01, Some(0.0)), Some(Color::Red));
907 }
908
909 fn artifact(name: &str, size_bytes: i64) -> ArtifactResponse {
910 ArtifactResponse {
911 id: Uuid::now_v7(),
912 step_id: Uuid::now_v7(),
913 name: name.to_string(),
914 content_type: "text/plain".to_string(),
915 size_bytes,
916 sha256: "0".repeat(64),
917 created_at: Utc::now(),
918 }
919 }
920
921 #[test]
922 fn format_bytes_keeps_raw_bytes_below_one_kilobyte() {
923 assert_eq!(format_bytes(0), "0 B");
924 assert_eq!(format_bytes(1023), "1023 B");
925 }
926
927 #[test]
928 fn format_bytes_switches_units_at_each_boundary() {
929 assert_eq!(format_bytes(1024), "1.0 KB");
930 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
931 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
932 }
933
934 #[test]
935 fn format_bytes_drops_the_decimal_past_ten() {
936 assert_eq!(format_bytes(145_408), "142 KB");
937 }
938
939 #[test]
940 fn format_artifacts_shows_a_dash_when_there_are_none() {
941 assert_eq!(format_artifacts(&[]), "-");
942 }
943
944 #[test]
945 fn format_artifacts_shows_the_count_and_total_size() {
946 let artifacts = vec![artifact("a.txt", 1024), artifact("b.txt", 1024)];
947 assert_eq!(format_artifacts(&artifacts), "2 (2.0 KB)");
948 }
949
950 #[test]
951 fn format_duration_ms_millis() {
952 assert_eq!(format_duration_ms(500), "500ms");
953 assert_eq!(format_duration_ms(0), "0ms");
954 }
955
956 #[test]
957 fn format_duration_ms_seconds() {
958 assert_eq!(format_duration_ms(5000), "5s");
959 assert_eq!(format_duration_ms(59000), "59s");
960 }
961
962 #[test]
963 fn format_duration_ms_minutes() {
964 assert_eq!(format_duration_ms(60000), "1m 0s");
965 assert_eq!(format_duration_ms(125000), "2m 5s");
966 }
967
968 #[test]
969 fn format_duration_ms_hours() {
970 assert_eq!(format_duration_ms(3_600_000), "1h 0m");
971 assert_eq!(format_duration_ms(5_400_000), "1h 30m");
972 }
973
974 #[test]
975 fn format_optional_datetime_none() {
976 assert_eq!(format_optional_datetime(&None), "-");
977 }
978
979 #[test]
980 fn format_optional_datetime_some() {
981 let dt = "2026-06-02T14:30:00Z".parse::<DateTime<Utc>>().unwrap();
982 assert_eq!(format_optional_datetime(&Some(dt)), "2026-06-02 14:30:00");
983 }
984
985 #[test]
986 fn status_colors_are_distinct() {
987 let statuses = [
988 RunStatus::Completed,
989 RunStatus::Failed,
990 RunStatus::Running,
991 RunStatus::Pending,
992 RunStatus::Cancelled,
993 RunStatus::AwaitingApproval,
994 RunStatus::Retrying,
995 ];
996
997 let colors: Vec<Color> = statuses.iter().map(status_color).collect();
998 for (i, c1) in colors.iter().enumerate() {
999 for (j, c2) in colors.iter().enumerate() {
1000 if i != j {
1001 assert_ne!(c1, c2, "status colors must be distinct");
1002 }
1003 }
1004 }
1005 }
1006
1007 #[test]
1008 fn empty_runs_table_has_header() {
1009 let table = runs_table(&[]);
1010 let output = table.to_string();
1011 assert!(output.contains("ID"));
1012 assert!(output.contains("Workflow"));
1013 assert!(output.contains("Status"));
1014 assert!(output.contains("Triggered by"));
1015 }
1016
1017 #[test]
1018 fn runs_table_renders_the_author_label() {
1019 let run = run_fixture(CreatedBy {
1020 kind: CreatedByKind::ApiKey,
1021 id: Some(Uuid::now_v7()),
1022 label: "ci-deploy (alice)".to_string(),
1023 });
1024
1025 let output = runs_table(slice::from_ref(&run)).to_string();
1026 assert!(
1027 output.contains("ci-deploy (alice)"),
1028 "author missing from:\n{output}"
1029 );
1030 }
1031
1032 #[test]
1033 fn run_detail_table_renders_the_author_label() {
1034 let detail = RunDetailResponse {
1035 run: run_fixture(CreatedBy {
1036 kind: CreatedByKind::System,
1037 id: None,
1038 label: "/hooks/github".to_string(),
1039 }),
1040 steps: Vec::new(),
1041 payload: Value::Object(Map::new()),
1042 };
1043
1044 let output = run_detail_table(&detail).to_string();
1045 assert!(output.contains("Triggered by"));
1046 assert!(
1047 output.contains("/hooks/github"),
1048 "author missing from:\n{output}"
1049 );
1050 }
1051
1052 #[test]
1053 fn empty_workflows_table_has_header() {
1054 let table = workflows_table(&[]);
1055 let output = table.to_string();
1056 assert!(output.contains("Name"));
1057 assert!(output.contains("Category"));
1058 }
1059
1060 fn secret_fixture(key: &str) -> SecretResponse {
1063 let now = Utc::now();
1064 SecretResponse {
1065 id: Uuid::now_v7(),
1066 key: key.to_string(),
1067 created_at: now,
1068 updated_at: now,
1069 }
1070 }
1071
1072 #[test]
1073 fn empty_secrets_table_has_header() {
1074 let output = secrets_table(&[]).to_string();
1075 assert!(output.contains("Key"));
1076 assert!(output.contains("Created"));
1077 assert!(output.contains("Updated"));
1078 }
1079
1080 #[test]
1081 fn secrets_table_renders_the_key() {
1082 let secret = secret_fixture("workflows/inbox/gmail_token");
1083 let output = secrets_table(slice::from_ref(&secret)).to_string();
1084 assert!(output.contains("workflows/inbox/gmail_token"), "{output}");
1085 }
1086
1087 #[test]
1090 fn a_secret_response_carries_no_value_at_all() {
1091 let secret = secret_fixture("db/password");
1092 let json = serde_json::to_string(&secret).unwrap();
1093 assert!(!json.contains("value"), "{json}");
1094 }
1095
1096 fn api_key_fixture() -> ApiKeyResponse {
1099 ApiKeyResponse {
1100 id: Uuid::now_v7(),
1101 name: "ci-deploy".to_string(),
1102 key_prefix: "ifk_abcd".to_string(),
1103 scopes: vec![ApiKeyScope::RunsRead, ApiKeyScope::RunsWrite],
1104 is_active: true,
1105 created_at: Utc::now(),
1106 expires_at: None,
1107 last_used_at: None,
1108 rate_limit_override: None,
1109 }
1110 }
1111
1112 #[test]
1113 fn empty_api_keys_table_has_header() {
1114 let output = api_keys_table(&[]).to_string();
1115 for header in ["ID", "Name", "Prefix", "Scopes", "Active"] {
1116 assert!(output.contains(header), "missing {header} in {output}");
1117 }
1118 }
1119
1120 #[test]
1121 fn api_keys_table_joins_the_scopes() {
1122 let key = api_key_fixture();
1123 let output = api_keys_table(slice::from_ref(&key)).to_string();
1124 assert!(output.contains("runs_read, runs_write"), "{output}");
1125 assert!(output.contains("ifk_abcd"), "{output}");
1126 }
1127
1128 #[test]
1129 fn created_api_key_table_shows_the_raw_key() {
1130 let created = CreateApiKeyResponse {
1131 id: Uuid::now_v7(),
1132 name: "ci-deploy".to_string(),
1133 key: "ifk_full_raw_key".to_string(),
1134 key_prefix: "ifk_full".to_string(),
1135 scopes: vec![ApiKeyScope::Admin],
1136 created_at: Utc::now(),
1137 expires_at: None,
1138 rate_limit_override: None,
1139 };
1140
1141 let output = created_api_key_table(&created).to_string();
1142 assert!(output.contains("ifk_full_raw_key"), "{output}");
1143 }
1144
1145 #[test]
1146 fn empty_scopes_table_has_header() {
1147 let output = scopes_table(&[]).to_string();
1148 assert!(output.contains("Value"));
1149 assert!(output.contains("Description"));
1150 }
1151
1152 fn user_fixture(is_admin: bool) -> UserResponse {
1155 let now = Utc::now();
1156 UserResponse {
1157 id: Uuid::now_v7(),
1158 username: "alice".to_string(),
1159 email: "alice@example.com".to_string(),
1160 is_admin,
1161 created_at: now,
1162 updated_at: now,
1163 }
1164 }
1165
1166 #[test]
1167 fn empty_users_table_has_header() {
1168 let output = users_table(&[]).to_string();
1169 for header in ["ID", "Username", "Email", "Admin", "Created"] {
1170 assert!(output.contains(header), "missing {header} in {output}");
1171 }
1172 }
1173
1174 #[test]
1175 fn users_table_spells_out_the_role() {
1176 let admin = user_fixture(true);
1177 assert!(
1178 users_table(slice::from_ref(&admin))
1179 .to_string()
1180 .contains("yes")
1181 );
1182
1183 let member = user_fixture(false);
1184 assert!(
1185 users_table(slice::from_ref(&member))
1186 .to_string()
1187 .contains("no")
1188 );
1189 }
1190
1191 #[test]
1194 fn empty_audit_logs_table_has_header() {
1195 let output = audit_logs_table(&[]).to_string();
1196 for header in ["ID", "Type", "Run", "Step", "User", "Created"] {
1197 assert!(output.contains(header), "missing {header} in {output}");
1198 }
1199 }
1200
1201 #[test]
1202 fn audit_logs_table_omits_the_payload() {
1203 let entry = AuditLogEntry {
1204 id: Uuid::now_v7(),
1205 event_type: EventKind::RunCreated,
1206 payload: Value::Object(Map::new()),
1207 run_id: Some(Uuid::now_v7()),
1208 step_id: None,
1209 user_id: None,
1210 created_at: Utc::now(),
1211 };
1212
1213 let output = audit_logs_table(slice::from_ref(&entry)).to_string();
1214 assert!(output.contains("run_created"), "{output}");
1215 assert!(output.contains('-'), "{output}");
1217 }
1218
1219 #[test]
1220 fn format_optional_id_shortens_and_falls_back() {
1221 assert_eq!(format_optional_id(&None), "-");
1222 let id = Uuid::now_v7();
1223 let short = format_optional_id(&Some(id));
1224 assert_eq!(short, id.to_string().split('-').next().unwrap());
1225 }
1226
1227 #[test]
1230 fn deleted_table_reports_the_kind_and_id() {
1231 let deleted = Deleted::new("secret", "db/password");
1232 let output = deleted_table(&deleted).to_string();
1233 assert!(output.contains("secret"), "{output}");
1234 assert!(output.contains("db/password"), "{output}");
1235
1236 let json = serde_json::to_string(&deleted).unwrap();
1237 assert!(json.contains(r#""deleted":true"#), "{json}");
1238 }
1239}