Skip to main content

bnto_shell/
execute.rs

1// ShellCommand processor — execute external CLI tools.
2//
3// This is the generic "run any command" processor that enables
4// connector-as-recipe architecture. Recipes declare what tools
5// they need via recipe-level `requires`, and this processor
6// executes them via ProcessContext::run_command().
7//
8// Two output modes:
9//   - "stdout" (default): captures command stdout as a single output file
10//   - "file": runs command in a temp dir, collects written files as output.
11//     Use `{{output_dir}}` in args to inject the temp directory path.
12//     Designed for tools like yt-dlp and ffmpeg that write to disk.
13//
14// Security boundary: validate.rs checks every command before execution.
15// See that module for the full threat model (shell denylist, path
16// validation, env var sanitization).
17
18use bnto_core::metadata::InputCardinality;
19use bnto_core::processor::{NodeInput, NodeOutput};
20use bnto_core::{
21    BntoError, NodeCategory, NodeMetadata, NodeProcessor, ProcessContext, ProgressReporter,
22};
23
24pub use crate::args::flatten_conditional_args;
25use crate::args::resolve_input_placeholders;
26use crate::file_mode::process_file_mode;
27use crate::parameters::build_parameters;
28use crate::stdout_mode::process_stdout_mode;
29use crate::validate::{self, DEFAULT_MAX_OUTPUT_MB, DEFAULT_TIMEOUT_SECS};
30
31// Items the tests below exercise directly.
32#[cfg(test)]
33use crate::file_mode::OUTPUT_DIR_PLACEHOLDER;
34#[cfg(test)]
35use crate::output_files::{collect_output_files, mime_from_extension};
36#[cfg(test)]
37use crate::stdout_mode::make_output_filename;
38#[cfg(test)]
39use bnto_core::processor::FileData;
40
41/// JSON params of a node, as the processor receives them.
42type Params = serde_json::Map<String, serde_json::Value>;
43
44/// Shell command processor — runs external CLI tools.
45pub struct ShellCommand;
46
47impl ShellCommand {
48    pub fn new() -> Self {
49        Self
50    }
51}
52
53impl Default for ShellCommand {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl NodeProcessor for ShellCommand {
60    fn name(&self) -> &str {
61        "shell-command"
62    }
63
64    fn validate(&self, params: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
65        let mut errors = Vec::new();
66
67        match params.get("command").and_then(serde_json::Value::as_str) {
68            None => errors.push("'command' parameter is required".to_string()),
69            Some(cmd) => {
70                if let Err(e) = validate::validate_command(cmd) {
71                    errors.push(e);
72                }
73            }
74        }
75
76        if let Some(timeout) = params.get("timeout").and_then(serde_json::Value::as_u64)
77            && timeout == 0
78        {
79            errors.push("'timeout' must be greater than 0".to_string());
80        }
81
82        errors
83    }
84
85    fn process(
86        &self,
87        input: NodeInput,
88        progress: &ProgressReporter,
89        ctx: &dyn ProcessContext,
90    ) -> Result<NodeOutput, BntoError> {
91        let params = &input.params;
92        let command = required_command(params)?;
93        let mut args = flattened_args(params);
94        // Parsed (env sanitized) but not yet applied to the spawned command.
95        let _timeout = u64_param(params, "timeout", DEFAULT_TIMEOUT_SECS);
96        let _env = sanitized_env(params);
97        // Security check: reject shell interpreters and path-based commands
98        validate::validate_command(command).map_err(BntoError::InvalidInput)?;
99        // Resolve {{url}} and {{input}} placeholders from injected params.
100        // The CLI injects "url" or "text" params for URL/Text mode recipes.
101        resolve_input_placeholders(&mut args, params);
102        let max_output_mb = u64_param(params, "maxOutputSize", DEFAULT_MAX_OUTPUT_MB);
103        match params.get("outputMode").and_then(serde_json::Value::as_str) {
104            Some("file") => process_file_mode(command, &args, &input, progress, ctx, max_output_mb),
105            _ => process_stdout_mode(command, &args, &input, progress, ctx, max_output_mb),
106        }
107    }
108
109    fn metadata(&self) -> NodeMetadata {
110        NodeMetadata {
111            node_type: "shell-command".to_string(),
112            name: "Shell Command".to_string(),
113            description: "Execute external CLI tools with security validation.".to_string(),
114            category: NodeCategory::System,
115            accepts: vec![],
116            platforms: vec![
117                "cli".to_string(),
118                "server".to_string(),
119                "desktop".to_string(),
120            ],
121            parameters: build_parameters(),
122            input_cardinality: InputCardinality::Source,
123            requires: vec![],
124        }
125    }
126}
127
128/// The required `command` param.
129fn required_command(params: &Params) -> Result<&str, BntoError> {
130    params
131        .get("command")
132        .and_then(serde_json::Value::as_str)
133        .ok_or_else(|| BntoError::InvalidInput("'command' parameter is required".to_string()))
134}
135
136/// The `args` param with conditional groups flattened (empty when unset).
137fn flattened_args(params: &Params) -> Vec<String> {
138    params
139        .get("args")
140        .and_then(serde_json::Value::as_array)
141        .map(|arr| flatten_conditional_args(arr))
142        .unwrap_or_default()
143}
144
145/// A numeric param, or `default` when unset.
146fn u64_param(params: &Params, key: &str, default: u64) -> u64 {
147    params
148        .get(key)
149        .and_then(serde_json::Value::as_u64)
150        .unwrap_or(default)
151}
152
153/// Env vars with dangerous entries stripped silently.
154fn sanitized_env(params: &Params) -> Params {
155    params
156        .get("env")
157        .and_then(serde_json::Value::as_object)
158        .map(validate::sanitize_env)
159        .unwrap_or_default()
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use bnto_core::NoopContext;
166
167    // --- Trait basics ---
168
169    #[test]
170    fn test_processor_name() {
171        let processor = ShellCommand::new();
172        assert_eq!(processor.name(), "shell-command");
173    }
174
175    #[test]
176    fn test_metadata_category_system() {
177        let processor = ShellCommand::new();
178        let meta = processor.metadata();
179        assert_eq!(meta.category, NodeCategory::System);
180    }
181
182    #[test]
183    fn test_metadata_platforms_native_only() {
184        let processor = ShellCommand::new();
185        let meta = processor.metadata();
186        assert_eq!(meta.platforms, vec!["cli", "server", "desktop"]);
187        assert!(!meta.platforms.contains(&"browser".to_string()));
188    }
189
190    #[test]
191    fn test_metadata_parameters_complete() {
192        let processor = ShellCommand::new();
193        let meta = processor.metadata();
194        let param_names: Vec<&str> = meta.parameters.iter().map(|p| p.name.as_str()).collect();
195        assert!(param_names.contains(&"command"));
196        assert!(param_names.contains(&"args"));
197        assert!(param_names.contains(&"outputMode"));
198        assert!(param_names.contains(&"timeout"));
199        assert!(param_names.contains(&"maxOutputSize"));
200        assert!(param_names.contains(&"env"));
201    }
202
203    #[test]
204    fn test_metadata_no_requires() {
205        let processor = ShellCommand::new();
206        let meta = processor.metadata();
207        assert!(
208            meta.requires.is_empty(),
209            "shell-command has no inherent deps"
210        );
211    }
212
213    // --- Validation ---
214
215    #[test]
216    fn test_validate_empty_command_fails() {
217        let processor = ShellCommand::new();
218        let mut params = serde_json::Map::new();
219        params.insert("command".into(), serde_json::Value::String("".into()));
220        let errors = processor.validate(&params);
221        assert!(!errors.is_empty());
222    }
223
224    #[test]
225    fn test_validate_missing_command_fails() {
226        let processor = ShellCommand::new();
227        let params = serde_json::Map::new();
228        let errors = processor.validate(&params);
229        assert!(!errors.is_empty());
230        assert!(errors[0].contains("required"));
231    }
232
233    #[test]
234    fn test_validate_present_command_passes() {
235        let processor = ShellCommand::new();
236        let mut params = serde_json::Map::new();
237        params.insert("command".into(), serde_json::Value::String("echo".into()));
238        let errors = processor.validate(&params);
239        assert!(errors.is_empty());
240    }
241
242    #[test]
243    fn test_validate_shell_command_rejected() {
244        let processor = ShellCommand::new();
245        let mut params = serde_json::Map::new();
246        params.insert("command".into(), serde_json::Value::String("bash".into()));
247        let errors = processor.validate(&params);
248        assert!(!errors.is_empty());
249        assert!(errors[0].contains("shell interpreter"));
250    }
251
252    #[test]
253    fn test_validate_zero_timeout_rejected() {
254        let processor = ShellCommand::new();
255        let mut params = serde_json::Map::new();
256        params.insert("command".into(), serde_json::Value::String("echo".into()));
257        params.insert(
258            "timeout".into(),
259            serde_json::Value::Number(serde_json::Number::from(0)),
260        );
261        let errors = processor.validate(&params);
262        assert!(!errors.is_empty());
263        assert!(errors[0].contains("greater than 0"));
264    }
265
266    // --- Process ---
267
268    #[test]
269    fn test_noop_context_returns_error() {
270        let processor = ShellCommand::new();
271        let progress = ProgressReporter::new_noop();
272        let input = NodeInput {
273            data: FileData::Bytes(vec![]),
274            filename: "test.txt".to_string(),
275            mime_type: None,
276            params: {
277                let mut m = serde_json::Map::new();
278                m.insert("command".into(), serde_json::Value::String("echo".into()));
279                m.insert(
280                    "args".into(),
281                    serde_json::Value::Array(vec![serde_json::Value::String("hello".into())]),
282                );
283                m
284            },
285        };
286        let result = processor.process(input, &progress, &NoopContext);
287        assert!(result.is_err());
288        let err = result.err().expect("should be error");
289        assert!(err.to_string().contains("not available in browser"));
290    }
291
292    #[test]
293    fn test_process_rejects_missing_command() {
294        let processor = ShellCommand::new();
295        let progress = ProgressReporter::new_noop();
296        let input = NodeInput {
297            data: FileData::Bytes(vec![]),
298            filename: "test.txt".to_string(),
299            mime_type: None,
300            params: serde_json::Map::new(),
301        };
302        let result = processor.process(input, &progress, &NoopContext);
303        assert!(result.is_err());
304    }
305
306    #[test]
307    fn test_process_rejects_shell_command() {
308        let processor = ShellCommand::new();
309        let progress = ProgressReporter::new_noop();
310        let input = NodeInput {
311            data: FileData::Bytes(vec![]),
312            filename: "test.txt".to_string(),
313            mime_type: None,
314            params: {
315                let mut m = serde_json::Map::new();
316                m.insert("command".into(), serde_json::Value::String("bash".into()));
317                m.insert(
318                    "args".into(),
319                    serde_json::Value::Array(vec![
320                        serde_json::Value::String("-c".into()),
321                        serde_json::Value::String("echo pwned".into()),
322                    ]),
323                );
324                m
325            },
326        };
327        let result = processor.process(input, &progress, &NoopContext);
328        assert!(result.is_err());
329        let err = result.err().expect("should be error");
330        assert!(err.to_string().contains("shell interpreter"));
331    }
332
333    #[test]
334    fn test_default_timeout_in_metadata() {
335        let processor = ShellCommand::new();
336        let meta = processor.metadata();
337        let timeout_param = meta
338            .parameters
339            .iter()
340            .find(|p| p.name == "timeout")
341            .expect("timeout param should exist");
342        assert_eq!(
343            timeout_param.default,
344            Some(serde_json::Value::Number(serde_json::Number::from(300u64)))
345        );
346    }
347
348    #[test]
349    fn test_default_max_output_size_in_metadata() {
350        let processor = ShellCommand::new();
351        let meta = processor.metadata();
352        let param = meta
353            .parameters
354            .iter()
355            .find(|p| p.name == "maxOutputSize")
356            .expect("maxOutputSize param should exist");
357        assert_eq!(
358            param.default,
359            Some(serde_json::Value::Number(serde_json::Number::from(
360                DEFAULT_MAX_OUTPUT_MB
361            )))
362        );
363        assert_eq!(param.suffix, Some("MB".to_string()));
364    }
365
366    #[test]
367    fn test_env_param_not_surfaceable() {
368        let processor = ShellCommand::new();
369        let meta = processor.metadata();
370        let env_param = meta
371            .parameters
372            .iter()
373            .find(|p| p.name == "env")
374            .expect("env param should exist");
375        assert!(!env_param.surfaceable, "env should be internal-only");
376    }
377
378    #[test]
379    fn test_output_mode_default_is_stdout() {
380        let processor = ShellCommand::new();
381        let meta = processor.metadata();
382        let param = meta
383            .parameters
384            .iter()
385            .find(|p| p.name == "outputMode")
386            .expect("outputMode param should exist");
387        assert_eq!(
388            param.default,
389            Some(serde_json::Value::String("stdout".to_string()))
390        );
391    }
392
393    // --- File mode helpers ---
394
395    #[test]
396    fn test_mime_from_extension_video() {
397        assert_eq!(mime_from_extension("video.mp4"), "video/mp4");
398        assert_eq!(mime_from_extension("video.webm"), "video/webm");
399        assert_eq!(mime_from_extension("video.mkv"), "video/x-matroska");
400    }
401
402    #[test]
403    fn test_mime_from_extension_audio() {
404        assert_eq!(mime_from_extension("audio.mp3"), "audio/mpeg");
405        assert_eq!(mime_from_extension("audio.m4a"), "audio/mp4");
406        assert_eq!(mime_from_extension("audio.wav"), "audio/wav");
407        assert_eq!(mime_from_extension("audio.flac"), "audio/flac");
408    }
409
410    #[test]
411    fn test_mime_from_extension_unknown() {
412        assert_eq!(mime_from_extension("file.xyz"), "application/octet-stream");
413        assert_eq!(mime_from_extension("noext"), "application/octet-stream");
414    }
415
416    #[test]
417    fn test_make_output_filename_empty_input() {
418        assert_eq!(make_output_filename("echo", ""), "echo-output");
419    }
420
421    #[test]
422    fn test_make_output_filename_with_input() {
423        assert_eq!(
424            make_output_filename("ffmpeg", "video.mp4"),
425            "video-ffmpeg-output"
426        );
427    }
428
429    #[test]
430    fn test_collect_output_files_returns_path_variant() {
431        let temp = tempfile::tempdir().unwrap();
432        let dir = temp.path().to_path_buf();
433        std::fs::write(dir.join("video.mp4"), vec![0u8; 100]).unwrap();
434        std::fs::write(dir.join("info.json"), b"{}").unwrap();
435
436        let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
437        assert_eq!(files.len(), 2);
438
439        let names: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
440        assert!(names.contains(&"video.mp4"));
441        assert!(names.contains(&"info.json"));
442
443        // Verify MIME types
444        let mp4 = files.iter().find(|f| f.filename == "video.mp4").unwrap();
445        assert_eq!(mp4.mime_type, "video/mp4");
446        let json = files.iter().find(|f| f.filename == "info.json").unwrap();
447        assert_eq!(json.mime_type, "application/json");
448
449        // Verify FileData::Path variant — files stay on disk, not read into memory.
450        for file in &files {
451            assert!(
452                matches!(&file.data, FileData::Path(_)),
453                "collect_output_files should return FileData::Path, got Bytes for {}",
454                file.filename,
455            );
456        }
457    }
458
459    #[test]
460    fn test_collect_output_files_preserves_subdirectory_in_filename() {
461        let temp = tempfile::tempdir().unwrap();
462        let dir = temp.path().to_path_buf();
463        std::fs::create_dir_all(dir.join("subdir")).unwrap();
464        std::fs::write(dir.join("file.txt"), b"data").unwrap();
465        std::fs::write(dir.join("subdir").join("video.mp4"), vec![0u8; 50]).unwrap();
466
467        let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
468        assert_eq!(files.len(), 2);
469
470        let names: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
471        assert!(names.contains(&"file.txt"));
472        assert!(names.contains(&"subdir/video.mp4"));
473    }
474
475    #[test]
476    fn test_collect_output_files_preserves_deeply_nested_path() {
477        let temp = tempfile::tempdir().unwrap();
478        let dir = temp.path().to_path_buf();
479        std::fs::create_dir_all(dir.join("a").join("b")).unwrap();
480        std::fs::write(dir.join("a").join("b").join("deep.mp4"), vec![0u8; 10]).unwrap();
481
482        let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
483        assert_eq!(files.len(), 1);
484        assert_eq!(files[0].filename, "a/b/deep.mp4");
485    }
486
487    #[test]
488    fn test_collect_output_files_multiple_subdirs_preserve_structure() {
489        let temp = tempfile::tempdir().unwrap();
490        let dir = temp.path().to_path_buf();
491        std::fs::create_dir_all(dir.join("Alpha Legion")).unwrap();
492        std::fs::create_dir_all(dir.join("Suboden Khan")).unwrap();
493        std::fs::write(dir.join("Alpha Legion").join("part1.mp4"), b"vid1").unwrap();
494        std::fs::write(dir.join("Suboden Khan").join("part2.mp4"), b"vid2").unwrap();
495
496        let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
497        assert_eq!(files.len(), 2);
498
499        let mut names: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
500        names.sort();
501        assert_eq!(
502            names,
503            vec!["Alpha Legion/part1.mp4", "Suboden Khan/part2.mp4"]
504        );
505    }
506
507    #[test]
508    fn test_collect_output_files_empty_dir() {
509        let temp = tempfile::tempdir().unwrap();
510        let dir = temp.path().to_path_buf();
511
512        let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
513        assert!(files.is_empty());
514    }
515
516    #[test]
517    fn test_collect_output_files_rejects_oversized_file() {
518        let temp = tempfile::tempdir().unwrap();
519        let dir = temp.path().to_path_buf();
520        // 2 MB file with a 1 MB limit — should fail before reading into memory.
521        std::fs::write(dir.join("big.mp4"), vec![0u8; 2 * 1024 * 1024]).unwrap();
522
523        let result = collect_output_files(&dir, 1);
524        match result {
525            Err(e) => {
526                let msg = e.to_string();
527                assert!(msg.contains("big.mp4"), "Error should name the file");
528                assert!(msg.contains("1 MB limit"), "Error should state the limit");
529            }
530            Ok(_) => panic!("Expected error for oversized file"),
531        }
532    }
533
534    #[test]
535    fn test_collect_output_files_accepts_file_under_limit() {
536        let temp = tempfile::tempdir().unwrap();
537        let dir = temp.path().to_path_buf();
538        // 512 KB file with a 1 MB limit — should succeed.
539        std::fs::write(dir.join("small.mp4"), vec![0u8; 512 * 1024]).unwrap();
540
541        let files = collect_output_files(&dir, 1).unwrap();
542        assert_eq!(files.len(), 1);
543        assert_eq!(files[0].filename, "small.mp4");
544    }
545
546    #[test]
547    fn test_output_dir_placeholder_substitution() {
548        let args = [
549            "-o".to_string(),
550            "{{output_dir}}/%(title)s.%(ext)s".to_string(),
551            "--verbose".to_string(),
552        ];
553        let dir_str = "/tmp/bnto-output";
554        let resolved: Vec<String> = args
555            .iter()
556            .map(|a| a.replace(OUTPUT_DIR_PLACEHOLDER, dir_str))
557            .collect();
558        assert_eq!(resolved[0], "-o");
559        assert_eq!(resolved[1], "/tmp/bnto-output/%(title)s.%(ext)s");
560        assert_eq!(resolved[2], "--verbose");
561    }
562
563    // --- Input placeholder resolution ---
564
565    #[test]
566    fn test_resolve_url_appended_when_no_placeholder() {
567        let mut args = vec!["--no-playlist".to_string(), "-o".to_string()];
568        let mut params = serde_json::Map::new();
569        params.insert("url".into(), "https://example.com/video".into());
570        resolve_input_placeholders(&mut args, &params);
571        assert_eq!(args.len(), 3);
572        assert_eq!(args[2], "https://example.com/video");
573    }
574
575    #[test]
576    fn test_resolve_url_placeholder_substituted() {
577        let mut args = vec!["--download".to_string(), "{{url}}".to_string()];
578        let mut params = serde_json::Map::new();
579        params.insert("url".into(), "https://example.com/video".into());
580        resolve_input_placeholders(&mut args, &params);
581        assert_eq!(args.len(), 2);
582        assert_eq!(args[1], "https://example.com/video");
583    }
584
585    #[test]
586    fn test_resolve_input_placeholder_substituted() {
587        let mut args = vec!["process".to_string(), "{{input}}".to_string()];
588        let mut params = serde_json::Map::new();
589        params.insert("url".into(), "https://example.com/data".into());
590        resolve_input_placeholders(&mut args, &params);
591        assert_eq!(args.len(), 2);
592        assert_eq!(args[1], "https://example.com/data");
593    }
594
595    #[test]
596    fn test_resolve_no_url_no_change() {
597        let mut args = vec!["--help".to_string()];
598        let params = serde_json::Map::new();
599        resolve_input_placeholders(&mut args, &params);
600        assert_eq!(args.len(), 1);
601        assert_eq!(args[0], "--help");
602    }
603
604    #[test]
605    fn test_resolve_url_not_appended_when_placeholder_exists() {
606        let mut args = vec!["{{url}}".to_string(), "--verbose".to_string()];
607        let mut params = serde_json::Map::new();
608        params.insert("url".into(), "https://example.com".into());
609        resolve_input_placeholders(&mut args, &params);
610        // URL substituted in placeholder, NOT also appended
611        assert_eq!(args.len(), 2);
612        assert_eq!(args[0], "https://example.com");
613    }
614
615    // --- flatten_conditional_args ---
616
617    #[test]
618    fn test_flatten_flat_strings_included() {
619        let args = vec![
620            serde_json::Value::String("--no-playlist".into()),
621            serde_json::Value::String("--newline".into()),
622        ];
623        assert_eq!(
624            flatten_conditional_args(&args),
625            vec!["--no-playlist", "--newline"]
626        );
627    }
628
629    #[test]
630    fn test_flatten_empty_strings_dropped() {
631        let args = vec![
632            serde_json::Value::String("--always".into()),
633            serde_json::Value::String("".into()),
634            serde_json::Value::String("--also-always".into()),
635        ];
636        assert_eq!(
637            flatten_conditional_args(&args),
638            vec!["--always", "--also-always"]
639        );
640    }
641
642    #[test]
643    fn test_flatten_nested_array_all_non_empty_flattened() {
644        let args = vec![
645            serde_json::Value::String("--always".into()),
646            serde_json::Value::Array(vec![
647                serde_json::Value::String("--cookies-from-browser".into()),
648                serde_json::Value::String("chrome".into()),
649            ]),
650        ];
651        assert_eq!(
652            flatten_conditional_args(&args),
653            vec!["--always", "--cookies-from-browser", "chrome"]
654        );
655    }
656
657    #[test]
658    fn test_flatten_nested_array_any_empty_drops_group() {
659        let args = vec![
660            serde_json::Value::String("--always".into()),
661            serde_json::Value::Array(vec![
662                serde_json::Value::String("--cookies-from-browser".into()),
663                serde_json::Value::String("".into()),
664            ]),
665        ];
666        assert_eq!(flatten_conditional_args(&args), vec!["--always"]);
667    }
668
669    #[test]
670    fn test_flatten_nested_array_with_non_string_drops_group() {
671        let args = vec![serde_json::Value::Array(vec![
672            serde_json::Value::String("--flag".into()),
673            serde_json::Value::Number(serde_json::Number::from(42)),
674        ])];
675        assert!(
676            flatten_conditional_args(&args).is_empty(),
677            "Non-string element means group.len() != strings.len()"
678        );
679    }
680
681    #[test]
682    fn test_flatten_non_string_types_dropped() {
683        let args = vec![
684            serde_json::Value::String("--keep".into()),
685            serde_json::Value::Number(serde_json::Number::from(42)),
686            serde_json::Value::Bool(true),
687            serde_json::Value::Null,
688        ];
689        assert_eq!(flatten_conditional_args(&args), vec!["--keep"]);
690    }
691
692    #[test]
693    fn test_flatten_empty_array_produces_nothing() {
694        let args = vec![serde_json::Value::Array(vec![])];
695        // Empty group has all_non_empty = true (vacuously), but len == 0
696        // so nothing is actually added.
697        assert!(flatten_conditional_args(&args).is_empty());
698    }
699
700    #[test]
701    fn test_flatten_mixed_conditional_scenario() {
702        // Simulates the download-video recipe args after template resolution.
703        let args = vec![
704            serde_json::Value::String("--no-playlist".into()),
705            serde_json::Value::String("--newline".into()),
706            serde_json::Value::Array(vec![
707                serde_json::Value::String("--cookies-from-browser".into()),
708                serde_json::Value::String("chrome".into()),
709            ]),
710            serde_json::Value::Array(vec![
711                serde_json::Value::String("-S".into()),
712                serde_json::Value::String("res:720".into()),
713            ]),
714            serde_json::Value::String("".into()), // empty thumbnail → dropped
715            serde_json::Value::String("".into()), // empty sponsorblock → dropped
716        ];
717        assert_eq!(
718            flatten_conditional_args(&args),
719            vec![
720                "--no-playlist",
721                "--newline",
722                "--cookies-from-browser",
723                "chrome",
724                "-S",
725                "res:720",
726            ]
727        );
728    }
729
730    #[test]
731    fn test_flatten_all_groups_disabled() {
732        // All optional groups have empty values — only unconditional args survive.
733        let args = vec![
734            serde_json::Value::String("--no-playlist".into()),
735            serde_json::Value::Array(vec![
736                serde_json::Value::String("--cookies-from-browser".into()),
737                serde_json::Value::String("".into()),
738            ]),
739            serde_json::Value::Array(vec![
740                serde_json::Value::String("-S".into()),
741                serde_json::Value::String("".into()),
742            ]),
743            serde_json::Value::String("".into()),
744            serde_json::Value::String("".into()),
745        ];
746        assert_eq!(flatten_conditional_args(&args), vec!["--no-playlist"]);
747    }
748}