Skip to main content

nika_engine/display/
check.rs

1//! CheckRenderer -- pre-flight validation checklist for `nika check`.
2//!
3//! Displays validation phases as a checklist with pass/fail status,
4//! timing, and inline error details. Uses the Cosmic icon palette.
5
6use colored::Colorize;
7
8use crate::display::colors::stripped_len;
9use crate::display::icons;
10
11/// Result of a single validation phase.
12pub struct PhaseResult {
13    pub name: &'static str,
14    pub passed: bool,
15    pub detail: String,
16    pub duration_ms: u64,
17    /// If failed, optional error context lines
18    pub errors: Vec<String>,
19    /// If failed, optional hint box lines
20    pub hints: Vec<String>,
21}
22
23/// Result of MCP server validation (--strict mode).
24pub struct McpCheckResult {
25    pub server_name: String,
26    pub tool_count: usize,
27    pub connect_ms: u64,
28    pub validations: Vec<McpCallValidation>,
29}
30
31/// Validation result for a single MCP call.
32pub struct McpCallValidation {
33    pub task_id: String,
34    pub tool_name: String,
35    pub valid: bool,
36    pub errors: Vec<McpParamError>,
37}
38
39/// A single parameter validation error in an MCP call.
40pub struct McpParamError {
41    pub path: String,
42    pub message: String,
43}
44
45/// Terminal width capped at 72 for consistent layout.
46fn term_width() -> usize {
47    terminal_size::terminal_size()
48        .map(|(tw, _)| tw.0 as usize)
49        .unwrap_or(80)
50        .min(72)
51}
52
53/// Print the check header with rounded corners.
54///
55/// ```text
56/// +-----------------------------------------------------------+
57/// |                                                           |
58/// |  N I K A  C H E C K                             v0.40.2   |
59/// |                                                           |
60/// |  workflow.nika.yaml                                       |
61/// |                                                           |
62/// +-----------------------------------------------------------+
63/// ```
64pub fn print_check_header(file: &str, strict: bool, version: &str) {
65    let w = term_width();
66    let inner = w - 2;
67    let border = "\u{2500}".repeat(inner);
68
69    println!("\u{256D}{}\u{256E}", border.dimmed());
70    println!("\u{2502}{}\u{2502}", " ".repeat(inner));
71
72    let title = if strict {
73        "N I K A  C H E C K  \u{2500} \u{2500}  S T R I C T"
74    } else {
75        "N I K A  C H E C K"
76    };
77    let ver = format!("v{}", version);
78    let pad = inner.saturating_sub(title.len() + ver.len() + 4);
79    println!(
80        "\u{2502}  {}{}{}  \u{2502}",
81        title.bold().white(),
82        " ".repeat(pad),
83        ver.dimmed()
84    );
85    println!("\u{2502}{}\u{2502}", " ".repeat(inner));
86
87    // File name
88    let file_pad = inner.saturating_sub(file.len() + 2);
89    println!("\u{2502}  {}{}\u{2502}", file.bold(), " ".repeat(file_pad));
90
91    println!("\u{2502}{}\u{2502}", " ".repeat(inner));
92    println!("\u{2570}{}\u{256F}", border.dimmed());
93    println!();
94}
95
96/// Print a single validation phase line.
97///
98/// ```text
99///   +  schema          YAML valid against @0.12                      1ms
100///   x  dag             CYCLE DETECTED                                0ms
101/// ```
102pub fn print_phase(result: &PhaseResult) {
103    let icon = if result.passed {
104        icons::success()
105    } else {
106        icons::failed()
107    };
108
109    let dur = format!("{}ms", result.duration_ms);
110
111    // Build the content parts with plain strings first, then color
112    let name_padded = format!("{:<16}", result.name);
113    let detail_padded = format!("{:<50}", result.detail);
114
115    println!(
116        "  {}  {} {} {}",
117        icon,
118        name_padded,
119        detail_padded,
120        dur.dimmed()
121    );
122
123    // Error details (indented under the phase)
124    for err in &result.errors {
125        println!("     {}", "\u{2502}".dimmed());
126        println!("     {} {}", "\u{2502}".dimmed(), err.red());
127    }
128
129    // Hint box (dashed border)
130    if !result.hints.is_empty() {
131        println!("     {}", "\u{2502}".dimmed());
132        let max_w = result.hints.iter().map(|h| h.len()).max().unwrap_or(40);
133        let dashes = "\u{254C}".repeat(max_w + 2);
134        println!(
135            "     {} \u{256D}{}\u{256E}",
136            "\u{2502}".dimmed(),
137            dashes.dimmed()
138        );
139        for hint in &result.hints {
140            let pad = max_w.saturating_sub(hint.len());
141            println!(
142                "     {} \u{2502} {}{} \u{2502}",
143                "\u{2502}".dimmed(),
144                hint,
145                " ".repeat(pad)
146            );
147        }
148        println!(
149            "     {} \u{2570}{}\u{256F}",
150            "\u{2502}".dimmed(),
151            dashes.dimmed()
152        );
153    }
154}
155
156/// Print a skipped phase (dependency failed).
157///
158/// ```text
159///   (/)  bindings        skipped (DAG invalid)
160/// ```
161pub fn print_phase_skipped(name: &str, reason: &str) {
162    let name_padded = format!("{:<16}", name);
163    println!(
164        "  {}  {} {}",
165        icons::skipped(),
166        name_padded,
167        format!("skipped ({})", reason).dimmed()
168    );
169}
170
171/// Print the MCP validation section for --strict mode.
172///
173/// ```text
174///   -- MCP Validation -------------------------------------------------------
175///
176///   (+) novanet
177///   | connected . 47 tools available                            320ms
178///   |
179///   | v analyze     -> novanet_search        params valid
180///   | x publish     -> novanet_write         2 errors
181///   |   | [params.resource]  must be one of: ...
182///   |
183///   | 2/3 calls valid
184/// ```
185pub fn print_mcp_validation(results: &[McpCheckResult]) {
186    let w = term_width();
187
188    println!();
189    let label = "\u{2500}\u{2500} MCP Validation ";
190    let fill = "\u{2500}".repeat(w.saturating_sub(label.len() + 2));
191    println!("  {}{}", label.dimmed(), fill.dimmed());
192    println!();
193
194    for result in results {
195        println!("  {} {}", icons::mcp(), result.server_name.green().bold());
196
197        // Connection info line
198        let conn_info = format!("connected \u{00B7} {} tools available", result.tool_count);
199        let dur_str = format!("{}ms", result.connect_ms);
200        let conn_pad = w.saturating_sub(
201            // "  | " prefix = 4 chars, plus conn_info + dur_str
202            4 + conn_info.len() + dur_str.len() + 2,
203        );
204        println!(
205            "  {} {} \u{00B7} {} tools available{}{}",
206            "\u{2502}".dimmed(),
207            "connected".green(),
208            result.tool_count,
209            " ".repeat(conn_pad),
210            dur_str.dimmed()
211        );
212        println!("  {}", "\u{2502}".dimmed());
213
214        let mut valid_count = 0u32;
215        let total = result.validations.len() as u32;
216
217        for v in &result.validations {
218            if v.valid {
219                valid_count += 1;
220                println!(
221                    "  {} {} {:<14}\u{2192} {:<24} {}",
222                    "\u{2502}".dimmed(),
223                    icons::success(),
224                    v.task_id,
225                    v.tool_name,
226                    "params valid".dimmed()
227                );
228            } else {
229                println!(
230                    "  {} {} {:<14}\u{2192} {:<24} {}",
231                    "\u{2502}".dimmed(),
232                    icons::failed(),
233                    v.task_id.red(),
234                    v.tool_name,
235                    format!("{} errors", v.errors.len()).red()
236                );
237                for err in &v.errors {
238                    println!(
239                        "  {}   {} {}  {}",
240                        "\u{2502}".dimmed(),
241                        "\u{2502}".dimmed(),
242                        format!("[{}]", err.path).yellow(),
243                        err.message.dimmed()
244                    );
245                }
246            }
247        }
248
249        println!("  {}", "\u{2502}".dimmed());
250        let summary = format!("{}/{} calls valid", valid_count, total);
251        let summary_colored = if valid_count == total {
252            summary.green()
253        } else {
254            summary.yellow()
255        };
256        println!("  {} {}", "\u{2502}".dimmed(), summary_colored);
257        println!();
258    }
259}
260
261/// Print the check summary footer.
262///
263/// ```text
264/// +-----------------------------------------------------------+
265/// |                                                           |
266/// |  v  V A L I D                                      6ms   |
267/// |                                                           |
268/// |  6 tasks . 5 edges . 3 layers . 2 schemas . 0 warnings   |
269/// |                                                           |
270/// +-----------------------------------------------------------+
271/// ```
272#[allow(clippy::too_many_arguments)]
273pub fn print_check_summary(
274    valid: bool,
275    total_ms: u64,
276    task_count: usize,
277    edge_count: usize,
278    layer_count: usize,
279    schema_count: u32,
280    strict_info: Option<(u32, u32, u32)>, // (valid_calls, total_calls, param_errors)
281    error_codes: &[(&str, &str)],         // (code, message) for NIKA-XXX errors
282) {
283    let w = term_width();
284    let inner = w - 2;
285    let border = "\u{2500}".repeat(inner);
286
287    println!("\u{256D}{}\u{256E}", border.dimmed());
288    println!("\u{2502}{}\u{2502}", " ".repeat(inner));
289
290    // Status line: build plain text first, measure, then color
291    let (icon, label) = if valid {
292        (icons::success(), "V A L I D".green().bold())
293    } else {
294        (icons::failed(), "I N V A L I D".red().bold())
295    };
296    let dur = format!("{}ms", total_ms);
297    let status_line = format!("  {}  {}", icon, label);
298    let pad = inner.saturating_sub(stripped_len(&status_line) + dur.len() + 2);
299    println!(
300        "\u{2502}{}{}{}  \u{2502}",
301        status_line,
302        " ".repeat(pad),
303        dur.dimmed()
304    );
305    println!("\u{2502}{}\u{2502}", " ".repeat(inner));
306
307    // Stats line
308    let mut stats_parts = vec![
309        format!(
310            "{} {}",
311            task_count,
312            if task_count == 1 { "task" } else { "tasks" }
313        ),
314        format!(
315            "{} {}",
316            edge_count,
317            if edge_count == 1 { "edge" } else { "edges" }
318        ),
319        format!(
320            "{} {}",
321            layer_count,
322            if layer_count == 1 { "layer" } else { "layers" }
323        ),
324    ];
325    if schema_count > 0 {
326        stats_parts.push(format!("{} schemas", schema_count));
327    }
328    let stats = stats_parts.join(" \u{00B7} ");
329    let stats_pad = inner.saturating_sub(stats.len() + 2);
330    println!("\u{2502}  {}{}\u{2502}", stats, " ".repeat(stats_pad));
331
332    // Strict info
333    if let Some((valid_calls, total_calls, param_errors)) = strict_info {
334        let strict_line = format!(
335            "strict: {}/{} MCP calls valid \u{00B7} {} param errors",
336            valid_calls, total_calls, param_errors
337        );
338        let strict_pad = inner.saturating_sub(strict_line.len() + 2);
339        println!(
340            "\u{2502}  {}{}\u{2502}",
341            strict_line,
342            " ".repeat(strict_pad)
343        );
344    }
345
346    // Error codes
347    for (code, msg) in error_codes {
348        let err_line = format!("{}: {}", code, msg);
349        let err_pad = inner.saturating_sub(err_line.len() + 2);
350        println!(
351            "\u{2502}  {}{}\u{2502}",
352            err_line.red(),
353            " ".repeat(err_pad)
354        );
355    }
356
357    println!("\u{2502}{}\u{2502}", " ".repeat(inner));
358    println!("\u{2570}{}\u{256F}", border.dimmed());
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_phase_result_pass() {
367        let result = PhaseResult {
368            name: "schema",
369            passed: true,
370            detail: "YAML valid against @0.12".to_string(),
371            duration_ms: 1,
372            errors: vec![],
373            hints: vec![],
374        };
375        // Should not panic
376        print_phase(&result);
377    }
378
379    #[test]
380    fn test_phase_result_fail_with_hints() {
381        let result = PhaseResult {
382            name: "dag",
383            passed: false,
384            detail: "CYCLE DETECTED".to_string(),
385            duration_ms: 0,
386            errors: vec!["step_a \u{2192} step_b \u{2192} step_c \u{2192} step_a".to_string()],
387            hints: vec![
388                "Remove one dependency to break the cycle.".to_string(),
389                "Common fix: use with: binding instead of depends_on.".to_string(),
390            ],
391        };
392        print_phase(&result);
393    }
394
395    #[test]
396    fn test_stripped_len_plain() {
397        // Plain text: no ANSI escapes
398        assert_eq!(stripped_len("hello"), 5);
399        assert_eq!(stripped_len(""), 0);
400        assert_eq!(stripped_len("V A L I D"), 9);
401    }
402
403    #[test]
404    fn test_stripped_len_colored_crate() {
405        // Test with colored crate output (matches real usage)
406        use colored::Colorize;
407        let green = "hello".green().to_string();
408        assert_eq!(stripped_len(&green), 5);
409        let bold_green = "\u{2713}".green().bold().to_string();
410        assert_eq!(stripped_len(&bold_green), 1);
411    }
412}