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")]
17 pub metadata: Option<Metadata>,
18 #[serde(skip_serializing_if = "Option::is_none")]
20 pub error: Option<ErrorEnvelope>,
21 #[serde(default, skip_serializing_if = "Vec::is_empty")]
23 pub warnings: Vec<String>,
24 #[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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
33pub struct NextAction {
34 pub command: String,
36 pub description: String,
38 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
40 pub params: HashMap<String, NextActionParam>,
41}
42
43impl NextAction {
44 #[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 #[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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Default)]
64pub struct NextActionParam {
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub value: Option<String>,
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub r#enum: Vec<String>,
71 #[serde(default, skip_serializing_if = "is_false")]
73 pub required: bool,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub default: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub description: Option<String>,
80}
81
82impl NextActionParam {
83 #[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 #[must_use]
94 pub fn required() -> Self {
95 Self {
96 required: true,
97 ..Self::default()
98 }
99 }
100}
101
102#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
104pub struct Metadata {
105 pub system: String,
107 pub timestamp: String,
109 #[serde(skip_serializing_if = "String::is_empty")]
111 pub request_id: String,
112 #[serde(skip_serializing_if = "is_false")]
114 pub dry_run: bool,
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub pagination: Option<PaginationMeta>,
118 #[serde(skip_serializing_if = "String::is_empty")]
120 pub command: String,
121 #[serde(skip_serializing_if = "String::is_empty")]
123 pub duration: String,
124 #[serde(skip_serializing_if = "String::is_empty")]
126 pub env: String,
127 #[serde(skip_serializing_if = "String::is_empty")]
129 pub identity: String,
130 #[serde(skip_serializing_if = "is_absent_null_or_empty_object")]
132 pub args: Option<Value>,
133 #[serde(skip_serializing_if = "is_absent_null_or_empty_object")]
135 pub effective_args: Option<Value>,
136}
137
138#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
140pub struct PaginationMeta {
141 pub total: i64,
143 pub offset: i64,
145 pub limit: i64,
147 pub count: i64,
149}
150
151#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
153pub struct ErrorEnvelope {
154 pub code: String,
156 pub message: String,
158 #[serde(skip_serializing_if = "String::is_empty")]
160 pub system: String,
161 #[serde(skip_serializing_if = "String::is_empty")]
163 pub request_id: String,
164}
165
166impl Envelope {
167 #[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 #[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 #[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 #[must_use]
237 pub fn with_next_actions(mut self, actions: Vec<NextAction>) -> Self {
238 self.next_actions = actions;
239 self
240 }
241
242 #[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 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 #[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 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 #[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#[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#[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}