1use std::{collections::HashMap, path::Path};
50
51use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
52use serde::Deserialize;
53
54use crate::{assert, Asserter, Result};
55
56#[derive(Debug, Clone, Deserialize)]
58pub struct SpecFile {
59 #[serde(default)]
61 pub cli: Option<CliConfig>,
62
63 #[serde(default)]
65 pub example: HashMap<String, ExampleBlock>,
66
67 #[serde(default)]
69 pub suite: HashMap<String, Suite>,
70}
71
72#[derive(Debug, Clone, Deserialize)]
74pub struct CliConfig {
75 pub name: String,
77
78 pub path: String,
80
81 #[serde(default)]
83 pub alias: Option<String>,
84}
85
86#[derive(Debug, Clone, Deserialize)]
88pub struct ExampleBlock {
89 pub title: String,
91
92 #[serde(default)]
94 pub description: Option<String>,
95
96 #[serde(default)]
98 pub steps: Vec<CommandStep>,
99}
100
101#[derive(Debug, Clone, Deserialize)]
103pub struct CommandStep {
104 #[serde(default)]
106 pub commands: Vec<String>,
107
108 #[serde(default)]
110 pub args: Vec<String>,
111
112 #[serde(default)]
114 pub input_events: Vec<InputEvent>,
115
116 #[serde(default)]
118 pub description: Option<String>,
119}
120
121#[derive(Debug, Clone, Deserialize)]
123pub struct Suite {
124 #[serde(default)]
126 pub features: TestFeatures,
127
128 #[serde(default)]
130 pub test: HashMap<String, TestCase>,
131}
132
133#[derive(Debug, Clone, Deserialize)]
135pub struct TestCase {
136 #[serde(default)]
138 pub commands: Vec<String>,
139
140 #[serde(default)]
142 pub args: Vec<String>,
143
144 #[serde(default)]
146 pub features: TestFeatures,
147
148 #[serde(default)]
150 pub input_events: Vec<InputEvent>,
151
152 #[serde(default)]
154 pub assertions: Vec<AssertionConfig>,
155}
156
157#[derive(Debug, Clone, Default, Deserialize)]
159pub struct TestFeatures {
160 #[serde(default)]
162 pub skip: bool,
163
164 #[serde(default)]
166 pub only: bool,
167
168 #[serde(default = "default_true")]
170 pub parallel: bool,
171}
172
173fn default_true() -> bool {
174 true
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
179#[serde(rename_all = "lowercase")]
180pub enum Stream {
181 Stdout,
183 Stderr,
185}
186
187#[derive(Debug, Clone, Deserialize)]
189#[serde(tag = "type", rename_all = "snake_case")]
190pub enum AssertionConfig {
191 Success,
193
194 Failure,
196
197 ExitCode { value: i32 },
199
200 Contains { stream: Stream, value: String },
202
203 Empty { stream: Stream },
205
206 Snapshot {
208 snapshot: String,
210 },
211
212 And { conditions: Vec<AssertionConfig> },
214
215 Or { conditions: Vec<AssertionConfig> },
217}
218
219impl AssertionConfig {
220 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 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#[derive(Debug, Clone, Deserialize)]
256#[serde(untagged)]
257pub enum InputEvent {
258 Key {
260 key: String,
261 #[serde(default = "default_one")]
262 repeat: usize,
263 },
264 Text { text: String },
266}
267
268fn default_one() -> usize {
269 1
270}
271
272impl InputEvent {
273 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 _ => {
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 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 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 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 pub fn suite_names(&self) -> Vec<&str> {
337 self.suite.keys().map(|s| s.as_str()).collect()
338 }
339
340 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 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}