Skip to main content

evorule_cli/
output.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//! 输出格式化(human-readable + diff)
5//!
6//! # 设计
7//! - `fact_to_human`:单个 Fact 的单行摘要(适合 replay/diff)
8//! - `facts_to_human`:多 Fact 的多行输出
9//! - `format_diff_line`:diff 行前缀(`[~]`/`[-]`/`[+]`)
10
11use evorule_reactor::Fact;
12
13/// 单个 Fact 的单行摘要
14///
15/// 格式:`[F{id}] {type} {detail}`
16/// - Command: `[F1] Command type=noop`
17/// - StateTransition: `[F2] StateTransition cause=F1`
18/// - IoRequest: `[F3] IoRequest io_type=call_external`
19/// - Stable: `[F4] Stable`
20/// - Error: `[F5] Error: max_steps exceeded`
21///
22/// # 示例
23/// ```
24/// use evorule_cli::output::fact_to_human;
25/// use evorule_reactor::{Fact, FactId};
26/// use evorule_tcb::JsonValue;
27///
28/// let command = Fact::Command {
29///     id: FactId(1),
30///     instruction: JsonValue::object_from_pairs(&[
31///         ("type", JsonValue::string("noop")),
32///     ]),
33/// };
34/// assert_eq!(fact_to_human(&command), "[F1] Command type=noop");
35///
36/// let stable = Fact::Stable {
37///     id: FactId(4),
38///     version: 1,
39/// };
40/// assert_eq!(fact_to_human(&stable), "[F4] Stable version=1");
41/// ```
42pub fn fact_to_human(fact: &Fact) -> String {
43    match fact {
44        Fact::Command { id, instruction } => {
45            let instr_type = instruction
46                .get("type")
47                .and_then(|v| v.as_str())
48                .unwrap_or("?");
49            format!("[F{}] Command type={}", id.0, instr_type)
50        }
51        Fact::PayloadUpdate { id, path, .. } => {
52            format!("[F{}] PayloadUpdate path={}", id.0, path)
53        }
54        Fact::StateTransition { id, cause, .. } => {
55            format!("[F{}] StateTransition cause=F{}", id.0, cause.0)
56        }
57        Fact::IoRequest {
58            id, cause, io_type, ..
59        } => {
60            format!(
61                "[F{}] IoRequest cause=F{} io_type={}",
62                id.0,
63                cause.0,
64                io_type.as_str()
65            )
66        }
67        Fact::IoResponse {
68            id,
69            request_id,
70            error,
71            ..
72        } => match error {
73            Some(msg) => format!(
74                "[F{}] IoResponse request_id=F{} error={}",
75                id.0, request_id.0, msg
76            ),
77            None => format!("[F{}] IoResponse request_id=F{} ok", id.0, request_id.0),
78        },
79        Fact::Stable { id, version } => format!("[F{}] Stable version={}", id.0, version),
80        Fact::Error { id, message } => format!("[F{}] Error: {}", id.0, message),
81        Fact::TransitionTrace {
82            id,
83            cause,
84            rule_hits,
85        } => {
86            let hit_count = rule_hits.iter().filter(|h| h.hit).count();
87            format!(
88                "[F{}] TransitionTrace cause=F{} hits={}/{}",
89                id.0,
90                cause.0,
91                hit_count,
92                rule_hits.len()
93            )
94        }
95        Fact::Violation {
96            id,
97            cause,
98            rule_index,
99            reason,
100            ..
101        } => format!(
102            "[F{}] Violation cause=F{} rule={} {}",
103            id.0, cause.0, rule_index, reason
104        ),
105    }
106}
107
108/// 多 Fact 的多行输出
109pub fn facts_to_human(facts: &[Fact]) -> String {
110    facts
111        .iter()
112        .map(fact_to_human)
113        .collect::<Vec<_>>()
114        .join("\n")
115}
116
117/// diff 行前缀
118pub mod diff_prefix {
119    /// 两边都有但内容不同
120    pub const CHANGED: &str = "[~]";
121    /// 只在 A
122    pub const ONLY_A: &str = "[-]";
123    /// 只在 B
124    pub const ONLY_B: &str = "[+]";
125}
126
127/// 格式化 diff 行
128pub fn format_diff_line(prefix: &str, content: &str) -> String {
129    format!("{} {}", prefix, content)
130}
131
132#[cfg(test)]
133mod tests {
134    #![allow(clippy::unwrap_used)]
135    use super::*;
136    use evorule_reactor::{Fact, FactId, IoType};
137    use evorule_tcb::JsonValue;
138
139    #[test]
140    fn test_fact_to_human_command() {
141        let fact = Fact::Command {
142            id: FactId(1),
143            instruction: JsonValue::object_from_pairs(&[("type", JsonValue::string("noop"))]),
144        };
145        let s = fact_to_human(&fact);
146        assert!(s.contains("[F1] Command type=noop"));
147    }
148
149    #[test]
150    fn test_fact_to_human_error() {
151        let fact = Fact::Error {
152            id: FactId(5),
153            message: "max_steps exceeded".into(),
154        };
155        let s = fact_to_human(&fact);
156        assert!(s.contains("[F5] Error: max_steps exceeded"));
157    }
158
159    #[test]
160    fn test_fact_to_human_stable() {
161        let fact = Fact::Stable {
162            id: FactId(3),
163            version: 1,
164        };
165        let s = fact_to_human(&fact);
166        assert!(s.contains("[F3] Stable version=1"));
167    }
168
169    #[test]
170    fn test_fact_to_human_io_request() {
171        let fact = Fact::IoRequest {
172            id: FactId(4),
173            cause: FactId(2),
174            io_type: IoType::http_get(),
175            params: JsonValue::empty_object(),
176        };
177        let s = fact_to_human(&fact);
178        assert!(s.contains("IoRequest"));
179        assert!(s.contains("io_type=http_get"));
180    }
181
182    #[test]
183    fn test_facts_to_human_multiline() {
184        let facts = vec![
185            Fact::Command {
186                id: FactId(1),
187                instruction: JsonValue::empty_object(),
188            },
189            Fact::Stable {
190                id: FactId(2),
191                version: 1,
192            },
193        ];
194        let s = facts_to_human(&facts);
195        assert_eq!(s.lines().count(), 2);
196    }
197
198    #[test]
199    fn test_format_diff_line() {
200        let s = format_diff_line(diff_prefix::CHANGED, "some content");
201        assert_eq!(s, "[~] some content");
202    }
203}