evorule_cli/error.rs
1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 EvoRule Project
3// This file is part of EvoRule, licensed under GNU Affero General Public License v3 or later.
4//! CLI 错误类型
5//!
6//! 统一的错误枚举,覆盖 I/O、JSON、规则加载、payload 解析、fact log 解析、
7//! TCB 执行、WAL 序列化等场景。所有子命令返回 `Result<(), CliError>`,
8//! main.rs 根据错误类型决定退出码。
9
10use thiserror::Error;
11
12/// CLI 错误
13///
14/// 设计原则:
15/// - 每个变体携带足够上下文(路径、行号、原因),便于用户定位问题
16/// - `#[from]` 自动转换 std::io::Error / serde_json::Error / WalError
17/// - Display 输出人类可读的中文/英文混合消息(与现有 CLI 风格一致)
18#[derive(Debug, Error)]
19pub enum CliError {
20 /// 审计链哨兵结构化退出码(2=自检失败 3=链破坏 4=文件消失 5=损坏拒收)
21 #[error("audit chain sentinel exit code {0}")]
22 SentinelExit(u8),
23 /// 文件系统 I/O 错误
24 #[error("I/O error: {0}")]
25 Io(#[from] std::io::Error),
26
27 /// JSON 序列化/反序列化错误
28 #[error("JSON error: {0}")]
29 Json(#[from] serde_json::Error),
30
31 /// tier1 WAL 序列化错误(fact_from_json 失败等)
32 #[error("WAL error: {0}")]
33 Wal(#[from] evorule_reactor::WalError),
34
35 /// 规则目录不存在
36 #[error("Rules directory does not exist: {0}")]
37 RulesDirNotFound(String),
38
39 /// 规则目录中无 .json 文件
40 #[error("No .json files found in {0}")]
41 NoRulesFound(String),
42
43 /// 初始 payload JSON 解析失败
44 #[error("Invalid payload JSON: {0}")]
45 InvalidPayload(String),
46
47 /// fact log 解析错误(指定行号和原因)
48 #[error("Fact log parse error at line {line}: {reason}")]
49 FactLogParse {
50 /// 出错的行号(1-based)
51 line: usize,
52 /// 错误原因
53 reason: String,
54 },
55
56 /// 哈希链验证失败
57 #[error("Hash chain verification failed: {0}")]
58 HashChain(String),
59
60 /// 执行完成但产生 Error 事实(规则执行失败)
61 ///
62 ///(C1/C3):执行含 Error fact 时不再返回退出码 0。
63 /// CI/自动化管道以退出码判定成败,Error fact 静默成功会让"确定性执行"
64 /// 的核心承诺在自动化场景下失效。fact log 仍正常写出供审计。
65 #[error(
66 "Execution completed with {count} Error fact(s); fact log written for audit (exit code 3)"
67 )]
68 ExecutionHadErrors {
69 /// Error 事实数量
70 count: usize,
71 },
72
73 /// 通用错误(兜底)
74 #[error("{0}")]
75 Other(String),
76}
77
78impl CliError {
79 /// 从任意字符串创建通用错误
80 ///
81 /// # 示例
82 /// ```
83 /// use evorule_cli::CliError;
84 ///
85 /// let err = CliError::other("something went wrong");
86 /// assert_eq!(err.to_string(), "something went wrong");
87 /// ```
88 pub fn other(msg: impl Into<String>) -> Self {
89 Self::Other(msg.into())
90 }
91}
92
93/// 退出码映射
94///
95/// 约定:
96/// - 0:成功
97/// - 1:通用错误(默认)
98/// - 2:规则加载错误(目录不存在、无 .json)
99/// - 3:执行完成但产生 Error 事实(:不再静默成功)
100impl CliError {
101 /// 返回该错误对应的退出码
102 ///
103 /// # 约定
104 /// - 0:成功
105 /// - 1:通用错误(默认)
106 /// - 2:规则加载错误(目录不存在、无 .json)
107 /// - 3:执行完成但产生 Error 事实()
108 ///
109 /// # 示例
110 /// ```
111 /// use evorule_cli::CliError;
112 ///
113 /// // 规则目录缺失 → 退出码 2
114 /// let dir_err = CliError::RulesDirNotFound("/nonexistent".into());
115 /// assert_eq!(dir_err.exit_code(), 2);
116 ///
117 /// // 执行含 Error fact → 退出码 3
118 /// let exec_err = CliError::ExecutionHadErrors { count: 1 };
119 /// assert_eq!(exec_err.exit_code(), 3);
120 ///
121 /// // 通用错误 → 退出码 1
122 /// assert_eq!(CliError::other("boom").exit_code(), 1);
123 /// ```
124 pub fn exit_code(&self) -> i32 {
125 match self {
126 CliError::SentinelExit(c) => *c as i32,
127 CliError::RulesDirNotFound(_) | CliError::NoRulesFound(_) => 2,
128 CliError::ExecutionHadErrors { .. } => 3,
129 _ => 1,
130 }
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 #![allow(clippy::unwrap_used)]
137 use super::*;
138
139 #[test]
140 fn test_exit_code_mapping() {
141 assert_eq!(CliError::RulesDirNotFound("x".into()).exit_code(), 2);
142 assert_eq!(CliError::NoRulesFound("x".into()).exit_code(), 2);
143 assert_eq!(CliError::ExecutionHadErrors { count: 1 }.exit_code(), 3);
144 assert_eq!(CliError::Other("x".into()).exit_code(), 1);
145 assert_eq!(
146 CliError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "x")).exit_code(),
147 1
148 );
149 }
150
151 #[test]
152 fn test_from_io_error() {
153 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
154 let cli_err: CliError = io_err.into();
155 assert!(matches!(cli_err, CliError::Io(_)));
156 }
157
158 #[test]
159 fn test_display_includes_context() {
160 let err = CliError::FactLogParse {
161 line: 42,
162 reason: "missing type field".into(),
163 };
164 let msg = format!("{}", err);
165 assert!(msg.contains("42"));
166 assert!(msg.contains("missing type field"));
167 }
168}