1use crate::CliExitCode;
18use crate::DiagnosticView;
19use crate::Error;
20use crate::ErrorContext;
21use crate::ErrorDescriptor;
22use crate::PublicErrorView;
23use crate::SharedError;
24use crate::ViewValueRef;
25
26#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
28pub enum CliVerbosity {
29 #[default]
31 Default,
32 Verbose,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct CliOutput {
39 exit_code: CliExitCode,
40 stderr: String,
41}
42
43impl CliOutput {
44 #[inline]
46 pub const fn exit_code(&self) -> CliExitCode {
47 self.exit_code
48 }
49
50 #[inline]
52 pub fn stderr(&self) -> &str {
53 &self.stderr
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct CliErrorView {
60 descriptor: &'static ErrorDescriptor,
61 context: ErrorContext,
62}
63
64impl CliErrorView {
65 #[inline]
67 pub fn from_error(error: &Error) -> Self {
68 Self {
69 descriptor: error.descriptor(),
70 context: error.context().clone(),
71 }
72 }
73
74 #[inline]
76 pub fn from_shared_error(error: &SharedError) -> Self {
77 Self::from_error(error.as_ref())
78 }
79
80 pub fn output(&self, verbosity: CliVerbosity) -> CliOutput {
87 let public = PublicErrorView::try_new(self.descriptor, &self.context)
88 .unwrap_or_else(|_| PublicErrorView::descriptor_only(self.descriptor));
89 let mut stderr = format!("ERROR {}: {}", public.code(), public.message());
90
91 if verbosity == CliVerbosity::Verbose {
92 append_diagnostics(&mut stderr, self.descriptor, &self.context);
93 }
94
95 CliOutput {
96 exit_code: public.projection().cli().exit_code,
97 stderr,
98 }
99 }
100}
101
102fn append_diagnostics(stderr: &mut String, descriptor: &'static ErrorDescriptor, context: &ErrorContext) {
103 let Ok(view) = DiagnosticView::try_new(descriptor, context) else {
104 return;
105 };
106 let mut rendered = String::new();
107 for field in view.fields() {
108 if !rendered.is_empty() {
109 rendered.push_str(", ");
110 }
111 rendered.push_str(field.name());
112 rendered.push('=');
113 let value = match field.value() {
114 ViewValueRef::Text(value) => format!("{value:?}"),
115 ViewValueRef::I64(value) => value.to_string(),
116 ViewValueRef::U64(value) => value.to_string(),
117 ViewValueRef::Bool(value) => value.to_string(),
118 ViewValueRef::Redacted => "<redacted>".to_string(),
119 };
120 rendered.push_str(&value);
121 }
122 if !rendered.is_empty() {
123 stderr.push_str("; details={");
124 stderr.push_str(&rendered);
125 stderr.push('}');
126 }
127 if view.is_truncated() {
128 stderr.push_str("; truncated=true");
129 }
130}
131
132impl From<&Error> for CliErrorView {
133 #[inline]
134 fn from(error: &Error) -> Self {
135 Self::from_error(error)
136 }
137}
138
139impl From<&SharedError> for CliErrorView {
140 #[inline]
141 fn from(error: &SharedError) -> Self {
142 Self::from_shared_error(error)
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use crate::CliErrorView;
149 use crate::CliExitCode;
150 use crate::CliVerbosity;
151 use crate::Error;
152 use crate::ErrorContext;
153
154 #[test]
155 fn cli_view_uses_descriptor_exit_code_and_stable_code() {
156 let error = Error::new(&crate::CORE_ARGUMENT_INVALID)
157 .with_context(ErrorContext::new().with_secret_presence(crate::fields::MESSAGE_PRESENT));
158 let output = CliErrorView::from_error(&error).output(CliVerbosity::Default);
159
160 assert_eq!(output.exit_code(), CliExitCode::USAGE);
161 assert_eq!(output.stderr(), "ERROR core.argument.invalid: Argument is invalid");
162 }
163
164 #[test]
165 fn default_stderr_never_contains_context_or_source_text() {
166 let error = Error::new(&crate::STORAGE_READ_FAILED).with_context(
167 ErrorContext::new()
168 .with_secret_presence(crate::fields::PATH_PRESENT)
169 .with_secret_presence(crate::fields::SOURCE_PRESENT),
170 );
171 let output = CliErrorView::from_error(&error).output(CliVerbosity::Default);
172 let rendered = output.stderr();
173
174 assert_eq!(rendered, "ERROR storage.read.failed: Storage read failed");
175 assert!(!rendered.contains("secret/token"));
176 assert!(!rendered.contains("permission denied"));
177
178 let route_error = Error::new(&crate::ROUTE_TOPIC_NOT_FOUND)
179 .with_context(ErrorContext::new().with_text(crate::fields::TOPIC, "TopicA"));
180 let route = CliErrorView::from_error(&route_error).output(CliVerbosity::Default);
181 assert_eq!(route.stderr(), "ERROR route.topic.not_found: Topic route was not found");
182 }
183
184 #[test]
185 fn verbose_stderr_uses_only_controlled_diagnostic_fields() {
186 let error = Error::new(&crate::CORE_ARGUMENT_INVALID)
187 .with_context(ErrorContext::new().with_secret_presence(crate::fields::MESSAGE_PRESENT));
188 let output = CliErrorView::from_error(&error).output(CliVerbosity::Verbose);
189 let rendered = output.stderr();
190
191 assert!(rendered.contains("message=<redacted>"));
192 assert!(!rendered.contains("plain-text"));
193 assert!(!rendered.contains("private"));
194 assert!(!rendered.contains("xxxx"));
195 assert!(!rendered.contains('\n'));
196 assert!(!rendered.contains('\r'));
197 }
198
199 #[test]
200 fn verbose_stderr_preserves_typed_scalar_fields() {
201 let error = Error::new(&crate::BROKER_MESSAGE_TOO_LARGE).with_context(
202 ErrorContext::new()
203 .with_u64(crate::fields::ACTUAL_BYTES, 7)
204 .with_u64(crate::fields::LIMIT_BYTES, 9),
205 );
206 let output = CliErrorView::from_error(&error).output(CliVerbosity::Verbose);
207 let rendered = output.stderr();
208
209 assert!(rendered.contains("actual_bytes=7"));
210 assert!(rendered.contains("limit_bytes=9"));
211
212 let error = Error::new(&crate::OBSERVABILITY_SUBSCRIBER_INSTALLATION_FAILED).with_context(
213 ErrorContext::new()
214 .with_bool(crate::fields::ATTEMPTED, true)
215 .with_bool(crate::fields::INSTALLED, false),
216 );
217 let output = CliErrorView::from_error(&error).output(CliVerbosity::Verbose);
218 assert!(output.stderr().contains("attempted=true"));
219 assert!(output.stderr().contains("installed=false"));
220 }
221
222 #[test]
223 fn verbosity_never_changes_the_descriptor_exit_code() {
224 let error = Error::new(&crate::ROUTE_TOPIC_NOT_FOUND)
225 .with_context(ErrorContext::new().with_text(crate::fields::TOPIC, "TopicA"));
226 let view = CliErrorView::from_error(&error);
227
228 assert_eq!(
229 view.output(CliVerbosity::Default).exit_code(),
230 view.output(CliVerbosity::Verbose).exit_code()
231 );
232 }
233
234 #[test]
235 fn every_supported_cli_exit_category_is_descriptor_owned() {
236 let cases = [
237 (&crate::CORE_ARGUMENT_INVALID, CliExitCode::USAGE),
238 (&crate::CORE_LIFECYCLE_NOT_INITIALIZED, CliExitCode::DATA),
239 (&crate::ROUTE_CLUSTER_NOT_FOUND, CliExitCode::NOT_FOUND),
240 (&crate::TRANSPORT_CONNECTION_FAILED, CliExitCode::UNAVAILABLE),
241 (&crate::CORE_INTERNAL_FAILURE, CliExitCode::SOFTWARE),
242 (&crate::CORE_OPERATION_TIMED_OUT, CliExitCode::TEMPORARY_FAILURE),
243 (&crate::PROXY_BROKER_PERMISSION_DENIED, CliExitCode::PERMISSION),
244 (&crate::CORE_CONFIGURATION_INVALID, CliExitCode::CONFIG),
245 ];
246
247 for (descriptor, expected) in cases {
248 let view = CliErrorView {
249 descriptor,
250 context: crate::ErrorContext::new(),
251 };
252 assert_eq!(view.output(CliVerbosity::Default).exit_code(), expected);
253 assert_eq!(view.output(CliVerbosity::Verbose).exit_code(), expected);
254 }
255 }
256
257 #[test]
258 fn invalid_context_fails_closed_without_losing_descriptor_identity() {
259 let view = CliErrorView {
260 descriptor: &crate::ROUTE_TOPIC_NOT_FOUND,
261 context: crate::ErrorContext::new().with_text(crate::fields::GROUP, "secret-group"),
262 };
263
264 let default = view.output(CliVerbosity::Default);
265 let verbose = view.output(CliVerbosity::Verbose);
266 assert_eq!(
267 default.stderr(),
268 "ERROR route.topic.not_found: Topic route was not found"
269 );
270 assert_eq!(verbose.stderr(), default.stderr());
271 assert_eq!(verbose.exit_code(), default.exit_code());
272 assert!(!verbose.stderr().contains("secret-group"));
273 }
274}