Skip to main content

rocketmq_error/
cli.rs

1// Copyright 2023 The RocketMQ Rust Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! CLI-facing error projection.
16
17use 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/// Controls how much safe diagnostic information a CLI error line contains.
27#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
28pub enum CliVerbosity {
29    /// Emits only the stable code and fixed public message.
30    #[default]
31    Default,
32    /// Appends descriptor-approved, redaction-aware diagnostic fields.
33    Verbose,
34}
35
36/// Complete process-boundary projection for one CLI failure.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct CliOutput {
39    exit_code: CliExitCode,
40    stderr: String,
41}
42
43impl CliOutput {
44    /// Returns the descriptor-owned process exit code.
45    #[inline]
46    pub const fn exit_code(&self) -> CliExitCode {
47        self.exit_code
48    }
49
50    /// Returns the single-line, redaction-safe stderr payload.
51    #[inline]
52    pub fn stderr(&self) -> &str {
53        &self.stderr
54    }
55}
56
57/// Stable error view for command-line tools.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct CliErrorView {
60    descriptor: &'static ErrorDescriptor,
61    context: ErrorContext,
62}
63
64impl CliErrorView {
65    /// Builds a CLI view from the canonical descriptor catalog.
66    #[inline]
67    pub fn from_error(error: &Error) -> Self {
68        Self {
69            descriptor: error.descriptor(),
70            context: error.context().clone(),
71        }
72    }
73
74    /// Builds a CLI view from a shared canonical error allocation.
75    #[inline]
76    pub fn from_shared_error(error: &SharedError) -> Self {
77        Self::from_error(error.as_ref())
78    }
79
80    /// Produces the exit code and stderr line from one catalog projection.
81    ///
82    /// Default output contains only the stable code and fixed public message.
83    /// Verbose output may append descriptor-approved, bounded diagnostic fields
84    /// with secret-bearing values represented only as `<redacted>`. Neither
85    /// mode renders source errors, locations, or backtraces.
86    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}