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