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::script;
33use crate::search_path;
34use crate::stdout_mode::process_stdout_mode;
35use crate::validate::{self, DEFAULT_MAX_OUTPUT_MB};
36
37#[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
45type Params = serde_json::Map<String, serde_json::Value>;
47
48pub 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 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 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 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 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 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
169fn 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 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
202fn 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
220fn 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
236fn 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
244fn 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
258fn 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
272fn 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 #[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 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 #[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(¶ms);
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(¶ms);
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(¶ms);
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(¶ms);
385 assert!(!errors.is_empty());
386 assert!(errors[0].contains("shell interpreter"));
387 }
388
389 #[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(¶ms);
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(¶ms);
410 assert!(
411 errors.iter().any(|e| e.contains("shell interpreter")),
412 "{errors:?}"
413 );
414 }
415
416 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 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(¶ms);
497 assert!(!errors.is_empty());
498 assert!(errors[0].contains("greater than 0"));
499 }
500
501 #[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 #[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 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 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 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 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 #[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, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms).unwrap();
845 assert_eq!(args.len(), 2);
847 assert_eq!(args[0], "https://example.com");
848 }
849
850 #[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 assert!(flatten_conditional_args(&args).is_empty());
933 }
934
935 #[test]
936 fn test_flatten_mixed_conditional_scenario() {
937 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()), serde_json::Value::String("".into()), ];
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 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 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 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 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 #[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}