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 fn validate(&self, params: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
70 let mut errors = Vec::new();
71
72 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 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 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 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 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
182fn 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 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 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
223fn 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
241fn 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
257fn 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
265fn 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
279fn 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
293fn 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 #[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 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 #[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(¶ms);
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(¶ms);
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(¶ms);
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(¶ms);
406 assert!(!errors.is_empty());
407 assert!(errors[0].contains("shell interpreter"));
408 }
409
410 #[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(¶ms);
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(¶ms);
431 assert!(
432 errors.iter().any(|e| e.contains("shell interpreter")),
433 "{errors:?}"
434 );
435 }
436
437 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 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(¶ms);
518 assert!(!errors.is_empty());
519 assert!(errors[0].contains("greater than 0"));
520 }
521
522 #[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 #[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 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 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 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 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 #[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, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms).unwrap();
866 assert_eq!(args.len(), 2);
868 assert_eq!(args[0], "https://example.com");
869 }
870
871 #[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 assert!(flatten_conditional_args(&args).is_empty());
954 }
955
956 #[test]
957 fn test_flatten_mixed_conditional_scenario() {
958 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()), serde_json::Value::String("".into()), ];
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 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 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 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 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 #[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}