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