1use std::{collections::HashMap, path::Path, time::Duration};
4
5use serde::Serialize;
6
7use super::xacli::{self, CliConfig};
8use crate::Result;
9
10#[derive(Debug, Clone, Serialize)]
12pub struct TapeFile {
13 #[serde(skip_serializing_if = "Option::is_none")]
15 pub output: Option<TapeOutput>,
16
17 #[serde(skip)]
19 pub alias: Option<(String, String)>,
20
21 pub tests: Vec<TapeTest>,
23}
24
25#[derive(Debug, Clone, Serialize)]
27pub struct TapeOutput {
28 pub path: String,
30
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub width: Option<u16>,
34
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub height: Option<u16>,
38}
39
40#[derive(Debug, Clone, Serialize)]
42pub struct TapeTest {
43 pub name: String,
45
46 pub command: Vec<String>,
48
49 #[serde(skip_serializing_if = "Vec::is_empty")]
51 pub inputs: Vec<TapeInstruction>,
52
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub description: Option<String>,
56}
57
58impl TapeFile {
59 pub fn new() -> Self {
61 Self {
62 output: None,
63 alias: None,
64 tests: Vec::new(),
65 }
66 }
67
68 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 pub fn to_vhs_format(&self) -> String {
77 let mut output = String::new();
78
79 if let Some(out) = &self.output {
81 output.push_str(&format!("Output {}\n", out.path));
82 output.push('\n');
83 }
84
85 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 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 for (idx, test) in self.tests.iter().enumerate() {
111 if idx > 0 {
112 output.push_str("\n\n");
113 }
114
115 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 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 !test.inputs.is_empty() {
129 output.push_str("Sleep 500ms\n");
130 }
131 }
132
133 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 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
154impl 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 if suite.features.skip {
162 continue;
163 }
164
165 for (name, test_case) in &suite.test {
166 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
179impl 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 if suite.features.skip {
187 continue;
188 }
189
190 for (name, test_case) in &suite.test {
191 if test_case.features.skip {
193 continue;
194 }
195
196 let mut test = TapeTest::from((name.as_str(), test_case));
197 test.command.insert(0, program_name.to_string());
199 tape.tests.push(test);
200 }
201 }
202
203 tape
204 }
205}
206
207impl From<(&str, &xacli::TestCase)> for TapeTest {
209 fn from((name, test_case): (&str, &xacli::TestCase)) -> Self {
210 let mut command = Vec::new();
212 command.extend(test_case.commands.iter().cloned());
213 command.extend(test_case.args.iter().cloned());
214
215 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
231impl 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 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 return vec![TapeInstruction::Type(key.clone()); *repeat];
262 } else {
263 return vec![];
265 }
266 }
267 };
268
269 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#[derive(Debug, Clone, PartialEq, Serialize)]
285pub enum TapeInstruction {
286 Type(String),
288 Enter,
290 Backspace(usize),
292 Up,
294 Down,
296 Left,
298 Right,
300 Space,
302 Escape,
304 Ctrl(char),
306 Sleep(Duration),
308 Home,
310 End,
312 Tab,
314}
315
316impl TapeInstruction {
317 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
344impl From<(&str, &xacli::ExampleBlock)> for TapeTest {
347 fn from((name, example): (&str, &xacli::ExampleBlock)) -> Self {
348 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 Self {
369 name: format!("{}: {}", name, example.title),
370 command: vec![],
371 inputs: vec![],
372 description: example.description.clone(),
373 }
374 }
375 }
376}
377
378impl 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 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 for (step_idx, step) in example.steps.iter().enumerate() {
392 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}