Skip to main content

bnto_shell/
run.rs

1// ShellRun 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::{BatchFile, BatchInput, FileData, 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::command_options::command_options;
27use crate::cwd;
28use crate::each;
29use crate::file_mode::process_file_mode;
30use crate::invocation::Invocation;
31use crate::parameters::build_parameters;
32use crate::powershell;
33use crate::script;
34use crate::search_path;
35use crate::stdout_mode::process_stdout_mode;
36use crate::validate::{self, DEFAULT_MAX_OUTPUT_MB};
37
38// Items the tests below exercise directly.
39#[cfg(test)]
40use crate::file_mode::OUTPUT_DIR_PLACEHOLDER;
41#[cfg(test)]
42use crate::output_files::{collect_output_files, mime_from_extension};
43#[cfg(test)]
44use crate::stdout_mode::make_output_filename;
45
46/// JSON params of a node, as the processor receives them.
47type Params = serde_json::Map<String, serde_json::Value>;
48
49/// Shell command processor — runs external CLI tools.
50pub struct ShellRun;
51
52impl ShellRun {
53    pub fn new() -> Self {
54        Self
55    }
56}
57
58impl Default for ShellRun {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl NodeProcessor for ShellRun {
65    fn name(&self) -> &str {
66        crate::NODE_TYPE
67    }
68
69    #[expect(clippy::too_many_lines)]
70    fn validate(&self, params: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
71        let mut errors = Vec::new();
72
73        // `command` is checked whenever it's set, even beside a script, so a
74        // bad command can't hide behind a missing (or probed) script.
75        let command = params.get("command").and_then(serde_json::Value::as_str);
76        if let Some(Err(e)) = command.map(validate::validate_command) {
77            errors.push(e);
78        }
79        match (command, script::script(params)) {
80            (_, Some(_)) => errors.extend(script::script_problems(params)),
81            (None, None) => errors.push("'command' or 'script' is required".to_string()),
82            (Some(_), None) => {}
83        }
84
85        if let Err(e) = search_path::read_search_path(params.get("searchPath")) {
86            errors.push(e);
87        }
88        if let Err(e) = each::check_groups(params.get("args")) {
89            errors.push(e);
90        }
91        if let Err(e) = cwd::read_cwd(params.get("cwd")) {
92            errors.push(e);
93        }
94        if let Some(timeout) = params.get("timeout").and_then(serde_json::Value::as_u64)
95            && timeout == 0
96        {
97            errors.push("'timeout' must be greater than 0".to_string());
98        }
99
100        errors
101    }
102
103    /// Filled after the executor, by shell-run itself (SEC-27).
104    fn reserved_tokens(&self) -> &'static [&'static str] {
105        &[
106            "{{output_dir}}",
107            "{{url}}",
108            "{{input}}",
109            "{{path}}",
110            "{{file}}",
111            "{{item.",
112        ]
113    }
114
115    fn platform_problems(
116        &self,
117        params: &serde_json::Map<String, serde_json::Value>,
118        platforms: &[&str],
119    ) -> Vec<String> {
120        if script::script(params).is_none() {
121            return Vec::new();
122        }
123        let problem = powershell::platform_problem(script::shell_name(params), platforms);
124        problem.into_iter().collect()
125    }
126
127    fn validate_raw(&self, raw: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
128        crate::raw_checks::raw_problems(raw)
129    }
130
131    /// Run with no incoming files (a direct call, not through the executor).
132    fn process(
133        &self,
134        input: NodeInput,
135        progress: &ProgressReporter,
136        ctx: &dyn ProcessContext,
137    ) -> Result<NodeOutput, BntoError> {
138        run_node(&input, &[], progress, ctx)
139    }
140
141    /// Run once with every incoming file, for `each` groups in `args`. The
142    /// files never reach the command otherwise, and the output is named
143    /// after the command alone, as when the node had no input.
144    fn process_batch(
145        &self,
146        input: BatchInput,
147        progress: &ProgressReporter,
148        ctx: &dyn ProcessContext,
149    ) -> Result<NodeOutput, BntoError> {
150        let node_input = NodeInput {
151            data: FileData::Bytes(Vec::new()),
152            filename: String::new(),
153            mime_type: None,
154            params: input.params,
155        };
156        run_node(&node_input, &input.files, progress, ctx)
157    }
158
159    /// Fills `{{item.*}}` inside `each` groups itself, once per file.
160    fn resolves_items(&self) -> bool {
161        true
162    }
163
164    fn metadata(&self) -> NodeMetadata {
165        NodeMetadata {
166            node_type: crate::NODE_TYPE.to_string(),
167            name: "Run Shell Command".to_string(),
168            description: "Run external CLI tools with security validation.".to_string(),
169            category: NodeCategory::System,
170            accepts: vec![],
171            platforms: vec![
172                "cli".to_string(),
173                "server".to_string(),
174                "desktop".to_string(),
175            ],
176            parameters: build_parameters(),
177            input_cardinality: InputCardinality::Batch,
178            requires: vec![],
179        }
180    }
181}
182
183/// Validate, build and run the command `input.params` describe, with
184/// `files` for its `each` groups.
185#[expect(clippy::too_many_lines)]
186fn run_node(
187    input: &NodeInput,
188    files: &[BatchFile],
189    progress: &ProgressReporter,
190    ctx: &dyn ProcessContext,
191) -> Result<NodeOutput, BntoError> {
192    let params = &input.params;
193    let (command, program, mut args) = match script::script(params) {
194        Some(text) => script_program(params, text)?,
195        None => {
196            let command = required_command(params)?;
197            // Security check: reject shell interpreters and path-based commands
198            validate::validate_command(command).map_err(BntoError::InvalidInput)?;
199            (command, resolved_program(command, params, ctx)?, Vec::new())
200        }
201    };
202    let dir = working_dir(params, ctx)?;
203    let mut options = command_options(params, dir);
204    let values = command_args(params, files, ctx)?;
205    if script::values_in_env(params) {
206        // Never on the command line: PowerShell would read them as code.
207        options.env.extend(powershell::value_env(&values));
208    } else {
209        args.extend(values);
210    }
211    let max_output_mb = u64_param(params, "maxOutputSize", DEFAULT_MAX_OUTPUT_MB);
212    let invocation = Invocation {
213        command,
214        program: &program,
215        args: &args,
216        options,
217        clixml: script::values_in_env(params),
218    };
219    match params.get("outputMode").and_then(serde_json::Value::as_str) {
220        Some("file") => process_file_mode(&invocation, input, progress, ctx, max_output_mb),
221        _ => process_stdout_mode(&invocation, input, progress, ctx, max_output_mb),
222    }
223}
224
225/// The command's arguments: `each` groups filled per file, conditional
226/// groups flattened, then `{{url}}` / `{{input}}` resolved from the params
227/// the CLI injects for URL/Text mode recipes.
228fn command_args(
229    params: &Params,
230    files: &[BatchFile],
231    ctx: &dyn ProcessContext,
232) -> Result<Vec<String>, BntoError> {
233    if let Some(error) = each::unfilled_item(params) {
234        return Err(BntoError::InvalidInput(error));
235    }
236    let raw = params.get("args").and_then(serde_json::Value::as_array);
237    let expanded = each::expand_each(raw.map_or(&[], Vec::as_slice), files, ctx)?;
238    let mut args = flatten_conditional_args(&expanded);
239    resolve_input_placeholders(&mut args, params).map_err(BntoError::InvalidInput)?;
240    Ok(args)
241}
242
243/// A script step's shell (by absolute path) and leading args — `-c
244/// <script> bnto`, or PowerShell's `-EncodedCommand <script>` — after
245/// checking its params. A POSIX script's values follow as `$1..$N`.
246fn script_program<'a>(
247    params: &'a Params,
248    text: &str,
249) -> Result<(&'a str, String, Vec<String>), BntoError> {
250    let problems = script::script_problems(params);
251    if let Some(problem) = problems.into_iter().next() {
252        return Err(BntoError::InvalidInput(problem));
253    }
254    let shell = script::shell_name(params);
255    let (path, leading) = script::invocation(shell, text).map_err(BntoError::InvalidInput)?;
256    Ok((shell, path.to_string_lossy().into_owned(), leading))
257}
258
259/// The required `command` param.
260fn required_command(params: &Params) -> Result<&str, BntoError> {
261    params
262        .get("command")
263        .and_then(serde_json::Value::as_str)
264        .ok_or_else(|| BntoError::InvalidInput("'command' parameter is required".to_string()))
265}
266
267/// What to spawn: the command's full path from `searchPath` when set,
268/// else the bare name for PATH resolution.
269fn resolved_program(
270    command: &str,
271    params: &Params,
272    ctx: &dyn ProcessContext,
273) -> Result<String, BntoError> {
274    let folders =
275        search_path::read_search_path(params.get("searchPath")).map_err(BntoError::InvalidInput)?;
276    let found = search_path::resolve_command(command, &folders, ctx.work_dir().ok())
277        .map_err(BntoError::InvalidInput)?;
278    Ok(found.map_or_else(|| command.to_string(), |p| p.to_string_lossy().into_owned()))
279}
280
281/// The folder `cwd` names under the work dir, or `None` to run in the
282/// work dir itself.
283fn working_dir(
284    params: &Params,
285    ctx: &dyn ProcessContext,
286) -> Result<Option<std::path::PathBuf>, BntoError> {
287    let Some(folder) = cwd::read_cwd(params.get("cwd")).map_err(BntoError::InvalidInput)? else {
288        return Ok(None);
289    };
290    cwd::resolve_cwd(folder, ctx.work_dir()?)
291        .map(Some)
292        .map_err(BntoError::InvalidInput)
293}
294
295/// A numeric param, or `default` when unset.
296fn u64_param(params: &Params, key: &str, default: u64) -> u64 {
297    params
298        .get(key)
299        .and_then(serde_json::Value::as_u64)
300        .unwrap_or(default)
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use bnto_core::NoopContext;
307
308    // --- Trait basics ---
309
310    #[test]
311    fn test_processor_name() {
312        let processor = ShellRun::new();
313        assert_eq!(processor.name(), "shell-run");
314    }
315
316    #[test]
317    fn test_metadata_category_system() {
318        let processor = ShellRun::new();
319        let meta = processor.metadata();
320        assert_eq!(meta.category, NodeCategory::System);
321    }
322
323    #[test]
324    fn test_metadata_platforms_native_only() {
325        let processor = ShellRun::new();
326        let meta = processor.metadata();
327        assert_eq!(meta.platforms, vec!["cli", "server", "desktop"]);
328        assert!(!meta.platforms.contains(&"browser".to_string()));
329    }
330
331    #[test]
332    fn test_metadata_parameters_complete() {
333        let processor = ShellRun::new();
334        let meta = processor.metadata();
335        let param_names: Vec<&str> = meta.parameters.iter().map(|p| p.name.as_str()).collect();
336        assert!(param_names.contains(&"command"));
337        assert!(param_names.contains(&"args"));
338        assert!(param_names.contains(&"outputMode"));
339        assert!(param_names.contains(&"timeout"));
340        assert!(param_names.contains(&"maxOutputSize"));
341        assert!(param_names.contains(&"env"));
342    }
343
344    #[test]
345    fn test_a_step_needs_a_command_or_a_script() {
346        // Either alone is enough, so neither is marked required in metadata;
347        // validate refuses a step with neither.
348        let meta = ShellRun::new().metadata();
349        let required: Vec<&str> = meta
350            .parameters
351            .iter()
352            .filter(|p| p.constraints.as_ref().is_some_and(|c| c.required))
353            .map(|p| p.name.as_str())
354            .collect();
355        assert!(required.is_empty(), "{required:?}");
356        let errors = ShellRun::new().validate(&serde_json::Map::new());
357        assert_eq!(errors, ["'command' or 'script' is required"]);
358        let script = serde_json::json!({ "script": "true" });
359        assert!(
360            ShellRun::new()
361                .validate(script.as_object().unwrap())
362                .is_empty()
363        );
364    }
365
366    #[test]
367    fn test_metadata_no_requires() {
368        let processor = ShellRun::new();
369        let meta = processor.metadata();
370        assert!(meta.requires.is_empty(), "shell-run has no inherent deps");
371    }
372
373    // --- Validation ---
374
375    #[test]
376    fn test_validate_empty_command_fails() {
377        let processor = ShellRun::new();
378        let mut params = serde_json::Map::new();
379        params.insert("command".into(), serde_json::Value::String("".into()));
380        let errors = processor.validate(&params);
381        assert!(!errors.is_empty());
382    }
383
384    #[test]
385    fn test_validate_missing_command_fails() {
386        let processor = ShellRun::new();
387        let params = serde_json::Map::new();
388        let errors = processor.validate(&params);
389        assert!(!errors.is_empty());
390        assert!(errors[0].contains("required"));
391    }
392
393    #[test]
394    fn test_validate_present_command_passes() {
395        let processor = ShellRun::new();
396        let mut params = serde_json::Map::new();
397        params.insert("command".into(), serde_json::Value::String("echo".into()));
398        let errors = processor.validate(&params);
399        assert!(errors.is_empty());
400    }
401
402    #[test]
403    fn test_validate_shell_command_rejected() {
404        let processor = ShellRun::new();
405        let mut params = serde_json::Map::new();
406        params.insert("command".into(), serde_json::Value::String("bash".into()));
407        let errors = processor.validate(&params);
408        assert!(!errors.is_empty());
409        assert!(errors[0].contains("shell interpreter"));
410    }
411
412    // --- searchPath ---
413
414    #[test]
415    fn test_validate_refuses_a_search_path_that_is_not_folders() {
416        let mut params = serde_json::Map::new();
417        params.insert("command".into(), serde_json::json!("tool"));
418        params.insert("searchPath".into(), serde_json::json!([1]));
419        let errors = ShellRun::new().validate(&params);
420        assert_eq!(errors.len(), 1, "{errors:?}");
421        assert!(errors[0].contains("searchPath"), "{errors:?}");
422    }
423
424    #[test]
425    fn test_validate_keeps_refusing_shells_with_a_search_path() {
426        let mut params = serde_json::Map::new();
427        params.insert("command".into(), serde_json::json!("cmd"));
428        params.insert(
429            "searchPath".into(),
430            serde_json::json!(["C:/Windows/System32"]),
431        );
432        let errors = ShellRun::new().validate(&params);
433        assert!(
434            errors.iter().any(|e| e.contains("shell interpreter")),
435            "{errors:?}"
436        );
437    }
438
439    /// Records the program it was asked to run and returns "ok" as stdout.
440    struct Recording(std::sync::Mutex<Vec<String>>);
441
442    impl ProcessContext for Recording {
443        fn run_command(&self, cmd: &str, _args: &[&str]) -> Result<Vec<u8>, BntoError> {
444            self.0.lock().unwrap().push(cmd.to_string());
445            Ok(b"ok".to_vec())
446        }
447        fn temp_file(&self, s: &str) -> Result<std::path::PathBuf, BntoError> {
448            NoopContext.temp_file(s)
449        }
450        fn env_var(&self, _key: &str) -> Option<String> {
451            None
452        }
453        fn work_dir(&self) -> Result<&std::path::Path, BntoError> {
454            NoopContext.work_dir()
455        }
456    }
457
458    fn search_path_input(dir: &std::path::Path) -> NodeInput {
459        let mut params = serde_json::Map::new();
460        params.insert("command".into(), serde_json::json!("tool.bat"));
461        params.insert(
462            "searchPath".into(),
463            serde_json::json!([dir.to_str().unwrap()]),
464        );
465        NodeInput {
466            data: FileData::Bytes(vec![]),
467            filename: String::new(),
468            mime_type: None,
469            params,
470        }
471    }
472
473    #[test]
474    fn test_process_runs_the_command_found_in_search_path_by_full_path() {
475        let dir = tempfile::tempdir().unwrap();
476        std::fs::write(dir.path().join("tool.bat"), b"").unwrap();
477        let ctx = Recording(Default::default());
478        let progress = ProgressReporter::new_noop();
479        let output = ShellRun::new()
480            .process(search_path_input(dir.path()), &progress, &ctx)
481            .unwrap();
482        // Named after the bare command, never the full path it ran from.
483        assert_eq!(output.files[0].filename, "tool.bat-output");
484        assert_eq!(output.metadata["command"], "tool.bat");
485        let ran = ctx.0.lock().unwrap().clone();
486        let expected = dir.path().join("tool.bat").to_string_lossy().into_owned();
487        assert_eq!(ran, [expected]);
488    }
489
490    #[test]
491    fn test_process_fails_when_search_path_lacks_the_command() {
492        let dir = tempfile::tempdir().unwrap();
493        let ctx = Recording(Default::default());
494        let progress = ProgressReporter::new_noop();
495        let err = ShellRun::new()
496            .process(search_path_input(dir.path()), &progress, &ctx)
497            .err()
498            .expect("should fail");
499        assert!(err.to_string().contains("not found in searchPath"), "{err}");
500        assert!(ctx.0.lock().unwrap().is_empty(), "nothing should run");
501    }
502
503    #[test]
504    fn test_search_path_param_is_internal_and_listed() {
505        let meta = ShellRun::new().metadata();
506        let param = meta.parameters.iter().find(|p| p.name == "searchPath");
507        assert!(!param.expect("searchPath should exist").surfaceable);
508    }
509
510    #[test]
511    fn test_validate_zero_timeout_rejected() {
512        let processor = ShellRun::new();
513        let mut params = serde_json::Map::new();
514        params.insert("command".into(), serde_json::Value::String("echo".into()));
515        params.insert(
516            "timeout".into(),
517            serde_json::Value::Number(serde_json::Number::from(0)),
518        );
519        let errors = processor.validate(&params);
520        assert!(!errors.is_empty());
521        assert!(errors[0].contains("greater than 0"));
522    }
523
524    // --- Process ---
525
526    #[test]
527    fn test_noop_context_returns_error() {
528        let processor = ShellRun::new();
529        let progress = ProgressReporter::new_noop();
530        let input = NodeInput {
531            data: FileData::Bytes(vec![]),
532            filename: "test.txt".to_string(),
533            mime_type: None,
534            params: {
535                let mut m = serde_json::Map::new();
536                m.insert("command".into(), serde_json::Value::String("echo".into()));
537                m.insert(
538                    "args".into(),
539                    serde_json::Value::Array(vec![serde_json::Value::String("hello".into())]),
540                );
541                m
542            },
543        };
544        let result = processor.process(input, &progress, &NoopContext);
545        assert!(result.is_err());
546        let err = result.err().expect("should be error");
547        assert!(err.to_string().contains("not available in browser"));
548    }
549
550    #[test]
551    fn test_process_rejects_missing_command() {
552        let processor = ShellRun::new();
553        let progress = ProgressReporter::new_noop();
554        let input = NodeInput {
555            data: FileData::Bytes(vec![]),
556            filename: "test.txt".to_string(),
557            mime_type: None,
558            params: serde_json::Map::new(),
559        };
560        let result = processor.process(input, &progress, &NoopContext);
561        assert!(result.is_err());
562    }
563
564    #[test]
565    fn test_process_rejects_shell_command() {
566        let processor = ShellRun::new();
567        let progress = ProgressReporter::new_noop();
568        let input = NodeInput {
569            data: FileData::Bytes(vec![]),
570            filename: "test.txt".to_string(),
571            mime_type: None,
572            params: {
573                let mut m = serde_json::Map::new();
574                m.insert("command".into(), serde_json::Value::String("bash".into()));
575                m.insert(
576                    "args".into(),
577                    serde_json::Value::Array(vec![
578                        serde_json::Value::String("-c".into()),
579                        serde_json::Value::String("echo pwned".into()),
580                    ]),
581                );
582                m
583            },
584        };
585        let result = processor.process(input, &progress, &NoopContext);
586        assert!(result.is_err());
587        let err = result.err().expect("should be error");
588        assert!(err.to_string().contains("shell interpreter"));
589    }
590
591    #[test]
592    fn test_default_timeout_in_metadata() {
593        let processor = ShellRun::new();
594        let meta = processor.metadata();
595        let timeout_param = meta
596            .parameters
597            .iter()
598            .find(|p| p.name == "timeout")
599            .expect("timeout param should exist");
600        assert_eq!(
601            timeout_param.default,
602            Some(serde_json::Value::Number(serde_json::Number::from(300u64)))
603        );
604    }
605
606    #[test]
607    fn test_default_max_output_size_in_metadata() {
608        let processor = ShellRun::new();
609        let meta = processor.metadata();
610        let param = meta
611            .parameters
612            .iter()
613            .find(|p| p.name == "maxOutputSize")
614            .expect("maxOutputSize param should exist");
615        assert_eq!(
616            param.default,
617            Some(serde_json::Value::Number(serde_json::Number::from(
618                DEFAULT_MAX_OUTPUT_MB
619            )))
620        );
621        assert_eq!(param.suffix, Some("MB".to_string()));
622    }
623
624    #[test]
625    fn test_env_param_not_surfaceable() {
626        let processor = ShellRun::new();
627        let meta = processor.metadata();
628        let env_param = meta
629            .parameters
630            .iter()
631            .find(|p| p.name == "env")
632            .expect("env param should exist");
633        assert!(!env_param.surfaceable, "env should be internal-only");
634    }
635
636    #[test]
637    fn test_output_mode_default_is_stdout() {
638        let processor = ShellRun::new();
639        let meta = processor.metadata();
640        let param = meta
641            .parameters
642            .iter()
643            .find(|p| p.name == "outputMode")
644            .expect("outputMode param should exist");
645        assert_eq!(
646            param.default,
647            Some(serde_json::Value::String("stdout".to_string()))
648        );
649    }
650
651    // --- File mode helpers ---
652
653    #[test]
654    fn test_mime_from_extension_video() {
655        assert_eq!(mime_from_extension("video.mp4"), "video/mp4");
656        assert_eq!(mime_from_extension("video.webm"), "video/webm");
657        assert_eq!(mime_from_extension("video.mkv"), "video/x-matroska");
658    }
659
660    #[test]
661    fn test_mime_from_extension_audio() {
662        assert_eq!(mime_from_extension("audio.mp3"), "audio/mpeg");
663        assert_eq!(mime_from_extension("audio.m4a"), "audio/mp4");
664        assert_eq!(mime_from_extension("audio.wav"), "audio/wav");
665        assert_eq!(mime_from_extension("audio.flac"), "audio/flac");
666    }
667
668    #[test]
669    fn test_mime_from_extension_unknown() {
670        assert_eq!(mime_from_extension("file.xyz"), "application/octet-stream");
671        assert_eq!(mime_from_extension("noext"), "application/octet-stream");
672    }
673
674    #[test]
675    fn test_make_output_filename_empty_input() {
676        assert_eq!(make_output_filename("echo", ""), "echo-output");
677    }
678
679    #[test]
680    fn test_make_output_filename_with_input() {
681        assert_eq!(
682            make_output_filename("ffmpeg", "video.mp4"),
683            "video-ffmpeg-output"
684        );
685    }
686
687    #[test]
688    fn test_collect_output_files_returns_path_variant() {
689        let temp = tempfile::tempdir().unwrap();
690        let dir = temp.path().to_path_buf();
691        std::fs::write(dir.join("video.mp4"), vec![0u8; 100]).unwrap();
692        std::fs::write(dir.join("info.json"), b"{}").unwrap();
693
694        let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
695        assert_eq!(files.len(), 2);
696
697        let names: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
698        assert!(names.contains(&"video.mp4"));
699        assert!(names.contains(&"info.json"));
700
701        // Verify MIME types
702        let mp4 = files.iter().find(|f| f.filename == "video.mp4").unwrap();
703        assert_eq!(mp4.mime_type, "video/mp4");
704        let json = files.iter().find(|f| f.filename == "info.json").unwrap();
705        assert_eq!(json.mime_type, "application/json");
706
707        // Verify FileData::Path variant — files stay on disk, not read into memory.
708        for file in &files {
709            assert!(
710                matches!(&file.data, FileData::Path(_)),
711                "collect_output_files should return FileData::Path, got Bytes for {}",
712                file.filename,
713            );
714        }
715    }
716
717    #[test]
718    fn test_collect_output_files_preserves_subdirectory_in_filename() {
719        let temp = tempfile::tempdir().unwrap();
720        let dir = temp.path().to_path_buf();
721        std::fs::create_dir_all(dir.join("subdir")).unwrap();
722        std::fs::write(dir.join("file.txt"), b"data").unwrap();
723        std::fs::write(dir.join("subdir").join("video.mp4"), vec![0u8; 50]).unwrap();
724
725        let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
726        assert_eq!(files.len(), 2);
727
728        let names: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
729        assert!(names.contains(&"file.txt"));
730        assert!(names.contains(&"subdir/video.mp4"));
731    }
732
733    #[test]
734    fn test_collect_output_files_preserves_deeply_nested_path() {
735        let temp = tempfile::tempdir().unwrap();
736        let dir = temp.path().to_path_buf();
737        std::fs::create_dir_all(dir.join("a").join("b")).unwrap();
738        std::fs::write(dir.join("a").join("b").join("deep.mp4"), vec![0u8; 10]).unwrap();
739
740        let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
741        assert_eq!(files.len(), 1);
742        assert_eq!(files[0].filename, "a/b/deep.mp4");
743    }
744
745    #[test]
746    fn test_collect_output_files_multiple_subdirs_preserve_structure() {
747        let temp = tempfile::tempdir().unwrap();
748        let dir = temp.path().to_path_buf();
749        std::fs::create_dir_all(dir.join("Alpha Legion")).unwrap();
750        std::fs::create_dir_all(dir.join("Suboden Khan")).unwrap();
751        std::fs::write(dir.join("Alpha Legion").join("part1.mp4"), b"vid1").unwrap();
752        std::fs::write(dir.join("Suboden Khan").join("part2.mp4"), b"vid2").unwrap();
753
754        let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
755        assert_eq!(files.len(), 2);
756
757        let mut names: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
758        names.sort();
759        assert_eq!(
760            names,
761            vec!["Alpha Legion/part1.mp4", "Suboden Khan/part2.mp4"]
762        );
763    }
764
765    #[test]
766    fn test_collect_output_files_empty_dir() {
767        let temp = tempfile::tempdir().unwrap();
768        let dir = temp.path().to_path_buf();
769
770        let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
771        assert!(files.is_empty());
772    }
773
774    #[test]
775    fn test_collect_output_files_rejects_oversized_file() {
776        let temp = tempfile::tempdir().unwrap();
777        let dir = temp.path().to_path_buf();
778        // 2 MB file with a 1 MB limit — should fail before reading into memory.
779        std::fs::write(dir.join("big.mp4"), vec![0u8; 2 * 1024 * 1024]).unwrap();
780
781        let result = collect_output_files(&dir, 1);
782        match result {
783            Err(e) => {
784                let msg = e.to_string();
785                assert!(msg.contains("big.mp4"), "Error should name the file");
786                assert!(msg.contains("1 MB limit"), "Error should state the limit");
787            }
788            Ok(_) => panic!("Expected error for oversized file"),
789        }
790    }
791
792    #[test]
793    fn test_collect_output_files_accepts_file_under_limit() {
794        let temp = tempfile::tempdir().unwrap();
795        let dir = temp.path().to_path_buf();
796        // 512 KB file with a 1 MB limit — should succeed.
797        std::fs::write(dir.join("small.mp4"), vec![0u8; 512 * 1024]).unwrap();
798
799        let files = collect_output_files(&dir, 1).unwrap();
800        assert_eq!(files.len(), 1);
801        assert_eq!(files[0].filename, "small.mp4");
802    }
803
804    #[test]
805    fn test_output_dir_placeholder_substitution() {
806        let args = [
807            "-o".to_string(),
808            "{{output_dir}}/%(title)s.%(ext)s".to_string(),
809            "--verbose".to_string(),
810        ];
811        let dir_str = "/tmp/bnto-output";
812        let resolved: Vec<String> = args
813            .iter()
814            .map(|a| a.replace(OUTPUT_DIR_PLACEHOLDER, dir_str))
815            .collect();
816        assert_eq!(resolved[0], "-o");
817        assert_eq!(resolved[1], "/tmp/bnto-output/%(title)s.%(ext)s");
818        assert_eq!(resolved[2], "--verbose");
819    }
820
821    // --- Input placeholder resolution ---
822
823    #[test]
824    fn test_resolve_url_appended_when_no_placeholder() {
825        let mut args = vec!["--no-playlist".to_string(), "-o".to_string()];
826        let mut params = serde_json::Map::new();
827        params.insert("url".into(), "https://example.com/video".into());
828        resolve_input_placeholders(&mut args, &params).unwrap();
829        assert_eq!(args.len(), 3);
830        assert_eq!(args[2], "https://example.com/video");
831    }
832
833    #[test]
834    fn test_resolve_url_placeholder_substituted() {
835        let mut args = vec!["--download".to_string(), "{{url}}".to_string()];
836        let mut params = serde_json::Map::new();
837        params.insert("url".into(), "https://example.com/video".into());
838        resolve_input_placeholders(&mut args, &params).unwrap();
839        assert_eq!(args.len(), 2);
840        assert_eq!(args[1], "https://example.com/video");
841    }
842
843    #[test]
844    fn test_resolve_input_placeholder_substituted() {
845        let mut args = vec!["process".to_string(), "{{input}}".to_string()];
846        let mut params = serde_json::Map::new();
847        params.insert("url".into(), "https://example.com/data".into());
848        resolve_input_placeholders(&mut args, &params).unwrap();
849        assert_eq!(args.len(), 2);
850        assert_eq!(args[1], "https://example.com/data");
851    }
852
853    #[test]
854    fn test_resolve_no_url_no_change() {
855        let mut args = vec!["--help".to_string()];
856        let params = serde_json::Map::new();
857        resolve_input_placeholders(&mut args, &params).unwrap();
858        assert_eq!(args.len(), 1);
859        assert_eq!(args[0], "--help");
860    }
861
862    #[test]
863    fn test_resolve_url_not_appended_when_placeholder_exists() {
864        let mut args = vec!["{{url}}".to_string(), "--verbose".to_string()];
865        let mut params = serde_json::Map::new();
866        params.insert("url".into(), "https://example.com".into());
867        resolve_input_placeholders(&mut args, &params).unwrap();
868        // URL substituted in placeholder, NOT also appended
869        assert_eq!(args.len(), 2);
870        assert_eq!(args[0], "https://example.com");
871    }
872
873    // --- flatten_conditional_args ---
874
875    #[test]
876    fn test_flatten_flat_strings_included() {
877        let args = vec![
878            serde_json::Value::String("--no-playlist".into()),
879            serde_json::Value::String("--newline".into()),
880        ];
881        assert_eq!(
882            flatten_conditional_args(&args),
883            vec!["--no-playlist", "--newline"]
884        );
885    }
886
887    #[test]
888    fn test_flatten_empty_strings_dropped() {
889        let args = vec![
890            serde_json::Value::String("--always".into()),
891            serde_json::Value::String("".into()),
892            serde_json::Value::String("--also-always".into()),
893        ];
894        assert_eq!(
895            flatten_conditional_args(&args),
896            vec!["--always", "--also-always"]
897        );
898    }
899
900    #[test]
901    fn test_flatten_nested_array_all_non_empty_flattened() {
902        let args = vec![
903            serde_json::Value::String("--always".into()),
904            serde_json::Value::Array(vec![
905                serde_json::Value::String("--cookies-from-browser".into()),
906                serde_json::Value::String("chrome".into()),
907            ]),
908        ];
909        assert_eq!(
910            flatten_conditional_args(&args),
911            vec!["--always", "--cookies-from-browser", "chrome"]
912        );
913    }
914
915    #[test]
916    fn test_flatten_nested_array_any_empty_drops_group() {
917        let args = vec![
918            serde_json::Value::String("--always".into()),
919            serde_json::Value::Array(vec![
920                serde_json::Value::String("--cookies-from-browser".into()),
921                serde_json::Value::String("".into()),
922            ]),
923        ];
924        assert_eq!(flatten_conditional_args(&args), vec!["--always"]);
925    }
926
927    #[test]
928    fn test_flatten_nested_array_with_non_string_drops_group() {
929        let args = vec![serde_json::Value::Array(vec![
930            serde_json::Value::String("--flag".into()),
931            serde_json::Value::Number(serde_json::Number::from(42)),
932        ])];
933        assert!(
934            flatten_conditional_args(&args).is_empty(),
935            "Non-string element means group.len() != strings.len()"
936        );
937    }
938
939    #[test]
940    fn test_flatten_non_string_types_dropped() {
941        let args = vec![
942            serde_json::Value::String("--keep".into()),
943            serde_json::Value::Number(serde_json::Number::from(42)),
944            serde_json::Value::Bool(true),
945            serde_json::Value::Null,
946        ];
947        assert_eq!(flatten_conditional_args(&args), vec!["--keep"]);
948    }
949
950    #[test]
951    fn test_flatten_empty_array_produces_nothing() {
952        let args = vec![serde_json::Value::Array(vec![])];
953        // Empty group has all_non_empty = true (vacuously), but len == 0
954        // so nothing is actually added.
955        assert!(flatten_conditional_args(&args).is_empty());
956    }
957
958    #[test]
959    fn test_flatten_mixed_conditional_scenario() {
960        // Simulates the download-video recipe args after template resolution.
961        let args = vec![
962            serde_json::Value::String("--no-playlist".into()),
963            serde_json::Value::String("--newline".into()),
964            serde_json::Value::Array(vec![
965                serde_json::Value::String("--cookies-from-browser".into()),
966                serde_json::Value::String("chrome".into()),
967            ]),
968            serde_json::Value::Array(vec![
969                serde_json::Value::String("-S".into()),
970                serde_json::Value::String("res:720".into()),
971            ]),
972            serde_json::Value::String("".into()), // empty thumbnail → dropped
973            serde_json::Value::String("".into()), // empty sponsorblock → dropped
974        ];
975        assert_eq!(
976            flatten_conditional_args(&args),
977            vec![
978                "--no-playlist",
979                "--newline",
980                "--cookies-from-browser",
981                "chrome",
982                "-S",
983                "res:720",
984            ]
985        );
986    }
987
988    #[test]
989    fn test_flatten_all_groups_disabled() {
990        // All optional groups have empty values — only unconditional args survive.
991        let args = vec![
992            serde_json::Value::String("--no-playlist".into()),
993            serde_json::Value::Array(vec![
994                serde_json::Value::String("--cookies-from-browser".into()),
995                serde_json::Value::String("".into()),
996            ]),
997            serde_json::Value::Array(vec![
998                serde_json::Value::String("-S".into()),
999                serde_json::Value::String("".into()),
1000            ]),
1001            serde_json::Value::String("".into()),
1002            serde_json::Value::String("".into()),
1003        ];
1004        assert_eq!(flatten_conditional_args(&args), vec!["--no-playlist"]);
1005    }
1006
1007    /// Records the options it was asked to run a command with, in `.1`.
1008    struct RecordingOptions(
1009        std::sync::Mutex<Vec<bnto_core::CommandOptions>>,
1010        std::path::PathBuf,
1011    );
1012
1013    impl ProcessContext for RecordingOptions {
1014        fn run_command(&self, _cmd: &str, _args: &[&str]) -> Result<Vec<u8>, BntoError> {
1015            Ok(b"ok".to_vec())
1016        }
1017        fn run_command_with(
1018            &self,
1019            _cmd: &str,
1020            _args: &[&str],
1021            options: &bnto_core::CommandOptions,
1022            _on_output: &dyn Fn(&str),
1023        ) -> Result<Vec<u8>, BntoError> {
1024            self.0.lock().unwrap().push(options.clone());
1025            Ok(b"ok".to_vec())
1026        }
1027        fn temp_file(&self, s: &str) -> Result<std::path::PathBuf, BntoError> {
1028            NoopContext.temp_file(s)
1029        }
1030        fn env_var(&self, _key: &str) -> Option<String> {
1031            None
1032        }
1033        fn work_dir(&self) -> Result<&std::path::Path, BntoError> {
1034            Ok(&self.1)
1035        }
1036    }
1037
1038    fn options_input(params: serde_json::Value) -> NodeInput {
1039        NodeInput {
1040            data: FileData::Bytes(vec![]),
1041            filename: String::new(),
1042            mime_type: None,
1043            params: params.as_object().unwrap().clone(),
1044        }
1045    }
1046
1047    #[test]
1048    fn test_the_command_runs_with_the_recipes_env_and_timeout() {
1049        let ctx = RecordingOptions(Default::default(), Default::default());
1050        let input = options_input(serde_json::json!({
1051            "command": "tool", "timeout": 9, "env": { "GREETING": "hi" }
1052        }));
1053        ShellRun::new()
1054            .process(input, &ProgressReporter::new_noop(), &ctx)
1055            .unwrap();
1056        let seen = ctx.0.lock().unwrap();
1057        assert_eq!(seen.len(), 1);
1058        assert_eq!(seen[0].timeout, Some(std::time::Duration::from_secs(9)));
1059        assert_eq!(
1060            seen[0].env,
1061            vec![("GREETING".to_string(), "hi".to_string())]
1062        );
1063    }
1064
1065    // --- each groups (batch input) ---
1066
1067    /// Records the args of every command it was asked to run.
1068    struct RecordingArgs(std::sync::Mutex<Vec<Vec<String>>>);
1069
1070    impl ProcessContext for RecordingArgs {
1071        fn run_command(&self, _cmd: &str, args: &[&str]) -> Result<Vec<u8>, BntoError> {
1072            let args = args.iter().map(|a| a.to_string()).collect();
1073            self.0.lock().unwrap().push(args);
1074            Ok(b"ok".to_vec())
1075        }
1076        fn temp_file(&self, s: &str) -> Result<std::path::PathBuf, BntoError> {
1077            NoopContext.temp_file(s)
1078        }
1079        fn env_var(&self, _key: &str) -> Option<String> {
1080            None
1081        }
1082        fn work_dir(&self) -> Result<&std::path::Path, BntoError> {
1083            NoopContext.work_dir()
1084        }
1085    }
1086
1087    fn batch(params: serde_json::Value, names: &[&str]) -> BatchInput {
1088        let files = names
1089            .iter()
1090            .map(|name| BatchFile {
1091                data: FileData::Path(std::env::temp_dir().join(name)),
1092                filename: name.to_string(),
1093                mime_type: None,
1094                metadata: serde_json::Map::new(),
1095            })
1096            .collect();
1097        BatchInput {
1098            files,
1099            params: params.as_object().unwrap().clone(),
1100        }
1101    }
1102
1103    #[test]
1104    fn test_shell_command_takes_its_incoming_files_and_fills_items_itself() {
1105        let meta = ShellRun::new().metadata();
1106        assert_eq!(meta.input_cardinality, InputCardinality::Batch);
1107        assert!(ShellRun::new().resolves_items());
1108    }
1109
1110    #[test]
1111    fn test_one_command_gets_every_file_through_an_each_group() {
1112        let ctx = RecordingArgs(Default::default());
1113        let input = batch(
1114            serde_json::json!({
1115                "command": "llvm-cov",
1116                "args": ["report", { "each": ["-object={{path}}"] }, "-format=text"]
1117            }),
1118            &["a.dll", "b.dll"],
1119        );
1120        let output = ShellRun::new()
1121            .process_batch(input, &ProgressReporter::new_noop(), &ctx)
1122            .unwrap();
1123        let object = |n: &str| format!("-object={}", std::env::temp_dir().join(n).display());
1124        let ran = ctx.0.lock().unwrap().clone();
1125        assert_eq!(
1126            ran,
1127            [vec![
1128                "report".to_string(),
1129                object("a.dll"),
1130                object("b.dll"),
1131                "-format=text".to_string()
1132            ]]
1133        );
1134        // Named after the command, never after an incoming file.
1135        assert_eq!(output.files[0].filename, "llvm-cov-output");
1136    }
1137
1138    #[test]
1139    fn test_incoming_files_without_a_group_change_nothing() {
1140        let ctx = RecordingArgs(Default::default());
1141        let input = batch(
1142            serde_json::json!({ "command": "git", "args": ["--version"] }),
1143            &["a.txt"],
1144        );
1145        let output = ShellRun::new()
1146            .process_batch(input, &ProgressReporter::new_noop(), &ctx)
1147            .unwrap();
1148        assert_eq!(
1149            ctx.0.lock().unwrap().clone(),
1150            [vec!["--version".to_string()]]
1151        );
1152        assert_eq!(output.files.len(), 1, "only the command's stdout");
1153        assert_eq!(output.files[0].filename, "git-output");
1154    }
1155
1156    #[test]
1157    fn test_an_item_outside_a_group_fails_before_running() {
1158        let ctx = RecordingArgs(Default::default());
1159        let input = batch(
1160            serde_json::json!({ "command": "tool", "args": ["{{item.size}}"] }),
1161            &["a.txt"],
1162        );
1163        let err = ShellRun::new()
1164            .process_batch(input, &ProgressReporter::new_noop(), &ctx)
1165            .err()
1166            .expect("should fail");
1167        assert!(
1168            err.to_string().contains("{{item.size}} has no value"),
1169            "{err}"
1170        );
1171        assert!(ctx.0.lock().unwrap().is_empty(), "nothing should run");
1172    }
1173
1174    // --- cwd ---
1175
1176    #[test]
1177    fn test_the_command_runs_in_the_cwd_folder_under_the_work_dir() {
1178        let work = tempfile::tempdir().unwrap();
1179        std::fs::create_dir(work.path().join("Tools")).unwrap();
1180        let ctx = RecordingOptions(Default::default(), work.path().to_path_buf());
1181        let input = options_input(serde_json::json!({ "command": "tool", "cwd": "Tools" }));
1182        ShellRun::new()
1183            .process(input, &ProgressReporter::new_noop(), &ctx)
1184            .unwrap();
1185        let seen = ctx.0.lock().unwrap();
1186        assert_eq!(seen[0].dir, Some(work.path().join("Tools")));
1187    }
1188
1189    #[test]
1190    fn test_without_cwd_the_command_runs_in_the_work_dir() {
1191        let ctx = RecordingOptions(Default::default(), Default::default());
1192        let input = options_input(serde_json::json!({ "command": "tool" }));
1193        ShellRun::new()
1194            .process(input, &ProgressReporter::new_noop(), &ctx)
1195            .unwrap();
1196        assert_eq!(ctx.0.lock().unwrap()[0].dir, None);
1197    }
1198
1199    #[test]
1200    fn test_a_missing_cwd_folder_fails_before_running() {
1201        let work = tempfile::tempdir().unwrap();
1202        let ctx = RecordingOptions(Default::default(), work.path().to_path_buf());
1203        let input = options_input(serde_json::json!({ "command": "tool", "cwd": "Tools" }));
1204        let err = ShellRun::new()
1205            .process(input, &ProgressReporter::new_noop(), &ctx)
1206            .err()
1207            .expect("should fail");
1208        assert!(err.to_string().contains("'cwd' folder 'Tools'"), "{err}");
1209        assert!(ctx.0.lock().unwrap().is_empty(), "nothing should run");
1210    }
1211
1212    #[test]
1213    fn test_validate_refuses_a_malformed_each_group() {
1214        let params = serde_json::json!({ "command": "tool", "args": [{ "each": "x" }] });
1215        let errors = ShellRun::new().validate(params.as_object().unwrap());
1216        assert_eq!(errors.len(), 1, "{errors:?}");
1217        assert!(errors[0].contains("each"), "{errors:?}");
1218    }
1219
1220    #[test]
1221    fn test_validate_refuses_a_cwd_that_leaves_the_work_dir() {
1222        let params = serde_json::json!({ "command": "tool", "cwd": "../elsewhere" });
1223        let errors = ShellRun::new().validate(params.as_object().unwrap());
1224        assert_eq!(errors.len(), 1, "{errors:?}");
1225        assert!(errors[0].contains("'cwd'"), "{errors:?}");
1226    }
1227
1228    #[test]
1229    fn test_cwd_param_is_internal_and_listed() {
1230        let meta = ShellRun::new().metadata();
1231        let param = meta.parameters.iter().find(|p| p.name == "cwd");
1232        assert!(!param.expect("cwd should exist").surfaceable);
1233    }
1234}