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    /// Optional execution metadata, controlled by `--verbose`.
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub metadata: Option<Metadata>,
18    /// Structured error information for failed commands.
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub error: Option<ErrorEnvelope>,
21    /// Non-fatal warnings.
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub warnings: Vec<String>,
24    /// Suggested follow-up actions for the caller (agent or human).
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub next_actions: Vec<NextAction>,
27    #[serde(default, skip)]
28    serialization_error: Option<String>,
29}
30
31/// A suggested follow-up command the caller can run next.
32#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
33pub struct NextAction {
34    /// Executable command template, e.g. `"application info --name {{name}}"`.
35    pub command: String,
36    /// Human-readable description of what this action does.
37    pub description: String,
38    /// Optional parameter hints for agent-driven invocation.
39    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
40    pub params: HashMap<String, NextActionParam>,
41}
42
43impl NextAction {
44    /// Creates a next action with a command template and description.
45    #[must_use]
46    pub fn new(command: impl Into<String>, description: impl Into<String>) -> Self {
47        Self {
48            command: command.into(),
49            description: description.into(),
50            params: HashMap::new(),
51        }
52    }
53
54    /// Adds a parameter hint.
55    #[must_use]
56    pub fn with_param(mut self, name: impl Into<String>, param: NextActionParam) -> Self {
57        self.params.insert(name.into(), param);
58        self
59    }
60}
61
62/// Metadata hint for a parameter in a [`NextAction`] command template.
63#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Default)]
64pub struct NextActionParam {
65    /// Concrete value to substitute, if known.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub value: Option<String>,
68    /// Allowed values for enumeration parameters.
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub r#enum: Vec<String>,
71    /// Whether the parameter is required.
72    #[serde(default, skip_serializing_if = "is_false")]
73    pub required: bool,
74    /// Default value when none is supplied.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub default: Option<String>,
77    /// Human-readable description of this parameter.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub description: Option<String>,
80}
81
82impl NextActionParam {
83    /// Creates a parameter hint with a known concrete value.
84    #[must_use]
85    pub fn value(value: impl Into<String>) -> Self {
86        Self {
87            value: Some(value.into()),
88            ..Self::default()
89        }
90    }
91
92    /// Creates a required parameter hint.
93    #[must_use]
94    pub fn required() -> Self {
95        Self {
96            required: true,
97            ..Self::default()
98        }
99    }
100}
101
102/// Execution metadata attached to an [`Envelope`].
103#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
104pub struct Metadata {
105    /// Backend/system id.
106    pub system: String,
107    /// UTC timestamp in RFC3339 seconds format.
108    pub timestamp: String,
109    /// Optional backend request id.
110    #[serde(skip_serializing_if = "String::is_empty")]
111    pub request_id: String,
112    /// Whether the command was a dry-run response.
113    #[serde(skip_serializing_if = "is_false")]
114    pub dry_run: bool,
115    /// Pagination metadata when client-side pagination ran.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub pagination: Option<PaginationMeta>,
118    /// Colon-separated command path.
119    #[serde(skip_serializing_if = "String::is_empty")]
120    pub command: String,
121    /// Rounded command duration.
122    #[serde(skip_serializing_if = "String::is_empty")]
123    pub duration: String,
124    /// Selected environment.
125    #[serde(skip_serializing_if = "String::is_empty")]
126    pub env: String,
127    /// Authenticated identity.
128    #[serde(skip_serializing_if = "String::is_empty")]
129    pub identity: String,
130    /// User-supplied args.
131    #[serde(skip_serializing_if = "is_absent_null_or_empty_object")]
132    pub args: Option<Value>,
133    /// Effective args after defaults and middleware injection.
134    #[serde(skip_serializing_if = "is_absent_null_or_empty_object")]
135    pub effective_args: Option<Value>,
136}
137
138/// Client-side pagination metadata.
139#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
140pub struct PaginationMeta {
141    /// Total list items before pagination.
142    pub total: i64,
143    /// Applied offset.
144    pub offset: i64,
145    /// Applied limit.
146    pub limit: i64,
147    /// Item count after pagination.
148    pub count: i64,
149}
150
151/// Structured error payload in an [`Envelope`].
152#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
153pub struct ErrorEnvelope {
154    /// Stable error code.
155    pub code: String,
156    /// Human-readable error message.
157    pub message: String,
158    /// Optional backend/system id.
159    #[serde(skip_serializing_if = "String::is_empty")]
160    pub system: String,
161    /// Optional backend request id.
162    #[serde(skip_serializing_if = "String::is_empty")]
163    pub request_id: String,
164}
165
166impl Envelope {
167    /// Creates a success envelope from serializable data.
168    #[must_use]
169    pub fn success(data: impl Serialize, system: impl Into<String>) -> Self {
170        let (data, serialization_error) = match serde_json::to_value(data) {
171            Ok(data) => (Some(data), None),
172            Err(err) => (None, Some(err.to_string())),
173        };
174        Self {
175            data,
176            metadata: Some(Metadata::new(system)),
177            error: None,
178            warnings: Vec::new(),
179            next_actions: Vec::new(),
180            serialization_error,
181        }
182    }
183
184    /// Creates a generic error envelope.
185    #[must_use]
186    pub fn error(
187        code: impl Into<String>,
188        message: impl Into<String>,
189        system: impl Into<String>,
190    ) -> Self {
191        let system = system.into();
192        Self {
193            data: None,
194            metadata: Some(Metadata::new(system.clone())),
195            error: Some(ErrorEnvelope {
196                code: code.into(),
197                message: message.into(),
198                system,
199                request_id: String::new(),
200            }),
201            warnings: Vec::new(),
202            next_actions: Vec::new(),
203            serialization_error: None,
204        }
205    }
206
207    /// Creates a structured error envelope with request id.
208    #[must_use]
209    pub fn error_detail(
210        code: impl Into<String>,
211        message: impl Into<String>,
212        system: impl Into<String>,
213        request_id: impl Into<String>,
214    ) -> Self {
215        let system = system.into();
216        let request_id = request_id.into();
217        Self {
218            data: None,
219            metadata: Some(Metadata {
220                request_id: request_id.clone(),
221                ..Metadata::new(system.clone())
222            }),
223            error: Some(ErrorEnvelope {
224                code: code.into(),
225                message: message.into(),
226                system,
227                request_id,
228            }),
229            warnings: Vec::new(),
230            next_actions: Vec::new(),
231            serialization_error: None,
232        }
233    }
234
235    /// Attaches suggested follow-up actions.
236    #[must_use]
237    pub fn with_next_actions(mut self, actions: Vec<NextAction>) -> Self {
238        self.next_actions = actions;
239        self
240    }
241
242    /// Marks the envelope as a dry-run response.
243    #[must_use]
244    pub fn with_dry_run(mut self) -> Self {
245        if let Some(metadata) = &mut self.metadata {
246            metadata.dry_run = true;
247        }
248        self
249    }
250
251    /// Adds command execution context to envelope metadata.
252    pub fn with_context(
253        &mut self,
254        command: &str,
255        env: &str,
256        identity: &str,
257        duration: Duration,
258        user_args: Option<Value>,
259        effective_args: Option<Value>,
260    ) {
261        if let Some(metadata) = &mut self.metadata {
262            metadata.command = command.to_owned();
263            metadata.env = env.to_owned();
264            metadata.identity = identity.to_owned();
265            metadata.duration = format_duration(duration);
266            metadata.args = user_args;
267            metadata.effective_args = effective_args;
268        }
269    }
270
271    /// Returns a copy with metadata stripped or filtered according to `--verbose`.
272    #[must_use]
273    pub fn prepare_for_render(&self, verbose: &str) -> Self {
274        let mut copy = self.clone();
275        if verbose.is_empty() {
276            copy.metadata = None;
277            return copy;
278        }
279        if verbose == "all" {
280            return copy;
281        }
282        if let Some(metadata) = &self.metadata {
283            copy.metadata = Some(metadata.filter_fields(verbose));
284        }
285        copy
286    }
287
288    /// Appends a non-fatal warning.
289    pub fn add_warning(&mut self, message: impl Into<String>) {
290        self.warnings.push(message.into());
291    }
292
293    pub(crate) fn serialization_result(&self) -> crate::Result<()> {
294        if let Some(error) = &self.serialization_error {
295            return Err(crate::CliCoreError::message(error.clone()));
296        }
297        Ok(())
298    }
299}
300
301impl Metadata {
302    /// Creates metadata with system and timestamp.
303    #[must_use]
304    pub fn new(system: impl Into<String>) -> Self {
305        Self {
306            system: system.into(),
307            timestamp: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
308            request_id: String::new(),
309            dry_run: false,
310            pagination: None,
311            command: String::new(),
312            duration: String::new(),
313            env: String::new(),
314            identity: String::new(),
315            args: None,
316            effective_args: None,
317        }
318    }
319
320    fn filter_fields(&self, verbose: &str) -> Self {
321        let wanted = verbose
322            .split(',')
323            .map(str::trim)
324            .filter(|field| !field.is_empty())
325            .collect::<Vec<_>>();
326        Self {
327            system: keep_string(&wanted, "system", &self.system),
328            timestamp: keep_string(&wanted, "timestamp", &self.timestamp),
329            request_id: keep_string(&wanted, "request_id", &self.request_id),
330            dry_run: wanted.contains(&"dry_run") && self.dry_run,
331            pagination: wanted
332                .contains(&"pagination")
333                .then(|| self.pagination.clone())
334                .flatten(),
335            command: keep_string(&wanted, "command", &self.command),
336            duration: keep_string(&wanted, "duration", &self.duration),
337            env: keep_string(&wanted, "env", &self.env),
338            identity: keep_string(&wanted, "identity", &self.identity),
339            args: wanted
340                .contains(&"args")
341                .then(|| self.args.clone())
342                .flatten(),
343            effective_args: wanted
344                .contains(&"effective_args")
345                .then(|| self.effective_args.clone())
346                .flatten(),
347        }
348    }
349}
350
351/// Builds an error envelope, preserving structured details from known error types.
352#[must_use]
353pub fn build_error_envelope(err: &(dyn std::error::Error + 'static), system: &str) -> Envelope {
354    if let Some((code, mut sys, request_id)) = find_detailed_error(err) {
355        if sys.is_empty() {
356            sys = system.to_owned();
357        }
358        return Envelope {
359            data: None,
360            metadata: Some(Metadata {
361                request_id: request_id.clone(),
362                ..Metadata::new(sys.clone())
363            }),
364            error: Some(ErrorEnvelope {
365                code: if code.is_empty() {
366                    "ERROR".to_owned()
367                } else {
368                    code
369                },
370                message: err.to_string(),
371                system: sys,
372                request_id,
373            }),
374            warnings: Vec::new(),
375            next_actions: Vec::new(),
376            serialization_error: None,
377        };
378    }
379    Envelope::error("ERROR", err.to_string(), system)
380}
381
382fn find_detailed_error(
383    err: &(dyn std::error::Error + 'static),
384) -> Option<(String, String, String)> {
385    let mut current = Some(err);
386    let mut fallback_system = None::<String>;
387    while let Some(error) = current {
388        if let Some(crate::CliCoreError::SystemMessage {
389            system,
390            code,
391            request_id,
392            ..
393        }) = error.downcast_ref::<crate::CliCoreError>()
394        {
395            return Some((code.clone(), system.clone(), request_id.clone()));
396        }
397        if let Some(crate::CliCoreError::System { system, .. }) =
398            error.downcast_ref::<crate::CliCoreError>()
399            && !system.is_empty()
400            && fallback_system.is_none()
401        {
402            fallback_system = Some(system.clone());
403        }
404        if let Some(crate::CliCoreError::Detailed {
405            code,
406            system,
407            request_id,
408            ..
409        }) = error.downcast_ref::<crate::CliCoreError>()
410        {
411            return Some((
412                code.clone(),
413                fallback_system
414                    .clone()
415                    .filter(|_| system.is_empty())
416                    .unwrap_or_else(|| system.clone()),
417                request_id.clone(),
418            ));
419        }
420        let detailed_transport = error.downcast_ref::<crate::transport::Error>().or_else(|| {
421            match error.downcast_ref::<crate::CliCoreError>() {
422                Some(crate::CliCoreError::Transport(transport)) => Some(transport),
423                Some(
424                    crate::CliCoreError::MissingAuthProvider(_)
425                    | crate::CliCoreError::AuthProvider { .. }
426                    | crate::CliCoreError::InvalidOutputFormat(_)
427                    | crate::CliCoreError::Message(_)
428                    | crate::CliCoreError::SystemMessage { .. }
429                    | crate::CliCoreError::System { .. }
430                    | crate::CliCoreError::Detailed { .. }
431                    | crate::CliCoreError::ExitCode { .. }
432                    | crate::CliCoreError::Io(_)
433                    | crate::CliCoreError::Json(_),
434                )
435                | None => None,
436            }
437        });
438        if let Some(detailed) = detailed_transport {
439            let system = detailed
440                .error_system()
441                .map_or_else(String::new, std::borrow::Cow::into_owned);
442            return Some((
443                detailed.error_code().into_owned(),
444                fallback_system
445                    .clone()
446                    .filter(|_| system.is_empty())
447                    .unwrap_or(system),
448                detailed
449                    .error_request_id()
450                    .map_or_else(String::new, std::borrow::Cow::into_owned),
451            ));
452        }
453        current = error.source();
454    }
455    fallback_system.map(|system| ("ERROR".to_owned(), system, String::new()))
456}
457
458/// Builds an error envelope from a [`DetailedError`].
459#[must_use]
460pub fn build_detailed_error_envelope(err: &dyn DetailedError, system: &str) -> Envelope {
461    let code = err.error_code().into_owned();
462    let sys = err
463        .error_system()
464        .map_or_else(|| system.to_owned(), std::borrow::Cow::into_owned);
465    let request_id = err
466        .error_request_id()
467        .map_or_else(String::new, std::borrow::Cow::into_owned);
468    Envelope {
469        data: None,
470        metadata: Some(Metadata {
471            request_id: request_id.clone(),
472            ..Metadata::new(sys.clone())
473        }),
474        error: Some(ErrorEnvelope {
475            code: if code.is_empty() {
476                "ERROR".to_owned()
477            } else {
478                code
479            },
480            message: err.to_string(),
481            system: sys,
482            request_id,
483        }),
484        warnings: Vec::new(),
485        next_actions: Vec::new(),
486        serialization_error: None,
487    }
488}
489
490fn keep_string(wanted: &[&str], field: &str, value: &str) -> String {
491    if wanted.contains(&field) {
492        value.to_owned()
493    } else {
494        String::new()
495    }
496}
497
498fn format_duration(duration: Duration) -> String {
499    let nanos = duration.as_nanos();
500    let millis = (nanos + 500_000) / 1_000_000;
501    if millis == 0 {
502        return "0s".to_owned();
503    }
504    if millis >= 1000 {
505        let secs = millis / 1000;
506        let rem = millis % 1000;
507        if rem == 0 {
508            format!("{secs}s")
509        } else {
510            let mut fraction = format!("{rem:03}");
511            while fraction.ends_with('0') {
512                fraction.pop();
513            }
514            format!("{secs}.{fraction}s")
515        }
516    } else {
517        format!("{millis}ms")
518    }
519}
520
521const fn is_false(value: &bool) -> bool {
522    !*value
523}
524
525fn is_absent_or_null(value: &Option<Value>) -> bool {
526    value.as_ref().is_none_or(Value::is_null)
527}
528
529fn is_absent_null_or_empty_object(value: &Option<Value>) -> bool {
530    match value {
531        None | Some(Value::Null) => true,
532        Some(Value::Object(map)) => map.is_empty(),
533        Some(Value::Array(_) | Value::Bool(_) | Value::Number(_) | Value::String(_)) => false,
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use serde_json::json;
540
541    use super::*;
542
543    #[test]
544    fn next_actions_appear_in_serialized_envelope() {
545        let envelope =
546            Envelope::success(json!({"id": "p1"}), "projects-api").with_next_actions(vec![
547                NextAction::new("project get --id {{id}}", "Get project details"),
548            ]);
549
550        let serialized = serde_json::to_string(&envelope).expect("envelope serializes to JSON");
551        let parsed: Value =
552            serde_json::from_str(&serialized).expect("serialized envelope is valid JSON");
553
554        assert_eq!(
555            parsed["next_actions"][0]["command"],
556            "project get --id {{id}}"
557        );
558        assert_eq!(
559            parsed["next_actions"][0]["description"],
560            "Get project details"
561        );
562    }
563
564    #[test]
565    fn next_actions_omitted_from_json_when_empty() {
566        let envelope = Envelope::success(json!({"id": "p1"}), "projects-api");
567
568        let serialized = serde_json::to_string(&envelope).expect("envelope serializes to JSON");
569        let parsed: Value =
570            serde_json::from_str(&serialized).expect("serialized envelope is valid JSON");
571
572        assert!(
573            parsed.get("next_actions").is_none(),
574            "empty next_actions must not appear in JSON output"
575        );
576    }
577
578    #[test]
579    fn next_action_params_serialize_when_present() {
580        let action = NextAction::new("deploy run --app {{app}}", "Deploy the app").with_param(
581            "app",
582            NextActionParam {
583                description: Some("Application name".to_owned()),
584                required: true,
585                value: None,
586                r#enum: Vec::new(),
587                default: None,
588            },
589        );
590        let envelope = Envelope::success(json!(null), "deploy-api").with_next_actions(vec![action]);
591
592        let serialized = serde_json::to_string(&envelope).expect("envelope serializes to JSON");
593        let parsed: Value =
594            serde_json::from_str(&serialized).expect("serialized envelope is valid JSON");
595
596        assert_eq!(
597            parsed["next_actions"][0]["params"]["app"]["description"],
598            "Application name"
599        );
600        assert_eq!(parsed["next_actions"][0]["params"]["app"]["required"], true);
601    }
602}