Skip to main content

xacli_testing/spec/
tape.rs

1//! VHS Tape file format and conversion
2
3use std::{collections::HashMap, path::Path, time::Duration};
4
5use serde::Serialize;
6
7use super::xacli::{self, CliConfig};
8use crate::Result;
9
10/// A VHS tape file containing test configuration
11#[derive(Debug, Clone, Serialize)]
12pub struct TapeFile {
13    /// Output configuration
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub output: Option<TapeOutput>,
16
17    /// Alias configuration (alias_name, full_path)
18    #[serde(skip)]
19    pub alias: Option<(String, String)>,
20
21    /// Test cases
22    pub tests: Vec<TapeTest>,
23}
24
25/// Output configuration for tape recording
26#[derive(Debug, Clone, Serialize)]
27pub struct TapeOutput {
28    /// Output file path for the recording
29    pub path: String,
30
31    /// Width of the terminal (columns)
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub width: Option<u16>,
34
35    /// Height of the terminal (rows)
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub height: Option<u16>,
38}
39
40/// A single test case in tape format
41#[derive(Debug, Clone, Serialize)]
42pub struct TapeTest {
43    /// Test name
44    pub name: String,
45
46    /// Command to execute (full command line)
47    pub command: Vec<String>,
48
49    /// Input instructions
50    #[serde(skip_serializing_if = "Vec::is_empty")]
51    pub inputs: Vec<TapeInstruction>,
52
53    /// Optional description
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub description: Option<String>,
56}
57
58impl TapeFile {
59    /// Create a new empty tape file
60    pub fn new() -> Self {
61        Self {
62            output: None,
63            alias: None,
64            tests: Vec::new(),
65        }
66    }
67
68    /// Save tape file to VHS format
69    pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
70        let content = self.to_vhs_format();
71        std::fs::write(path, content)?;
72        Ok(())
73    }
74
75    /// Convert to VHS tape format string
76    pub fn to_vhs_format(&self) -> String {
77        let mut output = String::new();
78
79        // Write output configuration if present
80        if let Some(out) = &self.output {
81            output.push_str(&format!("Output {}\n", out.path));
82            output.push('\n');
83        }
84
85        // Terminal settings
86        output.push_str("# Terminal settings\n");
87        output.push_str("Set Shell \"bash\"\n");
88        output.push_str("Set Width 2400\n");
89        output.push_str("Set Height 400\n");
90        output.push_str("Set FontSize 36\n");
91        output.push_str("Set Theme \"Catppuccin Mocha\"\n");
92        output.push_str("Set Padding 20\n");
93        output.push_str("Set Framerate 30\n");
94        output.push('\n');
95
96        // Set up alias if configured (hidden from display)
97        if let Some((alias_name, full_path)) = &self.alias {
98            output.push_str("Hide\n");
99            output.push_str(&format!(
100                "Type \"alias {}='{}' && clear\"\n",
101                alias_name, full_path
102            ));
103            output.push_str("Enter\n");
104            output.push_str("Show\n");
105            output.push_str("Sleep 100ms\n");
106            output.push('\n');
107        }
108
109        // Write each test
110        for (idx, test) in self.tests.iter().enumerate() {
111            if idx > 0 {
112                output.push_str("\n\n");
113            }
114
115            // Test comment
116            output.push_str(&format!("# Test: {}\n", test.name));
117            if let Some(desc) = &test.description {
118                output.push_str(&format!("# {}\n", desc));
119            }
120
121            // Command
122            if !test.command.is_empty() {
123                let cmd = test.command.join(" ");
124                output.push_str(&format!("Type \"{}\"\n", cmd));
125                output.push_str("Enter\n");
126
127                // If there are inputs, add a small delay before typing them
128                if !test.inputs.is_empty() {
129                    output.push_str("Sleep 500ms\n");
130                }
131            }
132
133            // Input instructions
134            for instruction in &test.inputs {
135                output.push_str(&instruction.to_vhs_command());
136                output.push('\n');
137                output.push_str("Sleep 300ms\n");
138            }
139
140            // Sleep after test completes
141            output.push_str("Sleep 1s\n");
142        }
143
144        output
145    }
146}
147
148impl Default for TapeFile {
149    fn default() -> Self {
150        Self::new()
151    }
152}
153
154/// Convert SpecFile to TapeFile (without program name)
155impl From<&xacli::SpecFile> for TapeFile {
156    fn from(spec: &xacli::SpecFile) -> Self {
157        let mut tape = TapeFile::new();
158
159        for suite in spec.suite.values() {
160            // Skip suite if marked as skip
161            if suite.features.skip {
162                continue;
163            }
164
165            for (name, test_case) in &suite.test {
166                // Skip tests marked as skip
167                if test_case.features.skip {
168                    continue;
169                }
170
171                tape.tests.push(TapeTest::from((name.as_str(), test_case)));
172            }
173        }
174
175        tape
176    }
177}
178
179/// Convert SpecFile with program name to TapeFile
180impl From<(&str, &xacli::SpecFile)> for TapeFile {
181    fn from((program_name, spec): (&str, &xacli::SpecFile)) -> Self {
182        let mut tape = TapeFile::new();
183
184        for suite in spec.suite.values() {
185            // Skip suite if marked as skip
186            if suite.features.skip {
187                continue;
188            }
189
190            for (name, test_case) in &suite.test {
191                // Skip tests marked as skip
192                if test_case.features.skip {
193                    continue;
194                }
195
196                let mut test = TapeTest::from((name.as_str(), test_case));
197                // Prepend program name to command
198                test.command.insert(0, program_name.to_string());
199                tape.tests.push(test);
200            }
201        }
202
203        tape
204    }
205}
206
207/// Convert test case to TapeTest
208impl From<(&str, &xacli::TestCase)> for TapeTest {
209    fn from((name, test_case): (&str, &xacli::TestCase)) -> Self {
210        // Build command: [commands..., args...]
211        let mut command = Vec::new();
212        command.extend(test_case.commands.iter().cloned());
213        command.extend(test_case.args.iter().cloned());
214
215        // Convert input events to tape instructions
216        let inputs = test_case
217            .input_events
218            .iter()
219            .flat_map(Vec::<TapeInstruction>::from)
220            .collect();
221
222        Self {
223            name: name.to_string(),
224            command,
225            inputs,
226            description: None,
227        }
228    }
229}
230
231/// Convert InputEvent to TapeInstructions
232impl From<&xacli::InputEvent> for Vec<TapeInstruction> {
233    fn from(event: &xacli::InputEvent) -> Self {
234        match event {
235            xacli::InputEvent::Key { key, repeat } => {
236                let instruction = match key.as_str() {
237                    "Enter" | "enter" => TapeInstruction::Enter,
238                    "Backspace" | "backspace" => TapeInstruction::Backspace(*repeat),
239                    "Up" | "up" => TapeInstruction::Up,
240                    "Down" | "down" => TapeInstruction::Down,
241                    "Left" | "left" => TapeInstruction::Left,
242                    "Right" | "right" => TapeInstruction::Right,
243                    "Space" | "space" => TapeInstruction::Space,
244                    "Escape" | "escape" | "Esc" | "esc" => TapeInstruction::Escape,
245                    "Home" | "home" => TapeInstruction::Home,
246                    "End" | "end" => TapeInstruction::End,
247                    "Tab" | "tab" => TapeInstruction::Tab,
248                    _ => {
249                        // Try to parse as Ctrl+key
250                        if let Some(stripped) = key
251                            .strip_prefix("Ctrl+")
252                            .or_else(|| key.strip_prefix("ctrl+"))
253                        {
254                            if let Some(c) = stripped.chars().next() {
255                                TapeInstruction::Ctrl(c)
256                            } else {
257                                return vec![];
258                            }
259                        } else if key.len() == 1 {
260                            // Single character key - treat as Type
261                            return vec![TapeInstruction::Type(key.clone()); *repeat];
262                        } else {
263                            // Unsupported multi-character key
264                            return vec![];
265                        }
266                    }
267                };
268
269                // Repeat the instruction if needed (but Backspace already has repeat built in)
270                if matches!(instruction, TapeInstruction::Backspace(_)) {
271                    vec![instruction]
272                } else {
273                    vec![instruction; *repeat]
274                }
275            }
276            xacli::InputEvent::Text { text } => {
277                vec![TapeInstruction::Type(text.clone())]
278            }
279        }
280    }
281}
282
283/// A VHS tape instruction
284#[derive(Debug, Clone, PartialEq, Serialize)]
285pub enum TapeInstruction {
286    /// Type a string of characters
287    Type(String),
288    /// Press Enter key
289    Enter,
290    /// Press Backspace key N times (default 1)
291    Backspace(usize),
292    /// Press Up arrow key
293    Up,
294    /// Press Down arrow key
295    Down,
296    /// Press Left arrow key
297    Left,
298    /// Press Right arrow key
299    Right,
300    /// Press Space key
301    Space,
302    /// Press Escape key
303    Escape,
304    /// Press Ctrl+key combination
305    Ctrl(char),
306    /// Sleep duration (ignored in tests, but parsed for completeness)
307    Sleep(Duration),
308    /// Home key
309    Home,
310    /// End key
311    End,
312    /// Tab key
313    Tab,
314}
315
316impl TapeInstruction {
317    /// Convert instruction to VHS tape command
318    pub fn to_vhs_command(&self) -> String {
319        match self {
320            TapeInstruction::Type(s) => format!("Type \"{}\"", s),
321            TapeInstruction::Enter => "Enter".to_string(),
322            TapeInstruction::Backspace(n) => {
323                if *n == 1 {
324                    "Backspace".to_string()
325                } else {
326                    format!("Backspace {}", n)
327                }
328            }
329            TapeInstruction::Up => "Up".to_string(),
330            TapeInstruction::Down => "Down".to_string(),
331            TapeInstruction::Left => "Left".to_string(),
332            TapeInstruction::Right => "Right".to_string(),
333            TapeInstruction::Space => "Space".to_string(),
334            TapeInstruction::Escape => "Escape".to_string(),
335            TapeInstruction::Ctrl(c) => format!("Ctrl+{}", c),
336            TapeInstruction::Sleep(d) => format!("Sleep {}ms", d.as_millis()),
337            TapeInstruction::Home => "Home".to_string(),
338            TapeInstruction::End => "End".to_string(),
339            TapeInstruction::Tab => "Tab".to_string(),
340        }
341    }
342}
343
344/// Convert ExampleBlock to TapeTest (without CLI path prefix)
345/// Note: This converts only the first step. Use the TapeFile conversion for multi-step examples.
346impl From<(&str, &xacli::ExampleBlock)> for TapeTest {
347    fn from((name, example): (&str, &xacli::ExampleBlock)) -> Self {
348        // Use first step if available
349        if let Some(step) = example.steps.first() {
350            let mut command = Vec::new();
351            command.extend(step.commands.iter().cloned());
352            command.extend(step.args.iter().cloned());
353
354            let inputs = step
355                .input_events
356                .iter()
357                .flat_map(Vec::<TapeInstruction>::from)
358                .collect();
359
360            Self {
361                name: format!("{}: {}", name, example.title),
362                command,
363                inputs,
364                description: example.description.clone(),
365            }
366        } else {
367            // Empty example
368            Self {
369                name: format!("{}: {}", name, example.title),
370                command: vec![],
371                inputs: vec![],
372                description: example.description.clone(),
373            }
374        }
375    }
376}
377
378/// Convert ExampleBlock with CliConfig to TapeFile
379/// Each step in the example becomes a separate TapeTest
380impl From<(&CliConfig, &HashMap<String, xacli::ExampleBlock>)> for TapeFile {
381    fn from((cli_config, examples): (&CliConfig, &HashMap<String, xacli::ExampleBlock>)) -> Self {
382        let mut tape = TapeFile::new();
383
384        // Set alias if configured
385        if let Some(alias) = &cli_config.alias {
386            tape.alias = Some((alias.clone(), cli_config.path.clone()));
387        }
388
389        for (name, example) in examples {
390            // Convert each step to a TapeTest
391            for (step_idx, step) in example.steps.iter().enumerate() {
392                // Use alias if available, otherwise use full path
393                let program = cli_config.alias.as_deref().unwrap_or(&cli_config.path);
394                let mut command = vec![program.to_string()];
395                command.extend(step.commands.iter().cloned());
396                command.extend(step.args.iter().cloned());
397
398                let inputs = step
399                    .input_events
400                    .iter()
401                    .flat_map(Vec::<TapeInstruction>::from)
402                    .collect();
403
404                let test_name = if example.steps.len() == 1 {
405                    format!("{}: {}", name, example.title)
406                } else {
407                    format!("{}: {} (step {})", name, example.title, step_idx + 1)
408                };
409
410                tape.tests.push(TapeTest {
411                    name: test_name,
412                    command,
413                    inputs,
414                    description: step
415                        .description
416                        .clone()
417                        .or_else(|| example.description.clone()),
418                });
419            }
420        }
421
422        tape
423    }
424}