Skip to main content

cli_engine/output/
envelope.rs

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/// Top-level output envelope rendered for successful and failed commands.
10#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
11pub struct Envelope {
12    /// Successful command data.
13    #[serde(skip_serializing_if = "is_absent_or_null")]
14    pub data: Option<Value>,
15    /// Client-side pagination facts, present whenever pagination was
16    /// actually applied — an effective `--limit` or `--offset` greater than
17    /// zero, not merely a command that opted into
18    /// [`with_pagination`](crate::CommandSpec::with_pagination) — regardless
19    /// of `--verbose`. A `default_limit` of `0` ("unlimited") with neither
20    /// flag passed leaves this `None` even for a paginating command. Unlike
21    /// [`metadata`](Envelope::metadata), this is not debugging output; a
22    /// caller relies on it to know whether more data exists at all.
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub pagination: Option<PaginationMeta>,
25    /// Optional execution metadata, controlled by `--verbose`.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub metadata: Option<Metadata>,
28    /// Structured error information for failed commands.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub error: Option<ErrorEnvelope>,
31    /// Non-fatal warnings.
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub warnings: Vec<String>,
34    /// Suggested follow-up actions for the caller (agent or human).
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub next_actions: Vec<NextAction>,
37    /// Optional recovery guidance for failed commands.
38    #[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/// A suggested follow-up command the caller can run next.
45///
46/// Construct with [`NextAction::new`], then chain `with_*` methods — never as
47/// a struct literal. `#[non_exhaustive]` enforces this so the engine can add
48/// fields without a breaking release.
49#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
50#[non_exhaustive]
51pub struct NextAction {
52    /// Executable command template, e.g. `"application info --name <name>"`.
53    /// A param's placeholder is its key wrapped in angle brackets (`<key>`);
54    /// human output substitutes it when the param carries a known
55    /// [`NextActionParam::value`].
56    pub command: String,
57    /// Human-readable description of what this action does.
58    pub description: String,
59    /// Optional parameter hints for agent-driven invocation.
60    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
61    pub params: HashMap<String, NextActionParam>,
62}
63
64impl NextAction {
65    /// Creates a next action with a command template and description.
66    #[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    /// Adds a parameter hint.
76    #[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/// Metadata hint for a parameter in a [`NextAction`] command template.
84#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Default)]
85pub struct NextActionParam {
86    /// Concrete value to substitute, if known.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub value: Option<String>,
89    /// Allowed values for enumeration parameters.
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub r#enum: Vec<String>,
92    /// Whether the parameter is required.
93    #[serde(default, skip_serializing_if = "is_false")]
94    pub required: bool,
95    /// Default value when none is supplied.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub default: Option<String>,
98    /// Human-readable description of this parameter.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub description: Option<String>,
101}
102
103impl NextActionParam {
104    /// Creates a parameter hint with a known concrete value.
105    #[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    /// Creates a required parameter hint.
114    #[must_use]
115    pub fn required() -> Self {
116        Self {
117            required: true,
118            ..Self::default()
119        }
120    }
121}
122
123/// Execution metadata attached to an [`Envelope`].
124#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
125pub struct Metadata {
126    /// Backend/system id.
127    pub system: String,
128    /// UTC timestamp in RFC3339 seconds format.
129    pub timestamp: String,
130    /// Optional backend request id.
131    #[serde(skip_serializing_if = "String::is_empty")]
132    pub request_id: String,
133    /// Whether the command was a dry-run response.
134    #[serde(skip_serializing_if = "is_false")]
135    pub dry_run: bool,
136    /// Colon-separated command path.
137    #[serde(skip_serializing_if = "String::is_empty")]
138    pub command: String,
139    /// Rounded command duration.
140    #[serde(skip_serializing_if = "String::is_empty")]
141    pub duration: String,
142    /// Selected environment.
143    #[serde(skip_serializing_if = "String::is_empty")]
144    pub env: String,
145    /// Authenticated identity.
146    #[serde(skip_serializing_if = "String::is_empty")]
147    pub identity: String,
148    /// User-supplied args.
149    #[serde(skip_serializing_if = "is_absent_null_or_empty_object")]
150    pub args: Option<Value>,
151    /// Effective args after defaults and middleware injection.
152    #[serde(skip_serializing_if = "is_absent_null_or_empty_object")]
153    pub effective_args: Option<Value>,
154}
155
156/// Client-side pagination metadata.
157#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
158pub struct PaginationMeta {
159    /// Total list items before pagination.
160    pub total: i64,
161    /// Applied offset.
162    pub offset: i64,
163    /// Applied limit.
164    pub limit: i64,
165    /// Item count after pagination.
166    pub count: i64,
167    /// Whether items beyond this page remain (`offset + count < total`).
168    pub has_more: bool,
169}
170
171/// Structured error payload in an [`Envelope`].
172#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
173pub struct ErrorEnvelope {
174    /// Stable error code.
175    pub code: String,
176    /// Human-readable error message.
177    pub message: String,
178    /// Optional backend/system id.
179    #[serde(skip_serializing_if = "String::is_empty")]
180    pub system: String,
181    /// Optional backend request id.
182    #[serde(skip_serializing_if = "String::is_empty")]
183    pub request_id: String,
184}
185
186impl Envelope {
187    /// Creates a success envelope from serializable data.
188    #[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    /// Creates a generic error envelope.
207    #[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    /// Creates a structured error envelope with request id.
232    #[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    /// Attaches suggested follow-up actions.
262    #[must_use]
263    pub fn with_next_actions(mut self, actions: Vec<NextAction>) -> Self {
264        self.next_actions = actions;
265        self
266    }
267
268    /// Attaches recovery guidance for a failed command (no-op on success envelopes).
269    #[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    /// Marks the envelope as a dry-run response.
279    #[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    /// Adds command execution context to envelope metadata.
288    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    /// Returns a copy with metadata stripped or filtered according to `--verbose`.
308    #[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    /// Appends a non-fatal warning.
325    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    /// Creates metadata with system and timestamp.
339    #[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/// Builds an error envelope, preserving structured details from known error types.
383#[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/// Builds an error envelope from a [`DetailedError`].
512#[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        // Mirrors the real path: a handler converts its `DetailedError` into a
739        // `CliCoreError` (type-erasing it), then the middleware renders that
740        // through `build_error_envelope` — never `build_detailed_error_envelope`.
741        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}