Skip to main content

cli_engine/output/
envelope.rs

1use std::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    #[serde(default, skip)]
25    serialization_error: Option<String>,
26}
27
28/// Execution metadata attached to an [`Envelope`].
29#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
30pub struct Metadata {
31    /// Backend/system id.
32    pub system: String,
33    /// UTC timestamp in RFC3339 seconds format.
34    pub timestamp: String,
35    /// Optional backend request id.
36    #[serde(skip_serializing_if = "String::is_empty")]
37    pub request_id: String,
38    /// Whether the command was a dry-run response.
39    #[serde(skip_serializing_if = "is_false")]
40    pub dry_run: bool,
41    /// Pagination metadata when client-side pagination ran.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub pagination: Option<PaginationMeta>,
44    /// Colon-separated command path.
45    #[serde(skip_serializing_if = "String::is_empty")]
46    pub command: String,
47    /// Rounded command duration.
48    #[serde(skip_serializing_if = "String::is_empty")]
49    pub duration: String,
50    /// Selected environment.
51    #[serde(skip_serializing_if = "String::is_empty")]
52    pub env: String,
53    /// Authenticated identity.
54    #[serde(skip_serializing_if = "String::is_empty")]
55    pub identity: String,
56    /// User-supplied args.
57    #[serde(skip_serializing_if = "is_absent_null_or_empty_object")]
58    pub args: Option<Value>,
59    /// Effective args after defaults and middleware injection.
60    #[serde(skip_serializing_if = "is_absent_null_or_empty_object")]
61    pub effective_args: Option<Value>,
62}
63
64/// Client-side pagination metadata.
65#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
66pub struct PaginationMeta {
67    /// Total list items before pagination.
68    pub total: i64,
69    /// Applied offset.
70    pub offset: i64,
71    /// Applied limit.
72    pub limit: i64,
73    /// Item count after pagination.
74    pub count: i64,
75}
76
77/// Structured error payload in an [`Envelope`].
78#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
79pub struct ErrorEnvelope {
80    /// Stable error code.
81    pub code: String,
82    /// Human-readable error message.
83    pub message: String,
84    /// Optional backend/system id.
85    #[serde(skip_serializing_if = "String::is_empty")]
86    pub system: String,
87    /// Optional backend request id.
88    #[serde(skip_serializing_if = "String::is_empty")]
89    pub request_id: String,
90}
91
92impl Envelope {
93    /// Creates a success envelope from serializable data.
94    #[must_use]
95    pub fn success(data: impl Serialize, system: impl Into<String>) -> Self {
96        let (data, serialization_error) = match serde_json::to_value(data) {
97            Ok(data) => (Some(data), None),
98            Err(err) => (None, Some(err.to_string())),
99        };
100        Self {
101            data,
102            metadata: Some(Metadata::new(system)),
103            error: None,
104            warnings: Vec::new(),
105            serialization_error,
106        }
107    }
108
109    /// Creates a generic error envelope.
110    #[must_use]
111    pub fn error(
112        code: impl Into<String>,
113        message: impl Into<String>,
114        system: impl Into<String>,
115    ) -> Self {
116        let system = system.into();
117        Self {
118            data: None,
119            metadata: Some(Metadata::new(system.clone())),
120            error: Some(ErrorEnvelope {
121                code: code.into(),
122                message: message.into(),
123                system,
124                request_id: String::new(),
125            }),
126            warnings: Vec::new(),
127            serialization_error: None,
128        }
129    }
130
131    /// Creates a structured error envelope with request id.
132    #[must_use]
133    pub fn error_detail(
134        code: impl Into<String>,
135        message: impl Into<String>,
136        system: impl Into<String>,
137        request_id: impl Into<String>,
138    ) -> Self {
139        let system = system.into();
140        let request_id = request_id.into();
141        Self {
142            data: None,
143            metadata: Some(Metadata {
144                request_id: request_id.clone(),
145                ..Metadata::new(system.clone())
146            }),
147            error: Some(ErrorEnvelope {
148                code: code.into(),
149                message: message.into(),
150                system,
151                request_id,
152            }),
153            warnings: Vec::new(),
154            serialization_error: None,
155        }
156    }
157
158    /// Marks the envelope as a dry-run response.
159    #[must_use]
160    pub fn with_dry_run(mut self) -> Self {
161        if let Some(metadata) = &mut self.metadata {
162            metadata.dry_run = true;
163        }
164        self
165    }
166
167    /// Adds command execution context to envelope metadata.
168    pub fn with_context(
169        &mut self,
170        command: &str,
171        env: &str,
172        identity: &str,
173        duration: Duration,
174        user_args: Option<Value>,
175        effective_args: Option<Value>,
176    ) {
177        if let Some(metadata) = &mut self.metadata {
178            metadata.command = command.to_owned();
179            metadata.env = env.to_owned();
180            metadata.identity = identity.to_owned();
181            metadata.duration = format_duration(duration);
182            metadata.args = user_args;
183            metadata.effective_args = effective_args;
184        }
185    }
186
187    /// Returns a copy with metadata stripped or filtered according to `--verbose`.
188    #[must_use]
189    pub fn prepare_for_render(&self, verbose: &str) -> Self {
190        let mut copy = self.clone();
191        if verbose.is_empty() {
192            copy.metadata = None;
193            return copy;
194        }
195        if verbose == "all" {
196            return copy;
197        }
198        if let Some(metadata) = &self.metadata {
199            copy.metadata = Some(metadata.filter_fields(verbose));
200        }
201        copy
202    }
203
204    /// Appends a non-fatal warning.
205    pub fn add_warning(&mut self, message: impl Into<String>) {
206        self.warnings.push(message.into());
207    }
208
209    pub(crate) fn serialization_result(&self) -> crate::Result<()> {
210        if let Some(error) = &self.serialization_error {
211            return Err(crate::CliCoreError::message(error.clone()));
212        }
213        Ok(())
214    }
215}
216
217impl Metadata {
218    /// Creates metadata with system and timestamp.
219    #[must_use]
220    pub fn new(system: impl Into<String>) -> Self {
221        Self {
222            system: system.into(),
223            timestamp: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
224            request_id: String::new(),
225            dry_run: false,
226            pagination: None,
227            command: String::new(),
228            duration: String::new(),
229            env: String::new(),
230            identity: String::new(),
231            args: None,
232            effective_args: None,
233        }
234    }
235
236    fn filter_fields(&self, verbose: &str) -> Self {
237        let wanted = verbose
238            .split(',')
239            .map(str::trim)
240            .filter(|field| !field.is_empty())
241            .collect::<Vec<_>>();
242        Self {
243            system: keep_string(&wanted, "system", &self.system),
244            timestamp: keep_string(&wanted, "timestamp", &self.timestamp),
245            request_id: keep_string(&wanted, "request_id", &self.request_id),
246            dry_run: wanted.contains(&"dry_run") && self.dry_run,
247            pagination: wanted
248                .contains(&"pagination")
249                .then(|| self.pagination.clone())
250                .flatten(),
251            command: keep_string(&wanted, "command", &self.command),
252            duration: keep_string(&wanted, "duration", &self.duration),
253            env: keep_string(&wanted, "env", &self.env),
254            identity: keep_string(&wanted, "identity", &self.identity),
255            args: wanted
256                .contains(&"args")
257                .then(|| self.args.clone())
258                .flatten(),
259            effective_args: wanted
260                .contains(&"effective_args")
261                .then(|| self.effective_args.clone())
262                .flatten(),
263        }
264    }
265}
266
267/// Builds an error envelope, preserving structured details from known error types.
268#[must_use]
269pub fn build_error_envelope(err: &(dyn std::error::Error + 'static), system: &str) -> Envelope {
270    if let Some((code, mut sys, request_id)) = find_detailed_error(err) {
271        if sys.is_empty() {
272            sys = system.to_owned();
273        }
274        return Envelope {
275            data: None,
276            metadata: Some(Metadata {
277                request_id: request_id.clone(),
278                ..Metadata::new(sys.clone())
279            }),
280            error: Some(ErrorEnvelope {
281                code: if code.is_empty() {
282                    "ERROR".to_owned()
283                } else {
284                    code
285                },
286                message: err.to_string(),
287                system: sys,
288                request_id,
289            }),
290            warnings: Vec::new(),
291            serialization_error: None,
292        };
293    }
294    Envelope::error("ERROR", err.to_string(), system)
295}
296
297fn find_detailed_error(
298    err: &(dyn std::error::Error + 'static),
299) -> Option<(String, String, String)> {
300    let mut current = Some(err);
301    let mut fallback_system = None::<String>;
302    while let Some(error) = current {
303        if let Some(crate::CliCoreError::SystemMessage {
304            system,
305            code,
306            request_id,
307            ..
308        }) = error.downcast_ref::<crate::CliCoreError>()
309        {
310            return Some((code.clone(), system.clone(), request_id.clone()));
311        }
312        if let Some(crate::CliCoreError::System { system, .. }) =
313            error.downcast_ref::<crate::CliCoreError>()
314            && !system.is_empty()
315            && fallback_system.is_none()
316        {
317            fallback_system = Some(system.clone());
318        }
319        if let Some(crate::CliCoreError::Detailed {
320            code,
321            system,
322            request_id,
323            ..
324        }) = error.downcast_ref::<crate::CliCoreError>()
325        {
326            return Some((
327                code.clone(),
328                fallback_system
329                    .clone()
330                    .filter(|_| system.is_empty())
331                    .unwrap_or_else(|| system.clone()),
332                request_id.clone(),
333            ));
334        }
335        let detailed_transport = error.downcast_ref::<crate::transport::Error>().or_else(|| {
336            match error.downcast_ref::<crate::CliCoreError>() {
337                Some(crate::CliCoreError::Transport(transport)) => Some(transport),
338                Some(
339                    crate::CliCoreError::MissingAuthProvider(_)
340                    | crate::CliCoreError::AuthProvider { .. }
341                    | crate::CliCoreError::InvalidOutputFormat(_)
342                    | crate::CliCoreError::Message(_)
343                    | crate::CliCoreError::SystemMessage { .. }
344                    | crate::CliCoreError::System { .. }
345                    | crate::CliCoreError::Detailed { .. }
346                    | crate::CliCoreError::ExitCode { .. }
347                    | crate::CliCoreError::Io(_)
348                    | crate::CliCoreError::Json(_),
349                )
350                | None => None,
351            }
352        });
353        if let Some(detailed) = detailed_transport {
354            let system = detailed
355                .error_system()
356                .map_or_else(String::new, std::borrow::Cow::into_owned);
357            return Some((
358                detailed.error_code().into_owned(),
359                fallback_system
360                    .clone()
361                    .filter(|_| system.is_empty())
362                    .unwrap_or(system),
363                detailed
364                    .error_request_id()
365                    .map_or_else(String::new, std::borrow::Cow::into_owned),
366            ));
367        }
368        current = error.source();
369    }
370    fallback_system.map(|system| ("ERROR".to_owned(), system, String::new()))
371}
372
373/// Builds an error envelope from a [`DetailedError`].
374#[must_use]
375pub fn build_detailed_error_envelope(err: &dyn DetailedError, system: &str) -> Envelope {
376    let code = err.error_code().into_owned();
377    let sys = err
378        .error_system()
379        .map_or_else(|| system.to_owned(), std::borrow::Cow::into_owned);
380    let request_id = err
381        .error_request_id()
382        .map_or_else(String::new, std::borrow::Cow::into_owned);
383    Envelope {
384        data: None,
385        metadata: Some(Metadata {
386            request_id: request_id.clone(),
387            ..Metadata::new(sys.clone())
388        }),
389        error: Some(ErrorEnvelope {
390            code: if code.is_empty() {
391                "ERROR".to_owned()
392            } else {
393                code
394            },
395            message: err.to_string(),
396            system: sys,
397            request_id,
398        }),
399        warnings: Vec::new(),
400        serialization_error: None,
401    }
402}
403
404fn keep_string(wanted: &[&str], field: &str, value: &str) -> String {
405    if wanted.contains(&field) {
406        value.to_owned()
407    } else {
408        String::new()
409    }
410}
411
412fn format_duration(duration: Duration) -> String {
413    let nanos = duration.as_nanos();
414    let millis = (nanos + 500_000) / 1_000_000;
415    if millis == 0 {
416        return "0s".to_owned();
417    }
418    if millis >= 1000 {
419        let secs = millis / 1000;
420        let rem = millis % 1000;
421        if rem == 0 {
422            format!("{secs}s")
423        } else {
424            let mut fraction = format!("{rem:03}");
425            while fraction.ends_with('0') {
426                fraction.pop();
427            }
428            format!("{secs}.{fraction}s")
429        }
430    } else {
431        format!("{millis}ms")
432    }
433}
434
435const fn is_false(value: &bool) -> bool {
436    !*value
437}
438
439fn is_absent_or_null(value: &Option<Value>) -> bool {
440    value.as_ref().is_none_or(Value::is_null)
441}
442
443fn is_absent_null_or_empty_object(value: &Option<Value>) -> bool {
444    match value {
445        None | Some(Value::Null) => true,
446        Some(Value::Object(map)) => map.is_empty(),
447        Some(Value::Array(_) | Value::Bool(_) | Value::Number(_) | Value::String(_)) => false,
448    }
449}