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