Skip to main content

oxios_cli/
format.rs

1//! CLI response formatter.
2//!
3//! Formats outgoing messages for terminal output with ANSI-compatible
4//! indicators for phase, evaluation, duration, and errors.
5
6use oxios_gateway::format::ChannelFormatter;
7use oxios_gateway::message::{ErrorKind, OutgoingMessage};
8
9/// CLI-specific response formatter.
10///
11/// Formats outgoing messages for terminal output with emoji indicators
12/// for phase, evaluation result, duration, and error classification.
13pub struct CliFormatter;
14
15impl ChannelFormatter for CliFormatter {
16    fn format_success(&self, msg: &OutgoingMessage) -> String {
17        let mut out = msg.content.clone();
18
19        if let Some(meta) = &msg.meta {
20            let eval_icon = if meta.evaluation_passed {
21                "✅"
22            } else {
23                "⚠️"
24            };
25            if !meta.phase.is_empty() {
26                out.push_str(&format!(
27                    "\n{} {} | {}",
28                    eval_icon,
29                    meta.phase,
30                    if meta.evaluation_passed {
31                        "통과"
32                    } else {
33                        "미통과"
34                    }
35                ));
36            }
37
38            if let Some(tag) = &meta.project_tag {
39                out.push_str(&format!(" | {tag}"));
40            }
41
42            if let Some(dur) = meta.duration_ms {
43                if dur >= 1000 {
44                    out.push_str(&format!(" | {:.1}s", dur as f64 / 1000.0));
45                } else {
46                    out.push_str(&format!(" | {dur}ms"));
47                }
48            }
49        }
50
51        out
52    }
53
54    fn format_error(&self, msg: &OutgoingMessage) -> String {
55        let meta = msg.meta.as_ref();
56        let kind = meta.and_then(|m| m.error.as_ref()).map(|e| e.kind);
57
58        let icon = match kind {
59            Some(ErrorKind::ExecutionFailed) => "❌",
60            Some(ErrorKind::ProviderError) => "🔌",
61            Some(ErrorKind::Timeout) => "⏱️",
62            Some(ErrorKind::PermissionDenied) => "🔒",
63            Some(ErrorKind::ValidationError) => "⚠️",
64            _ => "💥",
65        };
66
67        let mut out = format!("{} {}", icon, msg.content);
68
69        if let Some(err) = meta.and_then(|m| m.error.as_ref()) {
70            if let Some(s) = &err.suggestion {
71                out.push_str(&format!("\n💡 {s}"));
72            }
73        }
74
75        out
76    }
77
78    fn format_progress(&self, phase: &str) -> String {
79        match phase {
80            "Interview" => "🔍 분석 중...".into(),
81            "Seed" => "📋 계획 수립 중...".into(),
82            "Execute" => "⚡ 실행 중...".into(),
83            "Evaluate" => "📊 평가 중...".into(),
84            "Evolve" => "🔄 개선 중...".into(),
85            _ => "⏳ 처리 중...".into(),
86        }
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use oxios_gateway::message::{ResponseMeta, UserFacingError};
94    use std::collections::HashMap;
95
96    fn make_msg(content: &str, meta: Option<ResponseMeta>) -> OutgoingMessage {
97        OutgoingMessage {
98            id: uuid::Uuid::new_v4(),
99            channel: "cli".into(),
100            user_id: "test-user".into(),
101            content: content.into(),
102            timestamp: chrono::Utc::now(),
103            metadata: HashMap::new(),
104            meta,
105        }
106    }
107
108    #[test]
109    fn format_success_no_meta() {
110        let msg = make_msg("Hello", None);
111        let formatter = CliFormatter;
112        assert_eq!(formatter.format_success(&msg), "Hello");
113    }
114
115    #[test]
116    fn format_success_with_phase_and_eval() {
117        let meta = ResponseMeta {
118            session_id: None,
119            project_id: None,
120            project_tag: Some("[🔧 oxios]".into()),
121            seed_id: None,
122            phase: "Execute".into(),
123            evaluation_passed: true,
124            duration_ms: Some(1500),
125            error: None,
126        };
127        let msg = make_msg("Done!", Some(meta));
128        let formatter = CliFormatter;
129        let output = formatter.format_success(&msg);
130        assert!(output.contains("✅ Execute | 통과"));
131        assert!(output.contains("[🔧 oxios]"));
132        assert!(output.contains("1.5s"));
133    }
134
135    #[test]
136    fn format_success_failed_eval() {
137        let meta = ResponseMeta {
138            session_id: None,
139            project_id: None,
140            project_tag: None,
141            seed_id: None,
142            phase: "Evaluate".into(),
143            evaluation_passed: false,
144            duration_ms: Some(500),
145            error: None,
146        };
147        let msg = make_msg("Partial", Some(meta));
148        let formatter = CliFormatter;
149        let output = formatter.format_success(&msg);
150        assert!(output.contains("⚠️ Evaluate | 미통과"));
151        assert!(output.contains("500ms"));
152    }
153
154    #[test]
155    fn format_error_timeout() {
156        let meta = ResponseMeta {
157            session_id: None,
158            project_id: None,
159            project_tag: None,
160            seed_id: None,
161            phase: String::new(),
162            evaluation_passed: false,
163            duration_ms: None,
164            error: Some(UserFacingError {
165                message: "시간이 초과되었습니다.".into(),
166                kind: ErrorKind::Timeout,
167                suggestion: Some("더 간단한 요청으로 시도하세요.".into()),
168            }),
169        };
170        let msg = make_msg("시간이 초과되었습니다.", Some(meta));
171        let formatter = CliFormatter;
172        let output = formatter.format_error(&msg);
173        assert!(output.starts_with("⏱️"));
174        assert!(output.contains("💡 더 간단한 요청으로 시도하세요."));
175    }
176
177    #[test]
178    fn format_error_provider() {
179        let meta = ResponseMeta {
180            session_id: None,
181            project_id: None,
182            project_tag: None,
183            seed_id: None,
184            phase: String::new(),
185            evaluation_passed: false,
186            duration_ms: None,
187            error: Some(UserFacingError {
188                message: "AI 서비스 오류.".into(),
189                kind: ErrorKind::ProviderError,
190                suggestion: None,
191            }),
192        };
193        let msg = make_msg("AI 서비스 오류.", Some(meta));
194        let formatter = CliFormatter;
195        let output = formatter.format_error(&msg);
196        assert!(output.starts_with("🔌"));
197        assert!(!output.contains("💡")); // no suggestion
198    }
199
200    #[test]
201    fn format_progress_phases() {
202        let formatter = CliFormatter;
203        assert_eq!(formatter.format_progress("Interview"), "🔍 분석 중...");
204        assert_eq!(formatter.format_progress("Seed"), "📋 계획 수립 중...");
205        assert_eq!(formatter.format_progress("Execute"), "⚡ 실행 중...");
206        assert_eq!(formatter.format_progress("Evaluate"), "📊 평가 중...");
207        assert_eq!(formatter.format_progress("Evolve"), "🔄 개선 중...");
208        assert_eq!(formatter.format_progress("Unknown"), "⏳ 처리 중...");
209    }
210}