1use std::{collections::HashMap, time::Duration};
2
3use chrono::{SecondsFormat, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::error::DetailedError;
8
9#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
11pub struct Envelope {
12 #[serde(skip_serializing_if = "is_absent_or_null")]
14 pub data: Option<Value>,
15 #[serde(skip_serializing_if = "Option::is_none")]
24 pub pagination: Option<PaginationMeta>,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub metadata: Option<Metadata>,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub error: Option<ErrorEnvelope>,
31 #[serde(default, skip_serializing_if = "Vec::is_empty")]
33 pub warnings: Vec<String>,
34 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub next_actions: Vec<NextAction>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub fix: Option<String>,
40 #[serde(default, skip)]
41 serialization_error: Option<String>,
42}
43
44#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
50#[non_exhaustive]
51pub struct NextAction {
52 pub command: String,
57 pub description: String,
59 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
61 pub params: HashMap<String, NextActionParam>,
62}
63
64impl NextAction {
65 #[must_use]
67 pub fn new(command: impl Into<String>, description: impl Into<String>) -> Self {
68 Self {
69 command: command.into(),
70 description: description.into(),
71 params: HashMap::new(),
72 }
73 }
74
75 #[must_use]
77 pub fn with_param(mut self, name: impl Into<String>, param: NextActionParam) -> Self {
78 self.params.insert(name.into(), param);
79 self
80 }
81}
82
83#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Default)]
85pub struct NextActionParam {
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub value: Option<String>,
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub r#enum: Vec<String>,
92 #[serde(default, skip_serializing_if = "is_false")]
94 pub required: bool,
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub default: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub description: Option<String>,
101}
102
103impl NextActionParam {
104 #[must_use]
106 pub fn value(value: impl Into<String>) -> Self {
107 Self {
108 value: Some(value.into()),
109 ..Self::default()
110 }
111 }
112
113 #[must_use]
115 pub fn required() -> Self {
116 Self {
117 required: true,
118 ..Self::default()
119 }
120 }
121}
122
123#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
125pub struct Metadata {
126 pub system: String,
128 pub timestamp: String,
130 #[serde(skip_serializing_if = "String::is_empty")]
132 pub request_id: String,
133 #[serde(skip_serializing_if = "is_false")]
135 pub dry_run: bool,
136 #[serde(skip_serializing_if = "String::is_empty")]
138 pub command: String,
139 #[serde(skip_serializing_if = "String::is_empty")]
141 pub duration: String,
142 #[serde(skip_serializing_if = "String::is_empty")]
144 pub env: String,
145 #[serde(skip_serializing_if = "String::is_empty")]
147 pub identity: String,
148 #[serde(skip_serializing_if = "is_absent_null_or_empty_object")]
150 pub args: Option<Value>,
151 #[serde(skip_serializing_if = "is_absent_null_or_empty_object")]
153 pub effective_args: Option<Value>,
154}
155
156#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
158pub struct PaginationMeta {
159 pub total: i64,
161 pub offset: i64,
163 pub limit: i64,
165 pub count: i64,
167 pub has_more: bool,
169}
170
171#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
173pub struct ErrorEnvelope {
174 pub code: String,
176 pub message: String,
178 #[serde(skip_serializing_if = "String::is_empty")]
180 pub system: String,
181 #[serde(skip_serializing_if = "String::is_empty")]
183 pub request_id: String,
184}
185
186impl Envelope {
187 #[must_use]
189 pub fn success(data: impl Serialize, system: impl Into<String>) -> Self {
190 let (data, serialization_error) = match serde_json::to_value(data) {
191 Ok(data) => (Some(data), None),
192 Err(err) => (None, Some(err.to_string())),
193 };
194 Self {
195 data,
196 pagination: None,
197 metadata: Some(Metadata::new(system)),
198 error: None,
199 warnings: Vec::new(),
200 next_actions: Vec::new(),
201 fix: None,
202 serialization_error,
203 }
204 }
205
206 #[must_use]
208 pub fn error(
209 code: impl Into<String>,
210 message: impl Into<String>,
211 system: impl Into<String>,
212 ) -> Self {
213 let system = system.into();
214 Self {
215 data: None,
216 pagination: None,
217 metadata: Some(Metadata::new(system.clone())),
218 error: Some(ErrorEnvelope {
219 code: code.into(),
220 message: message.into(),
221 system,
222 request_id: String::new(),
223 }),
224 warnings: Vec::new(),
225 next_actions: Vec::new(),
226 fix: None,
227 serialization_error: None,
228 }
229 }
230
231 #[must_use]
233 pub fn error_detail(
234 code: impl Into<String>,
235 message: impl Into<String>,
236 system: impl Into<String>,
237 request_id: impl Into<String>,
238 ) -> Self {
239 let system = system.into();
240 let request_id = request_id.into();
241 Self {
242 data: None,
243 pagination: None,
244 metadata: Some(Metadata {
245 request_id: request_id.clone(),
246 ..Metadata::new(system.clone())
247 }),
248 error: Some(ErrorEnvelope {
249 code: code.into(),
250 message: message.into(),
251 system,
252 request_id,
253 }),
254 warnings: Vec::new(),
255 next_actions: Vec::new(),
256 fix: None,
257 serialization_error: None,
258 }
259 }
260
261 #[must_use]
263 pub fn with_next_actions(mut self, actions: Vec<NextAction>) -> Self {
264 self.next_actions = actions;
265 self
266 }
267
268 #[must_use]
270 pub fn with_fix(mut self, fix: impl Into<String>) -> Self {
271 if self.error.is_some() {
272 let fix = fix.into();
273 self.fix = (!fix.is_empty()).then_some(fix);
274 }
275 self
276 }
277
278 #[must_use]
280 pub fn with_dry_run(mut self) -> Self {
281 if let Some(metadata) = &mut self.metadata {
282 metadata.dry_run = true;
283 }
284 self
285 }
286
287 pub fn with_context(
289 &mut self,
290 command: &str,
291 env: &str,
292 identity: &str,
293 duration: Duration,
294 user_args: Option<Value>,
295 effective_args: Option<Value>,
296 ) {
297 if let Some(metadata) = &mut self.metadata {
298 metadata.command = command.to_owned();
299 metadata.env = env.to_owned();
300 metadata.identity = identity.to_owned();
301 metadata.duration = format_duration(duration);
302 metadata.args = user_args;
303 metadata.effective_args = effective_args;
304 }
305 }
306
307 #[must_use]
309 pub fn prepare_for_render(&self, verbose: &str) -> Self {
310 let mut copy = self.clone();
311 if verbose.is_empty() {
312 copy.metadata = None;
313 return copy;
314 }
315 if verbose == "all" {
316 return copy;
317 }
318 if let Some(metadata) = &self.metadata {
319 copy.metadata = Some(metadata.filter_fields(verbose));
320 }
321 copy
322 }
323
324 pub fn add_warning(&mut self, message: impl Into<String>) {
326 self.warnings.push(message.into());
327 }
328
329 pub(crate) fn serialization_result(&self) -> crate::Result<()> {
330 if let Some(error) = &self.serialization_error {
331 return Err(crate::CliCoreError::message(error.clone()));
332 }
333 Ok(())
334 }
335}
336
337impl Metadata {
338 #[must_use]
340 pub fn new(system: impl Into<String>) -> Self {
341 Self {
342 system: system.into(),
343 timestamp: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
344 request_id: String::new(),
345 dry_run: false,
346 command: String::new(),
347 duration: String::new(),
348 env: String::new(),
349 identity: String::new(),
350 args: None,
351 effective_args: None,
352 }
353 }
354
355 fn filter_fields(&self, verbose: &str) -> Self {
356 let wanted = verbose
357 .split(',')
358 .map(str::trim)
359 .filter(|field| !field.is_empty())
360 .collect::<Vec<_>>();
361 Self {
362 system: keep_string(&wanted, "system", &self.system),
363 timestamp: keep_string(&wanted, "timestamp", &self.timestamp),
364 request_id: keep_string(&wanted, "request_id", &self.request_id),
365 dry_run: wanted.contains(&"dry_run") && self.dry_run,
366 command: keep_string(&wanted, "command", &self.command),
367 duration: keep_string(&wanted, "duration", &self.duration),
368 env: keep_string(&wanted, "env", &self.env),
369 identity: keep_string(&wanted, "identity", &self.identity),
370 args: wanted
371 .contains(&"args")
372 .then(|| self.args.clone())
373 .flatten(),
374 effective_args: wanted
375 .contains(&"effective_args")
376 .then(|| self.effective_args.clone())
377 .flatten(),
378 }
379 }
380}
381
382#[must_use]
384pub fn build_error_envelope(err: &(dyn std::error::Error + 'static), system: &str) -> Envelope {
385 let fix = find_error_fix(err);
386 if let Some((code, mut sys, request_id, next_actions)) = find_detailed_error(err) {
387 if sys.is_empty() {
388 sys = system.to_owned();
389 }
390 return Envelope {
391 data: None,
392 pagination: None,
393 metadata: Some(Metadata {
394 request_id: request_id.clone(),
395 ..Metadata::new(sys.clone())
396 }),
397 error: Some(ErrorEnvelope {
398 code: if code.is_empty() {
399 "ERROR".to_owned()
400 } else {
401 code
402 },
403 message: err.to_string(),
404 system: sys,
405 request_id,
406 }),
407 warnings: Vec::new(),
408 next_actions,
409 fix,
410 serialization_error: None,
411 };
412 }
413 Envelope::error("ERROR", err.to_string(), system).with_fix(fix.unwrap_or_default())
414}
415
416fn find_error_fix(err: &(dyn std::error::Error + 'static)) -> Option<String> {
417 let mut current = Some(err);
418 while let Some(error) = current {
419 if let Some(crate::CliCoreError::Fix { fix, .. }) =
420 error.downcast_ref::<crate::CliCoreError>()
421 && !fix.is_empty()
422 {
423 return Some(fix.clone());
424 }
425 current = error.source();
426 }
427 None
428}
429
430fn find_detailed_error(
431 err: &(dyn std::error::Error + 'static),
432) -> Option<(String, String, String, Vec<NextAction>)> {
433 let mut current = Some(err);
434 let mut fallback_system = None::<String>;
435 while let Some(error) = current {
436 if let Some(crate::CliCoreError::SystemMessage {
437 system,
438 code,
439 request_id,
440 ..
441 }) = error.downcast_ref::<crate::CliCoreError>()
442 {
443 return Some((code.clone(), system.clone(), request_id.clone(), Vec::new()));
444 }
445 if let Some(crate::CliCoreError::System { system, .. }) =
446 error.downcast_ref::<crate::CliCoreError>()
447 && !system.is_empty()
448 && fallback_system.is_none()
449 {
450 fallback_system = Some(system.clone());
451 }
452 if let Some(crate::CliCoreError::Detailed {
453 code,
454 system,
455 request_id,
456 next_actions,
457 ..
458 }) = error.downcast_ref::<crate::CliCoreError>()
459 {
460 return Some((
461 code.clone(),
462 fallback_system
463 .clone()
464 .filter(|_| system.is_empty())
465 .unwrap_or_else(|| system.clone()),
466 request_id.clone(),
467 next_actions.clone(),
468 ));
469 }
470 let detailed_transport = error.downcast_ref::<crate::transport::Error>().or_else(|| {
471 match error.downcast_ref::<crate::CliCoreError>() {
472 Some(crate::CliCoreError::Transport(transport)) => Some(transport),
473 Some(
474 crate::CliCoreError::MissingAuthProvider(_)
475 | crate::CliCoreError::AuthProvider { .. }
476 | crate::CliCoreError::InvalidOutputFormat(_)
477 | crate::CliCoreError::Message(_)
478 | crate::CliCoreError::SystemMessage { .. }
479 | crate::CliCoreError::System { .. }
480 | crate::CliCoreError::Detailed { .. }
481 | crate::CliCoreError::ExitCode { .. }
482 | crate::CliCoreError::Fix { .. }
483 | crate::CliCoreError::Io(_)
484 | crate::CliCoreError::Json(_)
485 | crate::CliCoreError::EnvConfig(_),
486 )
487 | None => None,
488 }
489 });
490 if let Some(detailed) = detailed_transport {
491 let system = detailed
492 .error_system()
493 .map_or_else(String::new, std::borrow::Cow::into_owned);
494 return Some((
495 detailed.error_code().into_owned(),
496 fallback_system
497 .clone()
498 .filter(|_| system.is_empty())
499 .unwrap_or(system),
500 detailed
501 .error_request_id()
502 .map_or_else(String::new, std::borrow::Cow::into_owned),
503 detailed.error_next_actions(),
504 ));
505 }
506 current = error.source();
507 }
508 fallback_system.map(|system| ("ERROR".to_owned(), system, String::new(), Vec::new()))
509}
510
511#[must_use]
513pub fn build_detailed_error_envelope(err: &dyn DetailedError, system: &str) -> Envelope {
514 let code = err.error_code().into_owned();
515 let sys = err
516 .error_system()
517 .map_or_else(|| system.to_owned(), std::borrow::Cow::into_owned);
518 let request_id = err
519 .error_request_id()
520 .map_or_else(String::new, std::borrow::Cow::into_owned);
521 Envelope {
522 data: None,
523 pagination: None,
524 metadata: Some(Metadata {
525 request_id: request_id.clone(),
526 ..Metadata::new(sys.clone())
527 }),
528 error: Some(ErrorEnvelope {
529 code: if code.is_empty() {
530 "ERROR".to_owned()
531 } else {
532 code
533 },
534 message: err.to_string(),
535 system: sys,
536 request_id,
537 }),
538 warnings: Vec::new(),
539 next_actions: err.error_next_actions(),
540 fix: err
541 .error_fix()
542 .map(std::borrow::Cow::into_owned)
543 .filter(|fix| !fix.is_empty()),
544 serialization_error: None,
545 }
546}
547
548fn keep_string(wanted: &[&str], field: &str, value: &str) -> String {
549 if wanted.contains(&field) {
550 value.to_owned()
551 } else {
552 String::new()
553 }
554}
555
556fn format_duration(duration: Duration) -> String {
557 let nanos = duration.as_nanos();
558 let millis = (nanos + 500_000) / 1_000_000;
559 if millis == 0 {
560 return "0s".to_owned();
561 }
562 if millis >= 1000 {
563 let secs = millis / 1000;
564 let rem = millis % 1000;
565 if rem == 0 {
566 format!("{secs}s")
567 } else {
568 let mut fraction = format!("{rem:03}");
569 while fraction.ends_with('0') {
570 fraction.pop();
571 }
572 format!("{secs}.{fraction}s")
573 }
574 } else {
575 format!("{millis}ms")
576 }
577}
578
579const fn is_false(value: &bool) -> bool {
580 !*value
581}
582
583fn is_absent_or_null(value: &Option<Value>) -> bool {
584 value.as_ref().is_none_or(Value::is_null)
585}
586
587fn is_absent_null_or_empty_object(value: &Option<Value>) -> bool {
588 match value {
589 None | Some(Value::Null) => true,
590 Some(Value::Object(map)) => map.is_empty(),
591 Some(Value::Array(_) | Value::Bool(_) | Value::Number(_) | Value::String(_)) => false,
592 }
593}
594
595#[cfg(test)]
596mod tests {
597 use serde_json::json;
598
599 use super::*;
600
601 #[test]
602 fn next_actions_appear_in_serialized_envelope() {
603 let envelope =
604 Envelope::success(json!({"id": "p1"}), "projects-api").with_next_actions(vec![
605 NextAction::new("project get --id <id>", "Get project details"),
606 ]);
607
608 let serialized = serde_json::to_string(&envelope).expect("envelope serializes to JSON");
609 let parsed: Value =
610 serde_json::from_str(&serialized).expect("serialized envelope is valid JSON");
611
612 assert_eq!(
613 parsed["next_actions"][0]["command"],
614 "project get --id <id>"
615 );
616 assert_eq!(
617 parsed["next_actions"][0]["description"],
618 "Get project details"
619 );
620 }
621
622 #[test]
623 fn next_actions_omitted_from_json_when_empty() {
624 let envelope = Envelope::success(json!({"id": "p1"}), "projects-api");
625
626 let serialized = serde_json::to_string(&envelope).expect("envelope serializes to JSON");
627 let parsed: Value =
628 serde_json::from_str(&serialized).expect("serialized envelope is valid JSON");
629
630 assert!(
631 parsed.get("next_actions").is_none(),
632 "empty next_actions must not appear in JSON output"
633 );
634 }
635
636 #[test]
637 fn next_action_params_serialize_when_present() {
638 let action = NextAction::new("deploy run --app <app>", "Deploy the app").with_param(
639 "app",
640 NextActionParam {
641 description: Some("Application name".to_owned()),
642 required: true,
643 value: None,
644 r#enum: Vec::new(),
645 default: None,
646 },
647 );
648 let envelope = Envelope::success(json!(null), "deploy-api").with_next_actions(vec![action]);
649
650 let serialized = serde_json::to_string(&envelope).expect("envelope serializes to JSON");
651 let parsed: Value =
652 serde_json::from_str(&serialized).expect("serialized envelope is valid JSON");
653
654 assert_eq!(
655 parsed["next_actions"][0]["params"]["app"]["description"],
656 "Application name"
657 );
658 assert_eq!(parsed["next_actions"][0]["params"]["app"]["required"], true);
659 }
660
661 #[test]
662 fn fix_appears_in_serialized_error_envelope() {
663 let envelope = Envelope::error("AUTH_REQUIRED", "not logged in", "auth")
664 .with_fix("Run `auth login` and retry.");
665
666 let serialized = serde_json::to_string(&envelope).expect("envelope serializes to JSON");
667 let parsed: Value =
668 serde_json::from_str(&serialized).expect("serialized envelope is valid JSON");
669
670 assert_eq!(parsed["error"]["code"], "AUTH_REQUIRED");
671 assert_eq!(parsed["fix"], "Run `auth login` and retry.");
672 }
673
674 #[test]
675 fn fix_omitted_from_json_when_empty() {
676 let envelope = Envelope::error("ERROR", "boom", "domain");
677
678 let serialized = serde_json::to_string(&envelope).expect("envelope serializes to JSON");
679 let parsed: Value =
680 serde_json::from_str(&serialized).expect("serialized envelope is valid JSON");
681
682 assert!(
683 parsed.get("fix").is_none(),
684 "empty fix must not appear in JSON output"
685 );
686 }
687
688 #[test]
689 fn build_error_envelope_preserves_fix_wrapper() {
690 let err = crate::CliCoreError::with_fix(
691 "Run `auth login` and retry.",
692 crate::CliCoreError::message("not logged in"),
693 );
694 let envelope = build_error_envelope(&err, "auth");
695
696 assert_eq!(envelope.fix.as_deref(), Some("Run `auth login` and retry."));
697 assert_eq!(
698 envelope.error.as_ref().map(|e| e.message.as_str()),
699 Some("not logged in")
700 );
701 }
702
703 #[test]
704 fn build_error_envelope_surfaces_next_actions_from_a_detailed_error() {
705 use crate::error::DetailedError;
706
707 #[derive(Debug, thiserror::Error)]
708 #[error("'/businesses' matches 2 operations")]
709 struct Ambiguous;
710
711 impl DetailedError for Ambiguous {
712 fn error_code(&self) -> std::borrow::Cow<'static, str> {
713 std::borrow::Cow::Borrowed("AMBIGUOUS_MATCH")
714 }
715
716 fn error_system(&self) -> Option<std::borrow::Cow<'static, str>> {
717 None
718 }
719
720 fn error_request_id(&self) -> Option<std::borrow::Cow<'static, str>> {
721 None
722 }
723
724 fn error_next_actions(&self) -> Vec<NextAction> {
725 vec![
726 NextAction::new(
727 "api operation get /businesses --method GET",
728 "Get all businesses",
729 ),
730 NextAction::new(
731 "api operation get /businesses --method POST",
732 "Create a new business",
733 ),
734 ]
735 }
736 }
737
738 let err = crate::CliCoreError::with_detailed_error(Ambiguous);
742 let envelope = build_error_envelope(&err, "api");
743
744 assert_eq!(
745 envelope.error.as_ref().map(|e| e.code.as_str()),
746 Some("AMBIGUOUS_MATCH")
747 );
748 assert_eq!(envelope.next_actions.len(), 2);
749 assert_eq!(
750 envelope.next_actions[0].command,
751 "api operation get /businesses --method GET"
752 );
753 assert_eq!(
754 envelope.next_actions[1].command,
755 "api operation get /businesses --method POST"
756 );
757 }
758
759 #[test]
760 fn build_detailed_error_envelope_surfaces_next_actions() {
761 use crate::error::DetailedError;
762
763 #[derive(Debug, thiserror::Error)]
764 #[error("not found")]
765 struct NotFound;
766
767 impl DetailedError for NotFound {
768 fn error_code(&self) -> std::borrow::Cow<'static, str> {
769 std::borrow::Cow::Borrowed("NOT_FOUND")
770 }
771
772 fn error_system(&self) -> Option<std::borrow::Cow<'static, str>> {
773 None
774 }
775
776 fn error_request_id(&self) -> Option<std::borrow::Cow<'static, str>> {
777 None
778 }
779
780 fn error_next_actions(&self) -> Vec<NextAction> {
781 vec![NextAction::new("app list", "List applications")]
782 }
783 }
784
785 let err = NotFound;
786 let envelope = build_detailed_error_envelope(&err, "applications");
787
788 assert_eq!(envelope.next_actions.len(), 1);
789 assert_eq!(envelope.next_actions[0].command, "app list");
790 }
791
792 #[test]
793 fn success_envelope_ignores_with_fix() {
794 let envelope = Envelope::success(json!({"ok": true}), "api").with_fix("should not stick");
795
796 assert!(
797 envelope.fix.is_none(),
798 "fix is error-only and must not attach to success"
799 );
800 let serialized = serde_json::to_string(&envelope).expect("envelope serializes to JSON");
801 let parsed: Value =
802 serde_json::from_str(&serialized).expect("serialized envelope is valid JSON");
803 assert!(
804 parsed.get("fix").is_none(),
805 "fix must not appear on success"
806 );
807 }
808}