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, ParameterDef, ParameterType};
19use bnto_core::processor::{FileData, NodeInput, NodeOutput, OutputFile};
20use bnto_core::{
21    BntoError, NodeCategory, NodeMetadata, NodeProcessor, ProcessContext, ProgressReporter,
22};
23
24use crate::validate::{self, DEFAULT_MAX_OUTPUT_MB, DEFAULT_TIMEOUT_SECS};
25
26/// Placeholder in args that gets replaced with the temp output directory path.
27const OUTPUT_DIR_PLACEHOLDER: &str = "{{output_dir}}";
28
29/// Placeholder in args for URL/text input injected by the CLI's input preparation.
30const URL_PLACEHOLDER: &str = "{{url}}";
31const INPUT_PLACEHOLDER: &str = "{{input}}";
32
33/// Shell command processor — runs external CLI tools.
34pub struct ShellCommand;
35
36impl ShellCommand {
37    pub fn new() -> Self {
38        Self
39    }
40}
41
42impl Default for ShellCommand {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl NodeProcessor for ShellCommand {
49    fn name(&self) -> &str {
50        "shell-command"
51    }
52
53    fn validate(&self, params: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
54        let mut errors = Vec::new();
55
56        match params.get("command").and_then(serde_json::Value::as_str) {
57            None => errors.push("'command' parameter is required".to_string()),
58            Some(cmd) => {
59                if let Err(e) = validate::validate_command(cmd) {
60                    errors.push(e);
61                }
62            }
63        }
64
65        if let Some(timeout) = params.get("timeout").and_then(serde_json::Value::as_u64)
66            && timeout == 0
67        {
68            errors.push("'timeout' must be greater than 0".to_string());
69        }
70
71        errors
72    }
73
74    fn process(
75        &self,
76        input: NodeInput,
77        progress: &ProgressReporter,
78        ctx: &dyn ProcessContext,
79    ) -> Result<NodeOutput, BntoError> {
80        let command = input
81            .params
82            .get("command")
83            .and_then(serde_json::Value::as_str)
84            .ok_or_else(|| {
85                BntoError::InvalidInput("'command' parameter is required".to_string())
86            })?;
87
88        let mut args: Vec<String> = input
89            .params
90            .get("args")
91            .and_then(serde_json::Value::as_array)
92            .map(|arr| flatten_conditional_args(arr))
93            .unwrap_or_default();
94
95        let _timeout = input
96            .params
97            .get("timeout")
98            .and_then(serde_json::Value::as_u64)
99            .unwrap_or(DEFAULT_TIMEOUT_SECS);
100
101        // Sanitize env vars — strip dangerous ones silently
102        let _env = input
103            .params
104            .get("env")
105            .and_then(serde_json::Value::as_object)
106            .map(validate::sanitize_env)
107            .unwrap_or_default();
108
109        // Security check: reject shell interpreters and path-based commands
110        validate::validate_command(command).map_err(BntoError::InvalidInput)?;
111
112        // Resolve {{url}} and {{input}} placeholders from injected params.
113        // The CLI injects "url" or "text" params for URL/Text mode recipes.
114        resolve_input_placeholders(&mut args, &input.params);
115
116        let max_output_mb = input
117            .params
118            .get("maxOutputSize")
119            .and_then(serde_json::Value::as_u64)
120            .unwrap_or(DEFAULT_MAX_OUTPUT_MB);
121
122        let output_mode = input
123            .params
124            .get("outputMode")
125            .and_then(serde_json::Value::as_str)
126            .unwrap_or("stdout");
127
128        match output_mode {
129            "file" => process_file_mode(command, &args, &input, progress, ctx, max_output_mb),
130            _ => process_stdout_mode(command, &args, &input, progress, ctx, max_output_mb),
131        }
132    }
133
134    fn metadata(&self) -> NodeMetadata {
135        NodeMetadata {
136            node_type: "shell-command".to_string(),
137            name: "Shell Command".to_string(),
138            description: "Execute external CLI tools with security validation.".to_string(),
139            category: NodeCategory::System,
140            accepts: vec![],
141            platforms: vec![
142                "cli".to_string(),
143                "server".to_string(),
144                "desktop".to_string(),
145            ],
146            parameters: build_parameters(),
147            input_cardinality: InputCardinality::Source,
148            requires: vec![],
149        }
150    }
151}
152
153/// Resolve `{{url}}` and `{{input}}` placeholders in args from injected params.
154///
155/// The CLI's input preparation injects `"url"` or `"text"` into the node's
156/// params for URL/Text mode recipes. If no placeholder exists in the args
157/// but a `url` param is present, append the URL as the last argument.
158fn resolve_input_placeholders(
159    args: &mut Vec<String>,
160    params: &serde_json::Map<String, serde_json::Value>,
161) {
162    let url = params
163        .get("url")
164        .and_then(serde_json::Value::as_str)
165        .unwrap_or("");
166    let text = params
167        .get("text")
168        .and_then(serde_json::Value::as_str)
169        .unwrap_or("");
170
171    // The input value is whichever was injected (url or text).
172    let input_val = if !url.is_empty() { url } else { text };
173
174    let has_url_placeholder = args.iter().any(|a| a.contains(URL_PLACEHOLDER));
175    let has_input_placeholder = args.iter().any(|a| a.contains(INPUT_PLACEHOLDER));
176
177    // Substitute placeholders in existing args.
178    for arg in args.iter_mut() {
179        if arg.contains(URL_PLACEHOLDER) && !url.is_empty() {
180            *arg = arg.replace(URL_PLACEHOLDER, url);
181        }
182        if arg.contains(INPUT_PLACEHOLDER) && !input_val.is_empty() {
183            *arg = arg.replace(INPUT_PLACEHOLDER, input_val);
184        }
185    }
186
187    // If no placeholder was found but a URL was injected, append it.
188    // This is the common case: `yt-dlp <args...> <url>`
189    if !has_url_placeholder && !has_input_placeholder && !url.is_empty() {
190        args.push(url.to_string());
191    }
192}
193
194/// Stdout mode: capture command stdout as a single output file.
195fn process_stdout_mode(
196    command: &str,
197    args: &[String],
198    input: &NodeInput,
199    progress: &ProgressReporter,
200    ctx: &dyn ProcessContext,
201    max_output_mb: u64,
202) -> Result<NodeOutput, BntoError> {
203    progress.report(10, &format!("Running {command}..."));
204
205    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
206    let output_bytes = ctx.run_command_streaming(command, &arg_refs, &|line| {
207        progress.report_output(line);
208    })?;
209
210    let limit = validate::max_output_bytes(max_output_mb);
211    if output_bytes.len() > limit {
212        return Err(BntoError::ProcessingFailed(format!(
213            "Command output exceeded {max_output_mb} MB limit"
214        )));
215    }
216
217    progress.report(100, "Done");
218
219    let output_filename = make_output_filename(command, &input.filename);
220
221    let mut metadata = serde_json::Map::new();
222    metadata.insert("command".into(), command.into());
223    metadata.insert(
224        "outputBytes".into(),
225        serde_json::Number::from(output_bytes.len()).into(),
226    );
227
228    Ok(NodeOutput {
229        files: vec![OutputFile {
230            data: FileData::Bytes(output_bytes),
231            filename: output_filename,
232            mime_type: "application/octet-stream".to_string(),
233            metadata: serde_json::Map::new(),
234        }],
235        metadata,
236    })
237}
238
239/// File mode: run command in a temp dir, collect written files as output.
240/// Replaces `{{output_dir}}` in args with the temp directory path.
241fn process_file_mode(
242    command: &str,
243    args: &[String],
244    _input: &NodeInput,
245    progress: &ProgressReporter,
246    ctx: &dyn ProcessContext,
247    max_output_mb: u64,
248) -> Result<NodeOutput, BntoError> {
249    // Create a temp directory for the command's output files.
250    let temp_dir = ctx.temp_file("-output")?;
251    let output_dir = temp_dir.with_extension("d");
252    std::fs::create_dir_all(&output_dir).map_err(|e| {
253        BntoError::ProcessingFailed(format!("Failed to create output directory: {e}"))
254    })?;
255    let dir_str = output_dir.to_string_lossy();
256
257    // Substitute {{output_dir}} placeholder in args.
258    let resolved_args: Vec<String> = args
259        .iter()
260        .map(|a| a.replace(OUTPUT_DIR_PLACEHOLDER, &dir_str))
261        .collect();
262
263    progress.report(10, &format!("Running {command}..."));
264
265    let arg_refs: Vec<&str> = resolved_args.iter().map(String::as_str).collect();
266    // Run the command with stderr streaming — stdout is captured but unused.
267    let _stdout = ctx.run_command_streaming(command, &arg_refs, &|line| {
268        progress.report_output(line);
269    })?;
270
271    progress.report(80, "Collecting output files...");
272
273    let files = collect_output_files(&output_dir, max_output_mb)?;
274
275    if files.is_empty() {
276        return Err(BntoError::ProcessingFailed(format!(
277            "Command '{command}' produced no output files in {dir_str}"
278        )));
279    }
280
281    // Don't delete temp dir — files are referenced by path (FileData::Path).
282    // Consumers move files out via write_to() / rename(). Empty temp dirs
283    // are cleaned by the OS. This avoids reading multi-GB files into memory.
284
285    progress.report(100, "Done");
286
287    let total_bytes: u64 = files.iter().map(|f| f.data.len().unwrap_or(0)).sum();
288    let mut metadata = serde_json::Map::new();
289    metadata.insert("command".into(), command.into());
290    metadata.insert("outputMode".into(), "file".into());
291    metadata.insert(
292        "outputBytes".into(),
293        serde_json::Number::from(total_bytes).into(),
294    );
295    metadata.insert(
296        "fileCount".into(),
297        serde_json::Number::from(files.len()).into(),
298    );
299
300    Ok(NodeOutput { files, metadata })
301}
302
303/// Collect all files from a directory (recursively) as OutputFile entries.
304///
305/// Commands like yt-dlp create subdirectories in the output dir, so we
306/// walk the entire tree to find all produced files. Filenames preserve
307/// relative paths from the root dir (e.g. `"subdir/video.mp4"`).
308///
309/// Returns `FileData::Path` references — files are NOT read into memory.
310/// Consumers move them to the final destination via `write_to()` (rename).
311fn collect_output_files(
312    dir: &std::path::Path,
313    max_output_mb: u64,
314) -> Result<Vec<OutputFile>, BntoError> {
315    let mut files = Vec::new();
316    collect_output_files_recursive(dir, dir, max_output_mb, &mut files)?;
317    Ok(files)
318}
319
320fn collect_output_files_recursive(
321    root_dir: &std::path::Path,
322    dir: &std::path::Path,
323    max_output_mb: u64,
324    files: &mut Vec<OutputFile>,
325) -> Result<(), BntoError> {
326    let entries = std::fs::read_dir(dir).map_err(|e| {
327        BntoError::ProcessingFailed(format!("Failed to read output directory: {e}"))
328    })?;
329
330    for entry in entries.flatten() {
331        let path = entry.path();
332        if path.is_dir() {
333            collect_output_files_recursive(root_dir, &path, max_output_mb, files)?;
334            continue;
335        }
336        if !path.is_file() {
337            continue;
338        }
339        // Preserve relative path from root so subdirectory structure is retained.
340        let filename = path
341            .strip_prefix(root_dir)
342            .map(|rel| rel.to_string_lossy().into_owned())
343            .unwrap_or_else(|_| {
344                path.file_name()
345                    .map(|n| n.to_string_lossy().into_owned())
346                    .unwrap_or_else(|| "output".to_string())
347            });
348
349        // Check file size via metadata — validates without loading into memory.
350        let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
351        let limit = validate::max_output_bytes(max_output_mb) as u64;
352        if file_size > limit {
353            return Err(BntoError::ProcessingFailed(format!(
354                "Output file '{}' is {} MB, exceeding the {} MB limit",
355                filename,
356                file_size / (1024 * 1024),
357                max_output_mb
358            )));
359        }
360
361        // Return a path reference — file stays on disk, not read into memory.
362        let mime = mime_from_extension(&filename);
363        files.push(OutputFile {
364            data: FileData::Path(path.to_path_buf()),
365            filename,
366            mime_type: mime,
367            metadata: serde_json::Map::new(),
368        });
369    }
370    Ok(())
371}
372
373/// Derive MIME type from file extension for common media formats.
374fn mime_from_extension(filename: &str) -> String {
375    let ext = filename
376        .rsplit_once('.')
377        .map(|(_, e)| e.to_lowercase())
378        .unwrap_or_default();
379
380    match ext.as_str() {
381        "mp4" => "video/mp4",
382        "webm" => "video/webm",
383        "mkv" => "video/x-matroska",
384        "mp3" => "audio/mpeg",
385        "m4a" => "audio/mp4",
386        "ogg" | "opus" => "audio/ogg",
387        "wav" => "audio/wav",
388        "flac" => "audio/flac",
389        "json" => "application/json",
390        "txt" | "log" => "text/plain",
391        _ => "application/octet-stream",
392    }
393    .to_string()
394}
395
396/// Flatten a resolved `args` array that may contain conditional groups.
397///
398/// After template resolution, the args array can contain:
399/// - **Flat string**: included if non-empty, dropped if `""`
400/// - **Nested array**: ALL elements must be non-empty strings → flattened
401///   into parent; if ANY element is `""` → entire group dropped
402/// - **Other types**: dropped (backward compat)
403///
404/// This enables conditional flag groups in recipes:
405/// ```json
406/// "args": ["--always", ["--cookies-from-browser", "{{fields.browser}}"], "{{fields.thumbnail}}"]
407/// ```
408/// When `browser=""`: `["--always"]`
409/// When `browser="chrome"`: `["--always", "--cookies-from-browser", "chrome"]`
410pub fn flatten_conditional_args(args: &[serde_json::Value]) -> Vec<String> {
411    let mut result = Vec::new();
412    for item in args {
413        match item {
414            serde_json::Value::String(s) if !s.is_empty() => {
415                result.push(s.clone());
416            }
417            serde_json::Value::Array(group) => {
418                let strings: Vec<&str> =
419                    group.iter().filter_map(serde_json::Value::as_str).collect();
420                // All elements must be present strings AND non-empty.
421                let all_non_empty =
422                    strings.len() == group.len() && strings.iter().all(|s| !s.is_empty());
423                if all_non_empty {
424                    result.extend(strings.into_iter().map(String::from));
425                }
426            }
427            _ => {}
428        }
429    }
430    result
431}
432
433/// Generate a filename for stdout mode output.
434fn make_output_filename(command: &str, input_filename: &str) -> String {
435    if input_filename.is_empty() {
436        format!("{command}-output")
437    } else {
438        let stem = input_filename
439            .rsplit_once('.')
440            .map(|(s, _)| s)
441            .unwrap_or(input_filename);
442        format!("{stem}-{command}-output")
443    }
444}
445
446fn build_parameters() -> Vec<ParameterDef> {
447    vec![
448        ParameterDef {
449            name: "command".to_string(),
450            label: "Command".to_string(),
451            description: "Binary to execute (e.g., 'ffmpeg', 'yt-dlp'). Must be on PATH."
452                .to_string(),
453            param_type: ParameterType::String,
454            default: None,
455            constraints: None,
456            placeholder: Some("ffmpeg".to_string()),
457            visible_when: None,
458            required_when: None,
459            surfaceable: true,
460            group: None,
461            suffix: None,
462            control: None,
463            accept: None,
464            presets: None,
465            inverted: None,
466        },
467        ParameterDef {
468            name: "args".to_string(),
469            label: "Arguments".to_string(),
470            description: "Command arguments as an array of strings.".to_string(),
471            param_type: ParameterType::String,
472            default: None,
473            constraints: None,
474            placeholder: None,
475            visible_when: None,
476            required_when: None,
477            surfaceable: true,
478            group: None,
479            suffix: None,
480            control: Some("tagPicker".to_string()),
481            accept: None,
482            presets: None,
483            inverted: None,
484        },
485        ParameterDef {
486            name: "outputMode".to_string(),
487            label: "Output Mode".to_string(),
488            description: "How to collect output. 'stdout' captures command output. \
489                'file' reads files written by the command to a temp directory \
490                (use {{output_dir}} in args to inject the path)."
491                .to_string(),
492            param_type: ParameterType::String,
493            default: Some(serde_json::Value::String("stdout".to_string())),
494            constraints: None,
495            placeholder: None,
496            visible_when: None,
497            required_when: None,
498            surfaceable: true,
499            group: None,
500            suffix: None,
501            control: None,
502            accept: None,
503            presets: None,
504            inverted: None,
505        },
506        ParameterDef {
507            name: "timeout".to_string(),
508            label: "Timeout".to_string(),
509            description: "Maximum execution time in seconds. Default: 300.".to_string(),
510            param_type: ParameterType::Number,
511            default: Some(serde_json::Value::Number(serde_json::Number::from(
512                DEFAULT_TIMEOUT_SECS,
513            ))),
514            constraints: None,
515            placeholder: None,
516            visible_when: None,
517            required_when: None,
518            surfaceable: true,
519            group: None,
520            suffix: Some("seconds".to_string()),
521            control: None,
522            accept: None,
523            presets: None,
524            inverted: None,
525        },
526        ParameterDef {
527            name: "maxOutputSize".to_string(),
528            label: "Max Output Size".to_string(),
529            description: "Maximum size per output file in megabytes. Default: 100 MB.".to_string(),
530            param_type: ParameterType::Number,
531            default: Some(serde_json::Value::Number(serde_json::Number::from(
532                DEFAULT_MAX_OUTPUT_MB,
533            ))),
534            constraints: None,
535            placeholder: None,
536            visible_when: None,
537            required_when: None,
538            surfaceable: true,
539            group: None,
540            suffix: Some("MB".to_string()),
541            control: None,
542            accept: None,
543            presets: None,
544            inverted: None,
545        },
546        ParameterDef {
547            name: "env".to_string(),
548            label: "Environment".to_string(),
549            description: "Additional environment variables for the command.".to_string(),
550            param_type: ParameterType::Object,
551            default: None,
552            constraints: None,
553            placeholder: None,
554            visible_when: None,
555            required_when: None,
556            surfaceable: false,
557            group: None,
558            suffix: None,
559            control: None,
560            accept: None,
561            presets: None,
562            inverted: None,
563        },
564    ]
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use bnto_core::NoopContext;
571
572    // --- Trait basics ---
573
574    #[test]
575    fn test_processor_name() {
576        let processor = ShellCommand::new();
577        assert_eq!(processor.name(), "shell-command");
578    }
579
580    #[test]
581    fn test_metadata_category_system() {
582        let processor = ShellCommand::new();
583        let meta = processor.metadata();
584        assert_eq!(meta.category, NodeCategory::System);
585    }
586
587    #[test]
588    fn test_metadata_platforms_native_only() {
589        let processor = ShellCommand::new();
590        let meta = processor.metadata();
591        assert_eq!(meta.platforms, vec!["cli", "server", "desktop"]);
592        assert!(!meta.platforms.contains(&"browser".to_string()));
593    }
594
595    #[test]
596    fn test_metadata_parameters_complete() {
597        let processor = ShellCommand::new();
598        let meta = processor.metadata();
599        let param_names: Vec<&str> = meta.parameters.iter().map(|p| p.name.as_str()).collect();
600        assert!(param_names.contains(&"command"));
601        assert!(param_names.contains(&"args"));
602        assert!(param_names.contains(&"outputMode"));
603        assert!(param_names.contains(&"timeout"));
604        assert!(param_names.contains(&"maxOutputSize"));
605        assert!(param_names.contains(&"env"));
606    }
607
608    #[test]
609    fn test_metadata_no_requires() {
610        let processor = ShellCommand::new();
611        let meta = processor.metadata();
612        assert!(
613            meta.requires.is_empty(),
614            "shell-command has no inherent deps"
615        );
616    }
617
618    // --- Validation ---
619
620    #[test]
621    fn test_validate_empty_command_fails() {
622        let processor = ShellCommand::new();
623        let mut params = serde_json::Map::new();
624        params.insert("command".into(), serde_json::Value::String("".into()));
625        let errors = processor.validate(&params);
626        assert!(!errors.is_empty());
627    }
628
629    #[test]
630    fn test_validate_missing_command_fails() {
631        let processor = ShellCommand::new();
632        let params = serde_json::Map::new();
633        let errors = processor.validate(&params);
634        assert!(!errors.is_empty());
635        assert!(errors[0].contains("required"));
636    }
637
638    #[test]
639    fn test_validate_present_command_passes() {
640        let processor = ShellCommand::new();
641        let mut params = serde_json::Map::new();
642        params.insert("command".into(), serde_json::Value::String("echo".into()));
643        let errors = processor.validate(&params);
644        assert!(errors.is_empty());
645    }
646
647    #[test]
648    fn test_validate_shell_command_rejected() {
649        let processor = ShellCommand::new();
650        let mut params = serde_json::Map::new();
651        params.insert("command".into(), serde_json::Value::String("bash".into()));
652        let errors = processor.validate(&params);
653        assert!(!errors.is_empty());
654        assert!(errors[0].contains("shell interpreter"));
655    }
656
657    #[test]
658    fn test_validate_zero_timeout_rejected() {
659        let processor = ShellCommand::new();
660        let mut params = serde_json::Map::new();
661        params.insert("command".into(), serde_json::Value::String("echo".into()));
662        params.insert(
663            "timeout".into(),
664            serde_json::Value::Number(serde_json::Number::from(0)),
665        );
666        let errors = processor.validate(&params);
667        assert!(!errors.is_empty());
668        assert!(errors[0].contains("greater than 0"));
669    }
670
671    // --- Process ---
672
673    #[test]
674    fn test_noop_context_returns_error() {
675        let processor = ShellCommand::new();
676        let progress = ProgressReporter::new_noop();
677        let input = NodeInput {
678            data: FileData::Bytes(vec![]),
679            filename: "test.txt".to_string(),
680            mime_type: None,
681            params: {
682                let mut m = serde_json::Map::new();
683                m.insert("command".into(), serde_json::Value::String("echo".into()));
684                m.insert(
685                    "args".into(),
686                    serde_json::Value::Array(vec![serde_json::Value::String("hello".into())]),
687                );
688                m
689            },
690        };
691        let result = processor.process(input, &progress, &NoopContext);
692        assert!(result.is_err());
693        let err = result.err().expect("should be error");
694        assert!(err.to_string().contains("not available in browser"));
695    }
696
697    #[test]
698    fn test_process_rejects_missing_command() {
699        let processor = ShellCommand::new();
700        let progress = ProgressReporter::new_noop();
701        let input = NodeInput {
702            data: FileData::Bytes(vec![]),
703            filename: "test.txt".to_string(),
704            mime_type: None,
705            params: serde_json::Map::new(),
706        };
707        let result = processor.process(input, &progress, &NoopContext);
708        assert!(result.is_err());
709    }
710
711    #[test]
712    fn test_process_rejects_shell_command() {
713        let processor = ShellCommand::new();
714        let progress = ProgressReporter::new_noop();
715        let input = NodeInput {
716            data: FileData::Bytes(vec![]),
717            filename: "test.txt".to_string(),
718            mime_type: None,
719            params: {
720                let mut m = serde_json::Map::new();
721                m.insert("command".into(), serde_json::Value::String("bash".into()));
722                m.insert(
723                    "args".into(),
724                    serde_json::Value::Array(vec![
725                        serde_json::Value::String("-c".into()),
726                        serde_json::Value::String("echo pwned".into()),
727                    ]),
728                );
729                m
730            },
731        };
732        let result = processor.process(input, &progress, &NoopContext);
733        assert!(result.is_err());
734        let err = result.err().expect("should be error");
735        assert!(err.to_string().contains("shell interpreter"));
736    }
737
738    #[test]
739    fn test_default_timeout_in_metadata() {
740        let processor = ShellCommand::new();
741        let meta = processor.metadata();
742        let timeout_param = meta
743            .parameters
744            .iter()
745            .find(|p| p.name == "timeout")
746            .expect("timeout param should exist");
747        assert_eq!(
748            timeout_param.default,
749            Some(serde_json::Value::Number(serde_json::Number::from(300u64)))
750        );
751    }
752
753    #[test]
754    fn test_default_max_output_size_in_metadata() {
755        let processor = ShellCommand::new();
756        let meta = processor.metadata();
757        let param = meta
758            .parameters
759            .iter()
760            .find(|p| p.name == "maxOutputSize")
761            .expect("maxOutputSize param should exist");
762        assert_eq!(
763            param.default,
764            Some(serde_json::Value::Number(serde_json::Number::from(
765                DEFAULT_MAX_OUTPUT_MB
766            )))
767        );
768        assert_eq!(param.suffix, Some("MB".to_string()));
769    }
770
771    #[test]
772    fn test_env_param_not_surfaceable() {
773        let processor = ShellCommand::new();
774        let meta = processor.metadata();
775        let env_param = meta
776            .parameters
777            .iter()
778            .find(|p| p.name == "env")
779            .expect("env param should exist");
780        assert!(!env_param.surfaceable, "env should be internal-only");
781    }
782
783    #[test]
784    fn test_output_mode_default_is_stdout() {
785        let processor = ShellCommand::new();
786        let meta = processor.metadata();
787        let param = meta
788            .parameters
789            .iter()
790            .find(|p| p.name == "outputMode")
791            .expect("outputMode param should exist");
792        assert_eq!(
793            param.default,
794            Some(serde_json::Value::String("stdout".to_string()))
795        );
796    }
797
798    // --- File mode helpers ---
799
800    #[test]
801    fn test_mime_from_extension_video() {
802        assert_eq!(mime_from_extension("video.mp4"), "video/mp4");
803        assert_eq!(mime_from_extension("video.webm"), "video/webm");
804        assert_eq!(mime_from_extension("video.mkv"), "video/x-matroska");
805    }
806
807    #[test]
808    fn test_mime_from_extension_audio() {
809        assert_eq!(mime_from_extension("audio.mp3"), "audio/mpeg");
810        assert_eq!(mime_from_extension("audio.m4a"), "audio/mp4");
811        assert_eq!(mime_from_extension("audio.wav"), "audio/wav");
812        assert_eq!(mime_from_extension("audio.flac"), "audio/flac");
813    }
814
815    #[test]
816    fn test_mime_from_extension_unknown() {
817        assert_eq!(mime_from_extension("file.xyz"), "application/octet-stream");
818        assert_eq!(mime_from_extension("noext"), "application/octet-stream");
819    }
820
821    #[test]
822    fn test_make_output_filename_empty_input() {
823        assert_eq!(make_output_filename("echo", ""), "echo-output");
824    }
825
826    #[test]
827    fn test_make_output_filename_with_input() {
828        assert_eq!(
829            make_output_filename("ffmpeg", "video.mp4"),
830            "video-ffmpeg-output"
831        );
832    }
833
834    #[test]
835    fn test_collect_output_files_returns_path_variant() {
836        let dir = std::env::temp_dir().join("bnto-test-collect-output");
837        let _ = std::fs::remove_dir_all(&dir);
838        std::fs::create_dir_all(&dir).unwrap();
839        std::fs::write(dir.join("video.mp4"), vec![0u8; 100]).unwrap();
840        std::fs::write(dir.join("info.json"), b"{}").unwrap();
841
842        let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
843        assert_eq!(files.len(), 2);
844
845        let names: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
846        assert!(names.contains(&"video.mp4"));
847        assert!(names.contains(&"info.json"));
848
849        // Verify MIME types
850        let mp4 = files.iter().find(|f| f.filename == "video.mp4").unwrap();
851        assert_eq!(mp4.mime_type, "video/mp4");
852        let json = files.iter().find(|f| f.filename == "info.json").unwrap();
853        assert_eq!(json.mime_type, "application/json");
854
855        // Verify FileData::Path variant — files stay on disk, not read into memory.
856        for file in &files {
857            assert!(
858                matches!(&file.data, FileData::Path(_)),
859                "collect_output_files should return FileData::Path, got Bytes for {}",
860                file.filename,
861            );
862        }
863
864        let _ = std::fs::remove_dir_all(&dir);
865    }
866
867    #[test]
868    fn test_collect_output_files_preserves_subdirectory_in_filename() {
869        let dir = std::env::temp_dir().join("bnto-test-collect-subdirs");
870        let _ = std::fs::remove_dir_all(&dir);
871        std::fs::create_dir_all(dir.join("subdir")).unwrap();
872        std::fs::write(dir.join("file.txt"), b"data").unwrap();
873        std::fs::write(dir.join("subdir").join("video.mp4"), vec![0u8; 50]).unwrap();
874
875        let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
876        assert_eq!(files.len(), 2);
877
878        let names: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
879        assert!(names.contains(&"file.txt"));
880        assert!(names.contains(&"subdir/video.mp4"));
881
882        let _ = std::fs::remove_dir_all(&dir);
883    }
884
885    #[test]
886    fn test_collect_output_files_preserves_deeply_nested_path() {
887        let dir = std::env::temp_dir().join("bnto-test-collect-deep");
888        let _ = std::fs::remove_dir_all(&dir);
889        std::fs::create_dir_all(dir.join("a").join("b")).unwrap();
890        std::fs::write(dir.join("a").join("b").join("deep.mp4"), vec![0u8; 10]).unwrap();
891
892        let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
893        assert_eq!(files.len(), 1);
894        assert_eq!(files[0].filename, "a/b/deep.mp4");
895
896        let _ = std::fs::remove_dir_all(&dir);
897    }
898
899    #[test]
900    fn test_collect_output_files_multiple_subdirs_preserve_structure() {
901        let dir = std::env::temp_dir().join("bnto-test-collect-multi-subdirs");
902        let _ = std::fs::remove_dir_all(&dir);
903        std::fs::create_dir_all(dir.join("Alpha Legion")).unwrap();
904        std::fs::create_dir_all(dir.join("Suboden Khan")).unwrap();
905        std::fs::write(dir.join("Alpha Legion").join("part1.mp4"), b"vid1").unwrap();
906        std::fs::write(dir.join("Suboden Khan").join("part2.mp4"), b"vid2").unwrap();
907
908        let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
909        assert_eq!(files.len(), 2);
910
911        let mut names: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
912        names.sort();
913        assert_eq!(
914            names,
915            vec!["Alpha Legion/part1.mp4", "Suboden Khan/part2.mp4"]
916        );
917
918        let _ = std::fs::remove_dir_all(&dir);
919    }
920
921    #[test]
922    fn test_collect_output_files_empty_dir() {
923        let dir = std::env::temp_dir().join("bnto-test-collect-empty");
924        let _ = std::fs::remove_dir_all(&dir);
925        std::fs::create_dir_all(&dir).unwrap();
926
927        let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
928        assert!(files.is_empty());
929
930        let _ = std::fs::remove_dir_all(&dir);
931    }
932
933    #[test]
934    fn test_collect_output_files_rejects_oversized_file() {
935        let dir = std::env::temp_dir().join("bnto-test-collect-oversized");
936        let _ = std::fs::remove_dir_all(&dir);
937        std::fs::create_dir_all(&dir).unwrap();
938        // 2 MB file with a 1 MB limit — should fail before reading into memory.
939        std::fs::write(dir.join("big.mp4"), vec![0u8; 2 * 1024 * 1024]).unwrap();
940
941        let result = collect_output_files(&dir, 1);
942        match result {
943            Err(e) => {
944                let msg = e.to_string();
945                assert!(msg.contains("big.mp4"), "Error should name the file");
946                assert!(msg.contains("1 MB limit"), "Error should state the limit");
947            }
948            Ok(_) => panic!("Expected error for oversized file"),
949        }
950
951        let _ = std::fs::remove_dir_all(&dir);
952    }
953
954    #[test]
955    fn test_collect_output_files_accepts_file_under_limit() {
956        let dir = std::env::temp_dir().join("bnto-test-collect-under-limit");
957        let _ = std::fs::remove_dir_all(&dir);
958        std::fs::create_dir_all(&dir).unwrap();
959        // 512 KB file with a 1 MB limit — should succeed.
960        std::fs::write(dir.join("small.mp4"), vec![0u8; 512 * 1024]).unwrap();
961
962        let files = collect_output_files(&dir, 1).unwrap();
963        assert_eq!(files.len(), 1);
964        assert_eq!(files[0].filename, "small.mp4");
965
966        let _ = std::fs::remove_dir_all(&dir);
967    }
968
969    #[test]
970    fn test_output_dir_placeholder_substitution() {
971        let args = [
972            "-o".to_string(),
973            "{{output_dir}}/%(title)s.%(ext)s".to_string(),
974            "--verbose".to_string(),
975        ];
976        let dir_str = "/tmp/bnto-output";
977        let resolved: Vec<String> = args
978            .iter()
979            .map(|a| a.replace(OUTPUT_DIR_PLACEHOLDER, dir_str))
980            .collect();
981        assert_eq!(resolved[0], "-o");
982        assert_eq!(resolved[1], "/tmp/bnto-output/%(title)s.%(ext)s");
983        assert_eq!(resolved[2], "--verbose");
984    }
985
986    // --- Input placeholder resolution ---
987
988    #[test]
989    fn test_resolve_url_appended_when_no_placeholder() {
990        let mut args = vec!["--no-playlist".to_string(), "-o".to_string()];
991        let mut params = serde_json::Map::new();
992        params.insert("url".into(), "https://example.com/video".into());
993        resolve_input_placeholders(&mut args, &params);
994        assert_eq!(args.len(), 3);
995        assert_eq!(args[2], "https://example.com/video");
996    }
997
998    #[test]
999    fn test_resolve_url_placeholder_substituted() {
1000        let mut args = vec!["--download".to_string(), "{{url}}".to_string()];
1001        let mut params = serde_json::Map::new();
1002        params.insert("url".into(), "https://example.com/video".into());
1003        resolve_input_placeholders(&mut args, &params);
1004        assert_eq!(args.len(), 2);
1005        assert_eq!(args[1], "https://example.com/video");
1006    }
1007
1008    #[test]
1009    fn test_resolve_input_placeholder_substituted() {
1010        let mut args = vec!["process".to_string(), "{{input}}".to_string()];
1011        let mut params = serde_json::Map::new();
1012        params.insert("url".into(), "https://example.com/data".into());
1013        resolve_input_placeholders(&mut args, &params);
1014        assert_eq!(args.len(), 2);
1015        assert_eq!(args[1], "https://example.com/data");
1016    }
1017
1018    #[test]
1019    fn test_resolve_no_url_no_change() {
1020        let mut args = vec!["--help".to_string()];
1021        let params = serde_json::Map::new();
1022        resolve_input_placeholders(&mut args, &params);
1023        assert_eq!(args.len(), 1);
1024        assert_eq!(args[0], "--help");
1025    }
1026
1027    #[test]
1028    fn test_resolve_url_not_appended_when_placeholder_exists() {
1029        let mut args = vec!["{{url}}".to_string(), "--verbose".to_string()];
1030        let mut params = serde_json::Map::new();
1031        params.insert("url".into(), "https://example.com".into());
1032        resolve_input_placeholders(&mut args, &params);
1033        // URL substituted in placeholder, NOT also appended
1034        assert_eq!(args.len(), 2);
1035        assert_eq!(args[0], "https://example.com");
1036    }
1037
1038    // --- flatten_conditional_args ---
1039
1040    #[test]
1041    fn test_flatten_flat_strings_included() {
1042        let args = vec![
1043            serde_json::Value::String("--no-playlist".into()),
1044            serde_json::Value::String("--newline".into()),
1045        ];
1046        assert_eq!(
1047            flatten_conditional_args(&args),
1048            vec!["--no-playlist", "--newline"]
1049        );
1050    }
1051
1052    #[test]
1053    fn test_flatten_empty_strings_dropped() {
1054        let args = vec![
1055            serde_json::Value::String("--always".into()),
1056            serde_json::Value::String("".into()),
1057            serde_json::Value::String("--also-always".into()),
1058        ];
1059        assert_eq!(
1060            flatten_conditional_args(&args),
1061            vec!["--always", "--also-always"]
1062        );
1063    }
1064
1065    #[test]
1066    fn test_flatten_nested_array_all_non_empty_flattened() {
1067        let args = vec![
1068            serde_json::Value::String("--always".into()),
1069            serde_json::Value::Array(vec![
1070                serde_json::Value::String("--cookies-from-browser".into()),
1071                serde_json::Value::String("chrome".into()),
1072            ]),
1073        ];
1074        assert_eq!(
1075            flatten_conditional_args(&args),
1076            vec!["--always", "--cookies-from-browser", "chrome"]
1077        );
1078    }
1079
1080    #[test]
1081    fn test_flatten_nested_array_any_empty_drops_group() {
1082        let args = vec![
1083            serde_json::Value::String("--always".into()),
1084            serde_json::Value::Array(vec![
1085                serde_json::Value::String("--cookies-from-browser".into()),
1086                serde_json::Value::String("".into()),
1087            ]),
1088        ];
1089        assert_eq!(flatten_conditional_args(&args), vec!["--always"]);
1090    }
1091
1092    #[test]
1093    fn test_flatten_nested_array_with_non_string_drops_group() {
1094        let args = vec![serde_json::Value::Array(vec![
1095            serde_json::Value::String("--flag".into()),
1096            serde_json::Value::Number(serde_json::Number::from(42)),
1097        ])];
1098        assert!(
1099            flatten_conditional_args(&args).is_empty(),
1100            "Non-string element means group.len() != strings.len()"
1101        );
1102    }
1103
1104    #[test]
1105    fn test_flatten_non_string_types_dropped() {
1106        let args = vec![
1107            serde_json::Value::String("--keep".into()),
1108            serde_json::Value::Number(serde_json::Number::from(42)),
1109            serde_json::Value::Bool(true),
1110            serde_json::Value::Null,
1111        ];
1112        assert_eq!(flatten_conditional_args(&args), vec!["--keep"]);
1113    }
1114
1115    #[test]
1116    fn test_flatten_empty_array_produces_nothing() {
1117        let args = vec![serde_json::Value::Array(vec![])];
1118        // Empty group has all_non_empty = true (vacuously), but len == 0
1119        // so nothing is actually added.
1120        assert!(flatten_conditional_args(&args).is_empty());
1121    }
1122
1123    #[test]
1124    fn test_flatten_mixed_conditional_scenario() {
1125        // Simulates the download-video recipe args after template resolution.
1126        let args = vec![
1127            serde_json::Value::String("--no-playlist".into()),
1128            serde_json::Value::String("--newline".into()),
1129            serde_json::Value::Array(vec![
1130                serde_json::Value::String("--cookies-from-browser".into()),
1131                serde_json::Value::String("chrome".into()),
1132            ]),
1133            serde_json::Value::Array(vec![
1134                serde_json::Value::String("-S".into()),
1135                serde_json::Value::String("res:720".into()),
1136            ]),
1137            serde_json::Value::String("".into()), // empty thumbnail → dropped
1138            serde_json::Value::String("".into()), // empty sponsorblock → dropped
1139        ];
1140        assert_eq!(
1141            flatten_conditional_args(&args),
1142            vec![
1143                "--no-playlist",
1144                "--newline",
1145                "--cookies-from-browser",
1146                "chrome",
1147                "-S",
1148                "res:720",
1149            ]
1150        );
1151    }
1152
1153    #[test]
1154    fn test_flatten_all_groups_disabled() {
1155        // All optional groups have empty values — only unconditional args survive.
1156        let args = vec![
1157            serde_json::Value::String("--no-playlist".into()),
1158            serde_json::Value::Array(vec![
1159                serde_json::Value::String("--cookies-from-browser".into()),
1160                serde_json::Value::String("".into()),
1161            ]),
1162            serde_json::Value::Array(vec![
1163                serde_json::Value::String("-S".into()),
1164                serde_json::Value::String("".into()),
1165            ]),
1166            serde_json::Value::String("".into()),
1167            serde_json::Value::String("".into()),
1168        ];
1169        assert_eq!(flatten_conditional_args(&args), vec!["--no-playlist"]);
1170    }
1171}