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