Skip to main content

xacli_testing/spec/
xacli.rs

1//! XaCLI test file parser and runner
2//!
3//! Parses `.xacli` HCL test files and executes tests with declarative assertions.
4//!
5//! # File Format
6//!
7//! ```hcl
8//! suite "suite_name" {
9//!     features = {
10//!         skip = false
11//!         only = false
12//!         parallel = true
13//!     }
14//!
15//!     test "test_name" {
16//!         commands = ["get", "user"]
17//!         args = ["--verbose", "--output=json"]
18//!
19//!     input_events = [
20//!         { text = "hello" },
21//!         { key = "Enter" },
22//!         { key = "Backspace", repeat = 3 }
23//!     ]
24//!
25//!     assertions = [
26//!         { type = "success" },
27//!         { type = "exit_code", value = 0 },
28//!         { type = "contains", stream = "stdout", value = "expected text" },
29//!         { type = "empty", stream = "stderr" },
30//!         { type = "snapshot", snapshot = "snapshots/test.snap" },
31//!         {
32//!             type = "and",
33//!             conditions = [
34//!                 { type = "success" },
35//!                 { type = "contains", stream = "stdout", value = "done" }
36//!             ]
37//!         },
38//!         {
39//!             type = "or",
40//!             conditions = [
41//!                 { type = "exit_code", value = 0 },
42//!                 { type = "exit_code", value = 1 }
43//!             ]
44//!         }
45//!     ]
46//! }
47//! ```
48
49use std::{collections::HashMap, path::Path};
50
51use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
52use serde::Deserialize;
53
54use crate::{assert, Asserter, Result};
55
56/// A parsed .xacli test file
57#[derive(Debug, Clone, Deserialize)]
58pub struct SpecFile {
59    /// Global CLI configuration
60    #[serde(default)]
61    pub cli: Option<CliConfig>,
62
63    /// Documentation examples, keyed by example name
64    #[serde(default)]
65    pub example: HashMap<String, ExampleBlock>,
66
67    /// Test suites, keyed by suite name (from block label)
68    #[serde(default)]
69    pub suite: HashMap<String, Suite>,
70}
71
72/// Global CLI configuration
73#[derive(Debug, Clone, Deserialize)]
74pub struct CliConfig {
75    /// CLI application name
76    pub name: String,
77
78    /// Path to the CLI executable
79    pub path: String,
80
81    /// Optional short alias for display in tape files
82    #[serde(default)]
83    pub alias: Option<String>,
84}
85
86/// Documentation example block
87#[derive(Debug, Clone, Deserialize)]
88pub struct ExampleBlock {
89    /// Example title
90    pub title: String,
91
92    /// Optional description (can be multi-line Markdown)
93    #[serde(default)]
94    pub description: Option<String>,
95
96    /// Multiple command steps to execute
97    #[serde(default)]
98    pub steps: Vec<CommandStep>,
99}
100
101/// A single command step in an example
102#[derive(Debug, Clone, Deserialize)]
103pub struct CommandStep {
104    /// Commands/subcommands (e.g., ["doc", "build"])
105    #[serde(default)]
106    pub commands: Vec<String>,
107
108    /// Command line arguments
109    #[serde(default)]
110    pub args: Vec<String>,
111
112    /// Interactive input events (keyboard events)
113    #[serde(default)]
114    pub input_events: Vec<InputEvent>,
115
116    /// Optional step description
117    #[serde(default)]
118    pub description: Option<String>,
119}
120
121/// A test suite containing multiple test cases
122#[derive(Debug, Clone, Deserialize)]
123pub struct Suite {
124    /// Suite-level features (skip, only, parallel)
125    #[serde(default)]
126    pub features: TestFeatures,
127
128    /// Test cases within this suite, keyed by test name
129    #[serde(default)]
130    pub test: HashMap<String, TestCase>,
131}
132
133/// A single test case
134#[derive(Debug, Clone, Deserialize)]
135pub struct TestCase {
136    /// Commands/subcommands (e.g., ["get", "user"])
137    #[serde(default)]
138    pub commands: Vec<String>,
139
140    /// Command line arguments
141    #[serde(default)]
142    pub args: Vec<String>,
143
144    /// Test features (skip, only, parallel)
145    #[serde(default)]
146    pub features: TestFeatures,
147
148    /// Interactive input events (keyboard events)
149    #[serde(default)]
150    pub input_events: Vec<InputEvent>,
151
152    /// Assertions to perform after execution
153    #[serde(default)]
154    pub assertions: Vec<AssertionConfig>,
155}
156
157/// Test features controlling execution
158#[derive(Debug, Clone, Default, Deserialize)]
159pub struct TestFeatures {
160    /// Skip this test
161    #[serde(default)]
162    pub skip: bool,
163
164    /// Only run this test (and other "only" tests)
165    #[serde(default)]
166    pub only: bool,
167
168    /// Run in parallel with other tests (default: true)
169    #[serde(default = "default_true")]
170    pub parallel: bool,
171}
172
173fn default_true() -> bool {
174    true
175}
176
177/// Output stream selector
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
179#[serde(rename_all = "lowercase")]
180pub enum Stream {
181    /// Standard output
182    Stdout,
183    /// Standard error
184    Stderr,
185}
186
187/// Assertion configuration for declarative tests
188#[derive(Debug, Clone, Deserialize)]
189#[serde(tag = "type", rename_all = "snake_case")]
190pub enum AssertionConfig {
191    /// Assert command executed successfully (exit code 0)
192    Success,
193
194    /// Assert command failed (exit code non-zero)
195    Failure,
196
197    /// Assert exit code equals expected value
198    ExitCode { value: i32 },
199
200    /// Assert output stream contains text
201    Contains { stream: Stream, value: String },
202
203    /// Assert output stream is empty
204    Empty { stream: Stream },
205
206    /// Snapshot assertion (snapshot file contains code and stream info)
207    Snapshot {
208        /// Snapshot file path (relative to .xacli file)
209        snapshot: String,
210    },
211
212    /// Logical AND - all conditions must pass
213    And { conditions: Vec<AssertionConfig> },
214
215    /// Logical OR - at least one condition must pass
216    Or { conditions: Vec<AssertionConfig> },
217}
218
219impl AssertionConfig {
220    /// Convert assertion config to an Asserter implementation
221    pub fn to_asserter(&self) -> Result<Box<dyn Asserter>> {
222        match self {
223            AssertionConfig::Success => Ok(assert::success()),
224            AssertionConfig::Failure => Ok(assert::failure()),
225            AssertionConfig::ExitCode { value } => Ok(assert::code(*value as i64)),
226            AssertionConfig::Contains { stream, value } => Ok(match stream {
227                Stream::Stdout => assert::stdout().contains(value),
228                Stream::Stderr => assert::stderr().contains(value),
229            }),
230            AssertionConfig::Empty { stream } => Ok(match stream {
231                Stream::Stdout => assert::stdout().empty(),
232                Stream::Stderr => assert::stderr().empty(),
233            }),
234            AssertionConfig::Snapshot { .. } => {
235                // TODO: Implement snapshot assertion support
236                Err(crate::TestingError::Custom(
237                    "Snapshot assertions are not yet supported".to_string(),
238                ))
239            }
240            AssertionConfig::And { conditions } => {
241                let asserters: Result<Vec<_>> =
242                    conditions.iter().map(|c| c.to_asserter()).collect();
243                Ok(assert::and(asserters?))
244            }
245            AssertionConfig::Or { conditions } => {
246                let asserters: Result<Vec<_>> =
247                    conditions.iter().map(|c| c.to_asserter()).collect();
248                Ok(assert::or(asserters?))
249            }
250        }
251    }
252}
253
254/// An input event (keyboard input)
255#[derive(Debug, Clone, Deserialize)]
256#[serde(untagged)]
257pub enum InputEvent {
258    /// Key press: { key = "Enter" } or { key = "Backspace", repeat = 5 }
259    Key {
260        key: String,
261        #[serde(default = "default_one")]
262        repeat: usize,
263    },
264    /// Text input: { text = "hello" }
265    Text { text: String },
266}
267
268fn default_one() -> usize {
269    1
270}
271
272impl InputEvent {
273    /// Convert spec InputEvent to xacli_core InputEvents
274    /// Returns a Vec because repeat generates multiple events
275    pub fn to_core_events(&self) -> Result<Vec<xacli_core::InputEvent>> {
276        match self {
277            InputEvent::Key { key, repeat } => {
278                let key_code = match key.as_str() {
279                    "Enter" => KeyCode::Enter,
280                    "Backspace" => KeyCode::Backspace,
281                    "Delete" => KeyCode::Delete,
282                    "Left" => KeyCode::Left,
283                    "Right" => KeyCode::Right,
284                    "Up" => KeyCode::Up,
285                    "Down" => KeyCode::Down,
286                    "Home" => KeyCode::Home,
287                    "End" => KeyCode::End,
288                    "PageUp" => KeyCode::PageUp,
289                    "PageDown" => KeyCode::PageDown,
290                    "Tab" => KeyCode::Tab,
291                    "Esc" => KeyCode::Esc,
292                    "Space" => KeyCode::Char(' '),
293                    s if s.len() == 1 => KeyCode::Char(s.chars().next().unwrap()),
294                    // TODO: Support function keys (F1-F12), modifiers, etc.
295                    _ => {
296                        return Err(crate::TestingError::Custom(format!(
297                            "Unsupported key: {}",
298                            key
299                        )))
300                    }
301                };
302                Ok(vec![
303                    Event::Key(KeyEvent::new(
304                        key_code,
305                        KeyModifiers::empty()
306                    ));
307                    *repeat
308                ])
309            }
310            InputEvent::Text { text } => {
311                // TODO: Text input events are not directly supported in crossterm
312                // For now, we convert each character to a KeyCode::Char event
313                Ok(text
314                    .chars()
315                    .map(|c| Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty())))
316                    .collect())
317            }
318        }
319    }
320}
321
322impl SpecFile {
323    /// Parse a .xacli test file
324    pub fn parse_file(path: impl AsRef<Path>) -> Result<Self> {
325        let content = std::fs::read_to_string(path)?;
326        Self::parse_str(&content)
327    }
328
329    /// Parse from string content
330    pub fn parse_str(content: &str) -> Result<Self> {
331        hcl::from_str(content)
332            .map_err(|e| crate::TestingError::TapeParse(format!("HCL parse error: {}", e)))
333    }
334
335    /// Get all suite names
336    pub fn suite_names(&self) -> Vec<&str> {
337        self.suite.keys().map(|s| s.as_str()).collect()
338    }
339
340    /// Scan a directory recursively for .xacli.hcl spec files and merge into one SpecFile
341    ///
342    /// All suites and examples from multiple files are merged into a single SpecFile.
343    /// The cli config is taken from the first file that has one.
344    /// Files that fail to parse are skipped with a warning printed to stderr.
345    pub fn scan_dir(dir: impl AsRef<Path>) -> Result<Self> {
346        let dir = dir.as_ref();
347        if !dir.exists() {
348            return Err(crate::TestingError::Custom(format!(
349                "Directory does not exist: {}",
350                dir.display()
351            )));
352        }
353
354        let mut merged = Self {
355            cli: None,
356            example: HashMap::new(),
357            suite: HashMap::new(),
358        };
359        Self::scan_dir_recursive(dir, &mut merged)?;
360        Ok(merged)
361    }
362
363    fn scan_dir_recursive(dir: &Path, merged: &mut Self) -> Result<()> {
364        let entries = std::fs::read_dir(dir)?;
365
366        for entry in entries.flatten() {
367            let path = entry.path();
368            if path.is_dir() {
369                Self::scan_dir_recursive(&path, merged)?;
370            } else if path.extension().is_some_and(|ext| ext == "hcl") {
371                // Check if it's a .xacli.hcl file
372                if let Some(stem) = path.file_stem() {
373                    let stem_str = stem.to_string_lossy();
374                    if stem_str.ends_with(".xacli") {
375                        match Self::parse_file(&path) {
376                            Ok(spec) => {
377                                if merged.cli.is_none() {
378                                    merged.cli = spec.cli;
379                                }
380                                merged.example.extend(spec.example);
381                                merged.suite.extend(spec.suite);
382                            }
383                            Err(e) => {
384                                eprintln!("Warning: Failed to parse {}: {}", path.display(), e);
385                            }
386                        }
387                    }
388                }
389            }
390        }
391
392        Ok(())
393    }
394}