1use bnto_core::metadata::{InputCardinality, ParameterDef, ParameterType};
19use bnto_core::processor::{FileData, NodeInput, NodeOutput, OutputFile};
20use bnto_core::{
21 BntoError, NodeCategory, NodeMetadata, NodeProcessor, ProcessContext, ProgressReporter,
22};
23
24use crate::validate::{self, DEFAULT_MAX_OUTPUT_MB, DEFAULT_TIMEOUT_SECS};
25
26const OUTPUT_DIR_PLACEHOLDER: &str = "{{output_dir}}";
28
29const URL_PLACEHOLDER: &str = "{{url}}";
31const INPUT_PLACEHOLDER: &str = "{{input}}";
32
33pub struct ShellCommand;
35
36impl ShellCommand {
37 pub fn new() -> Self {
38 Self
39 }
40}
41
42impl Default for ShellCommand {
43 fn default() -> Self {
44 Self::new()
45 }
46}
47
48impl NodeProcessor for ShellCommand {
49 fn name(&self) -> &str {
50 "shell-command"
51 }
52
53 fn validate(&self, params: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
54 let mut errors = Vec::new();
55
56 match params.get("command").and_then(serde_json::Value::as_str) {
57 None => errors.push("'command' parameter is required".to_string()),
58 Some(cmd) => {
59 if let Err(e) = validate::validate_command(cmd) {
60 errors.push(e);
61 }
62 }
63 }
64
65 if let Some(timeout) = params.get("timeout").and_then(serde_json::Value::as_u64)
66 && timeout == 0
67 {
68 errors.push("'timeout' must be greater than 0".to_string());
69 }
70
71 errors
72 }
73
74 fn process(
75 &self,
76 input: NodeInput,
77 progress: &ProgressReporter,
78 ctx: &dyn ProcessContext,
79 ) -> Result<NodeOutput, BntoError> {
80 let command = input
81 .params
82 .get("command")
83 .and_then(serde_json::Value::as_str)
84 .ok_or_else(|| {
85 BntoError::InvalidInput("'command' parameter is required".to_string())
86 })?;
87
88 let mut args: Vec<String> = input
89 .params
90 .get("args")
91 .and_then(serde_json::Value::as_array)
92 .map(|arr| flatten_conditional_args(arr))
93 .unwrap_or_default();
94
95 let _timeout = input
96 .params
97 .get("timeout")
98 .and_then(serde_json::Value::as_u64)
99 .unwrap_or(DEFAULT_TIMEOUT_SECS);
100
101 let _env = input
103 .params
104 .get("env")
105 .and_then(serde_json::Value::as_object)
106 .map(validate::sanitize_env)
107 .unwrap_or_default();
108
109 validate::validate_command(command).map_err(BntoError::InvalidInput)?;
111
112 resolve_input_placeholders(&mut args, &input.params);
115
116 let max_output_mb = input
117 .params
118 .get("maxOutputSize")
119 .and_then(serde_json::Value::as_u64)
120 .unwrap_or(DEFAULT_MAX_OUTPUT_MB);
121
122 let output_mode = input
123 .params
124 .get("outputMode")
125 .and_then(serde_json::Value::as_str)
126 .unwrap_or("stdout");
127
128 match output_mode {
129 "file" => process_file_mode(command, &args, &input, progress, ctx, max_output_mb),
130 _ => process_stdout_mode(command, &args, &input, progress, ctx, max_output_mb),
131 }
132 }
133
134 fn metadata(&self) -> NodeMetadata {
135 NodeMetadata {
136 node_type: "shell-command".to_string(),
137 name: "Shell Command".to_string(),
138 description: "Execute external CLI tools with security validation.".to_string(),
139 category: NodeCategory::System,
140 accepts: vec![],
141 platforms: vec![
142 "cli".to_string(),
143 "server".to_string(),
144 "desktop".to_string(),
145 ],
146 parameters: build_parameters(),
147 input_cardinality: InputCardinality::Source,
148 requires: vec![],
149 }
150 }
151}
152
153fn resolve_input_placeholders(
159 args: &mut Vec<String>,
160 params: &serde_json::Map<String, serde_json::Value>,
161) {
162 let url = params
163 .get("url")
164 .and_then(serde_json::Value::as_str)
165 .unwrap_or("");
166 let text = params
167 .get("text")
168 .and_then(serde_json::Value::as_str)
169 .unwrap_or("");
170
171 let input_val = if !url.is_empty() { url } else { text };
173
174 let has_url_placeholder = args.iter().any(|a| a.contains(URL_PLACEHOLDER));
175 let has_input_placeholder = args.iter().any(|a| a.contains(INPUT_PLACEHOLDER));
176
177 for arg in args.iter_mut() {
179 if arg.contains(URL_PLACEHOLDER) && !url.is_empty() {
180 *arg = arg.replace(URL_PLACEHOLDER, url);
181 }
182 if arg.contains(INPUT_PLACEHOLDER) && !input_val.is_empty() {
183 *arg = arg.replace(INPUT_PLACEHOLDER, input_val);
184 }
185 }
186
187 if !has_url_placeholder && !has_input_placeholder && !url.is_empty() {
190 args.push(url.to_string());
191 }
192}
193
194fn process_stdout_mode(
196 command: &str,
197 args: &[String],
198 input: &NodeInput,
199 progress: &ProgressReporter,
200 ctx: &dyn ProcessContext,
201 max_output_mb: u64,
202) -> Result<NodeOutput, BntoError> {
203 progress.report(10, &format!("Running {command}..."));
204
205 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
206 let output_bytes = ctx.run_command_streaming(command, &arg_refs, &|line| {
207 progress.report_output(line);
208 })?;
209
210 let limit = validate::max_output_bytes(max_output_mb);
211 if output_bytes.len() > limit {
212 return Err(BntoError::ProcessingFailed(format!(
213 "Command output exceeded {max_output_mb} MB limit"
214 )));
215 }
216
217 progress.report(100, "Done");
218
219 let output_filename = make_output_filename(command, &input.filename);
220
221 let mut metadata = serde_json::Map::new();
222 metadata.insert("command".into(), command.into());
223 metadata.insert(
224 "outputBytes".into(),
225 serde_json::Number::from(output_bytes.len()).into(),
226 );
227
228 Ok(NodeOutput {
229 files: vec![OutputFile {
230 data: FileData::Bytes(output_bytes),
231 filename: output_filename,
232 mime_type: "application/octet-stream".to_string(),
233 metadata: serde_json::Map::new(),
234 }],
235 metadata,
236 })
237}
238
239fn process_file_mode(
242 command: &str,
243 args: &[String],
244 _input: &NodeInput,
245 progress: &ProgressReporter,
246 ctx: &dyn ProcessContext,
247 max_output_mb: u64,
248) -> Result<NodeOutput, BntoError> {
249 let temp_dir = ctx.temp_file("-output")?;
251 let output_dir = temp_dir.with_extension("d");
252 std::fs::create_dir_all(&output_dir).map_err(|e| {
253 BntoError::ProcessingFailed(format!("Failed to create output directory: {e}"))
254 })?;
255 let dir_str = output_dir.to_string_lossy();
256
257 let resolved_args: Vec<String> = args
259 .iter()
260 .map(|a| a.replace(OUTPUT_DIR_PLACEHOLDER, &dir_str))
261 .collect();
262
263 progress.report(10, &format!("Running {command}..."));
264
265 let arg_refs: Vec<&str> = resolved_args.iter().map(String::as_str).collect();
266 let _stdout = ctx.run_command_streaming(command, &arg_refs, &|line| {
268 progress.report_output(line);
269 })?;
270
271 progress.report(80, "Collecting output files...");
272
273 let files = collect_output_files(&output_dir, max_output_mb)?;
274
275 if files.is_empty() {
276 return Err(BntoError::ProcessingFailed(format!(
277 "Command '{command}' produced no output files in {dir_str}"
278 )));
279 }
280
281 progress.report(100, "Done");
286
287 let total_bytes: u64 = files.iter().map(|f| f.data.len().unwrap_or(0)).sum();
288 let mut metadata = serde_json::Map::new();
289 metadata.insert("command".into(), command.into());
290 metadata.insert("outputMode".into(), "file".into());
291 metadata.insert(
292 "outputBytes".into(),
293 serde_json::Number::from(total_bytes).into(),
294 );
295 metadata.insert(
296 "fileCount".into(),
297 serde_json::Number::from(files.len()).into(),
298 );
299
300 Ok(NodeOutput { files, metadata })
301}
302
303fn collect_output_files(
312 dir: &std::path::Path,
313 max_output_mb: u64,
314) -> Result<Vec<OutputFile>, BntoError> {
315 let mut files = Vec::new();
316 collect_output_files_recursive(dir, dir, max_output_mb, &mut files)?;
317 Ok(files)
318}
319
320fn collect_output_files_recursive(
321 root_dir: &std::path::Path,
322 dir: &std::path::Path,
323 max_output_mb: u64,
324 files: &mut Vec<OutputFile>,
325) -> Result<(), BntoError> {
326 let entries = std::fs::read_dir(dir).map_err(|e| {
327 BntoError::ProcessingFailed(format!("Failed to read output directory: {e}"))
328 })?;
329
330 for entry in entries.flatten() {
331 let path = entry.path();
332 if path.is_dir() {
333 collect_output_files_recursive(root_dir, &path, max_output_mb, files)?;
334 continue;
335 }
336 if !path.is_file() {
337 continue;
338 }
339 let filename = path
341 .strip_prefix(root_dir)
342 .map(|rel| rel.to_string_lossy().into_owned())
343 .unwrap_or_else(|_| {
344 path.file_name()
345 .map(|n| n.to_string_lossy().into_owned())
346 .unwrap_or_else(|| "output".to_string())
347 });
348
349 let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
351 let limit = validate::max_output_bytes(max_output_mb) as u64;
352 if file_size > limit {
353 return Err(BntoError::ProcessingFailed(format!(
354 "Output file '{}' is {} MB, exceeding the {} MB limit",
355 filename,
356 file_size / (1024 * 1024),
357 max_output_mb
358 )));
359 }
360
361 let mime = mime_from_extension(&filename);
363 files.push(OutputFile {
364 data: FileData::Path(path.to_path_buf()),
365 filename,
366 mime_type: mime,
367 metadata: serde_json::Map::new(),
368 });
369 }
370 Ok(())
371}
372
373fn mime_from_extension(filename: &str) -> String {
375 let ext = filename
376 .rsplit_once('.')
377 .map(|(_, e)| e.to_lowercase())
378 .unwrap_or_default();
379
380 match ext.as_str() {
381 "mp4" => "video/mp4",
382 "webm" => "video/webm",
383 "mkv" => "video/x-matroska",
384 "mp3" => "audio/mpeg",
385 "m4a" => "audio/mp4",
386 "ogg" | "opus" => "audio/ogg",
387 "wav" => "audio/wav",
388 "flac" => "audio/flac",
389 "json" => "application/json",
390 "txt" | "log" => "text/plain",
391 _ => "application/octet-stream",
392 }
393 .to_string()
394}
395
396pub fn flatten_conditional_args(args: &[serde_json::Value]) -> Vec<String> {
411 let mut result = Vec::new();
412 for item in args {
413 match item {
414 serde_json::Value::String(s) if !s.is_empty() => {
415 result.push(s.clone());
416 }
417 serde_json::Value::Array(group) => {
418 let strings: Vec<&str> =
419 group.iter().filter_map(serde_json::Value::as_str).collect();
420 let all_non_empty =
422 strings.len() == group.len() && strings.iter().all(|s| !s.is_empty());
423 if all_non_empty {
424 result.extend(strings.into_iter().map(String::from));
425 }
426 }
427 _ => {}
428 }
429 }
430 result
431}
432
433fn make_output_filename(command: &str, input_filename: &str) -> String {
435 if input_filename.is_empty() {
436 format!("{command}-output")
437 } else {
438 let stem = input_filename
439 .rsplit_once('.')
440 .map(|(s, _)| s)
441 .unwrap_or(input_filename);
442 format!("{stem}-{command}-output")
443 }
444}
445
446fn build_parameters() -> Vec<ParameterDef> {
447 vec![
448 ParameterDef {
449 name: "command".to_string(),
450 label: "Command".to_string(),
451 description: "Binary to execute (e.g., 'ffmpeg', 'yt-dlp'). Must be on PATH."
452 .to_string(),
453 param_type: ParameterType::String,
454 default: None,
455 constraints: None,
456 placeholder: Some("ffmpeg".to_string()),
457 visible_when: None,
458 required_when: None,
459 surfaceable: true,
460 group: None,
461 suffix: None,
462 control: None,
463 accept: None,
464 presets: None,
465 inverted: None,
466 },
467 ParameterDef {
468 name: "args".to_string(),
469 label: "Arguments".to_string(),
470 description: "Command arguments as an array of strings.".to_string(),
471 param_type: ParameterType::String,
472 default: None,
473 constraints: None,
474 placeholder: None,
475 visible_when: None,
476 required_when: None,
477 surfaceable: true,
478 group: None,
479 suffix: None,
480 control: Some("tagPicker".to_string()),
481 accept: None,
482 presets: None,
483 inverted: None,
484 },
485 ParameterDef {
486 name: "outputMode".to_string(),
487 label: "Output Mode".to_string(),
488 description: "How to collect output. 'stdout' captures command output. \
489 'file' reads files written by the command to a temp directory \
490 (use {{output_dir}} in args to inject the path)."
491 .to_string(),
492 param_type: ParameterType::String,
493 default: Some(serde_json::Value::String("stdout".to_string())),
494 constraints: None,
495 placeholder: None,
496 visible_when: None,
497 required_when: None,
498 surfaceable: true,
499 group: None,
500 suffix: None,
501 control: None,
502 accept: None,
503 presets: None,
504 inverted: None,
505 },
506 ParameterDef {
507 name: "timeout".to_string(),
508 label: "Timeout".to_string(),
509 description: "Maximum execution time in seconds. Default: 300.".to_string(),
510 param_type: ParameterType::Number,
511 default: Some(serde_json::Value::Number(serde_json::Number::from(
512 DEFAULT_TIMEOUT_SECS,
513 ))),
514 constraints: None,
515 placeholder: None,
516 visible_when: None,
517 required_when: None,
518 surfaceable: true,
519 group: None,
520 suffix: Some("seconds".to_string()),
521 control: None,
522 accept: None,
523 presets: None,
524 inverted: None,
525 },
526 ParameterDef {
527 name: "maxOutputSize".to_string(),
528 label: "Max Output Size".to_string(),
529 description: "Maximum size per output file in megabytes. Default: 100 MB.".to_string(),
530 param_type: ParameterType::Number,
531 default: Some(serde_json::Value::Number(serde_json::Number::from(
532 DEFAULT_MAX_OUTPUT_MB,
533 ))),
534 constraints: None,
535 placeholder: None,
536 visible_when: None,
537 required_when: None,
538 surfaceable: true,
539 group: None,
540 suffix: Some("MB".to_string()),
541 control: None,
542 accept: None,
543 presets: None,
544 inverted: None,
545 },
546 ParameterDef {
547 name: "env".to_string(),
548 label: "Environment".to_string(),
549 description: "Additional environment variables for the command.".to_string(),
550 param_type: ParameterType::Object,
551 default: None,
552 constraints: None,
553 placeholder: None,
554 visible_when: None,
555 required_when: None,
556 surfaceable: false,
557 group: None,
558 suffix: None,
559 control: None,
560 accept: None,
561 presets: None,
562 inverted: None,
563 },
564 ]
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570 use bnto_core::NoopContext;
571
572 #[test]
575 fn test_processor_name() {
576 let processor = ShellCommand::new();
577 assert_eq!(processor.name(), "shell-command");
578 }
579
580 #[test]
581 fn test_metadata_category_system() {
582 let processor = ShellCommand::new();
583 let meta = processor.metadata();
584 assert_eq!(meta.category, NodeCategory::System);
585 }
586
587 #[test]
588 fn test_metadata_platforms_native_only() {
589 let processor = ShellCommand::new();
590 let meta = processor.metadata();
591 assert_eq!(meta.platforms, vec!["cli", "server", "desktop"]);
592 assert!(!meta.platforms.contains(&"browser".to_string()));
593 }
594
595 #[test]
596 fn test_metadata_parameters_complete() {
597 let processor = ShellCommand::new();
598 let meta = processor.metadata();
599 let param_names: Vec<&str> = meta.parameters.iter().map(|p| p.name.as_str()).collect();
600 assert!(param_names.contains(&"command"));
601 assert!(param_names.contains(&"args"));
602 assert!(param_names.contains(&"outputMode"));
603 assert!(param_names.contains(&"timeout"));
604 assert!(param_names.contains(&"maxOutputSize"));
605 assert!(param_names.contains(&"env"));
606 }
607
608 #[test]
609 fn test_metadata_no_requires() {
610 let processor = ShellCommand::new();
611 let meta = processor.metadata();
612 assert!(
613 meta.requires.is_empty(),
614 "shell-command has no inherent deps"
615 );
616 }
617
618 #[test]
621 fn test_validate_empty_command_fails() {
622 let processor = ShellCommand::new();
623 let mut params = serde_json::Map::new();
624 params.insert("command".into(), serde_json::Value::String("".into()));
625 let errors = processor.validate(¶ms);
626 assert!(!errors.is_empty());
627 }
628
629 #[test]
630 fn test_validate_missing_command_fails() {
631 let processor = ShellCommand::new();
632 let params = serde_json::Map::new();
633 let errors = processor.validate(¶ms);
634 assert!(!errors.is_empty());
635 assert!(errors[0].contains("required"));
636 }
637
638 #[test]
639 fn test_validate_present_command_passes() {
640 let processor = ShellCommand::new();
641 let mut params = serde_json::Map::new();
642 params.insert("command".into(), serde_json::Value::String("echo".into()));
643 let errors = processor.validate(¶ms);
644 assert!(errors.is_empty());
645 }
646
647 #[test]
648 fn test_validate_shell_command_rejected() {
649 let processor = ShellCommand::new();
650 let mut params = serde_json::Map::new();
651 params.insert("command".into(), serde_json::Value::String("bash".into()));
652 let errors = processor.validate(¶ms);
653 assert!(!errors.is_empty());
654 assert!(errors[0].contains("shell interpreter"));
655 }
656
657 #[test]
658 fn test_validate_zero_timeout_rejected() {
659 let processor = ShellCommand::new();
660 let mut params = serde_json::Map::new();
661 params.insert("command".into(), serde_json::Value::String("echo".into()));
662 params.insert(
663 "timeout".into(),
664 serde_json::Value::Number(serde_json::Number::from(0)),
665 );
666 let errors = processor.validate(¶ms);
667 assert!(!errors.is_empty());
668 assert!(errors[0].contains("greater than 0"));
669 }
670
671 #[test]
674 fn test_noop_context_returns_error() {
675 let processor = ShellCommand::new();
676 let progress = ProgressReporter::new_noop();
677 let input = NodeInput {
678 data: FileData::Bytes(vec![]),
679 filename: "test.txt".to_string(),
680 mime_type: None,
681 params: {
682 let mut m = serde_json::Map::new();
683 m.insert("command".into(), serde_json::Value::String("echo".into()));
684 m.insert(
685 "args".into(),
686 serde_json::Value::Array(vec![serde_json::Value::String("hello".into())]),
687 );
688 m
689 },
690 };
691 let result = processor.process(input, &progress, &NoopContext);
692 assert!(result.is_err());
693 let err = result.err().expect("should be error");
694 assert!(err.to_string().contains("not available in browser"));
695 }
696
697 #[test]
698 fn test_process_rejects_missing_command() {
699 let processor = ShellCommand::new();
700 let progress = ProgressReporter::new_noop();
701 let input = NodeInput {
702 data: FileData::Bytes(vec![]),
703 filename: "test.txt".to_string(),
704 mime_type: None,
705 params: serde_json::Map::new(),
706 };
707 let result = processor.process(input, &progress, &NoopContext);
708 assert!(result.is_err());
709 }
710
711 #[test]
712 fn test_process_rejects_shell_command() {
713 let processor = ShellCommand::new();
714 let progress = ProgressReporter::new_noop();
715 let input = NodeInput {
716 data: FileData::Bytes(vec![]),
717 filename: "test.txt".to_string(),
718 mime_type: None,
719 params: {
720 let mut m = serde_json::Map::new();
721 m.insert("command".into(), serde_json::Value::String("bash".into()));
722 m.insert(
723 "args".into(),
724 serde_json::Value::Array(vec![
725 serde_json::Value::String("-c".into()),
726 serde_json::Value::String("echo pwned".into()),
727 ]),
728 );
729 m
730 },
731 };
732 let result = processor.process(input, &progress, &NoopContext);
733 assert!(result.is_err());
734 let err = result.err().expect("should be error");
735 assert!(err.to_string().contains("shell interpreter"));
736 }
737
738 #[test]
739 fn test_default_timeout_in_metadata() {
740 let processor = ShellCommand::new();
741 let meta = processor.metadata();
742 let timeout_param = meta
743 .parameters
744 .iter()
745 .find(|p| p.name == "timeout")
746 .expect("timeout param should exist");
747 assert_eq!(
748 timeout_param.default,
749 Some(serde_json::Value::Number(serde_json::Number::from(300u64)))
750 );
751 }
752
753 #[test]
754 fn test_default_max_output_size_in_metadata() {
755 let processor = ShellCommand::new();
756 let meta = processor.metadata();
757 let param = meta
758 .parameters
759 .iter()
760 .find(|p| p.name == "maxOutputSize")
761 .expect("maxOutputSize param should exist");
762 assert_eq!(
763 param.default,
764 Some(serde_json::Value::Number(serde_json::Number::from(
765 DEFAULT_MAX_OUTPUT_MB
766 )))
767 );
768 assert_eq!(param.suffix, Some("MB".to_string()));
769 }
770
771 #[test]
772 fn test_env_param_not_surfaceable() {
773 let processor = ShellCommand::new();
774 let meta = processor.metadata();
775 let env_param = meta
776 .parameters
777 .iter()
778 .find(|p| p.name == "env")
779 .expect("env param should exist");
780 assert!(!env_param.surfaceable, "env should be internal-only");
781 }
782
783 #[test]
784 fn test_output_mode_default_is_stdout() {
785 let processor = ShellCommand::new();
786 let meta = processor.metadata();
787 let param = meta
788 .parameters
789 .iter()
790 .find(|p| p.name == "outputMode")
791 .expect("outputMode param should exist");
792 assert_eq!(
793 param.default,
794 Some(serde_json::Value::String("stdout".to_string()))
795 );
796 }
797
798 #[test]
801 fn test_mime_from_extension_video() {
802 assert_eq!(mime_from_extension("video.mp4"), "video/mp4");
803 assert_eq!(mime_from_extension("video.webm"), "video/webm");
804 assert_eq!(mime_from_extension("video.mkv"), "video/x-matroska");
805 }
806
807 #[test]
808 fn test_mime_from_extension_audio() {
809 assert_eq!(mime_from_extension("audio.mp3"), "audio/mpeg");
810 assert_eq!(mime_from_extension("audio.m4a"), "audio/mp4");
811 assert_eq!(mime_from_extension("audio.wav"), "audio/wav");
812 assert_eq!(mime_from_extension("audio.flac"), "audio/flac");
813 }
814
815 #[test]
816 fn test_mime_from_extension_unknown() {
817 assert_eq!(mime_from_extension("file.xyz"), "application/octet-stream");
818 assert_eq!(mime_from_extension("noext"), "application/octet-stream");
819 }
820
821 #[test]
822 fn test_make_output_filename_empty_input() {
823 assert_eq!(make_output_filename("echo", ""), "echo-output");
824 }
825
826 #[test]
827 fn test_make_output_filename_with_input() {
828 assert_eq!(
829 make_output_filename("ffmpeg", "video.mp4"),
830 "video-ffmpeg-output"
831 );
832 }
833
834 #[test]
835 fn test_collect_output_files_returns_path_variant() {
836 let dir = std::env::temp_dir().join("bnto-test-collect-output");
837 let _ = std::fs::remove_dir_all(&dir);
838 std::fs::create_dir_all(&dir).unwrap();
839 std::fs::write(dir.join("video.mp4"), vec![0u8; 100]).unwrap();
840 std::fs::write(dir.join("info.json"), b"{}").unwrap();
841
842 let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
843 assert_eq!(files.len(), 2);
844
845 let names: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
846 assert!(names.contains(&"video.mp4"));
847 assert!(names.contains(&"info.json"));
848
849 let mp4 = files.iter().find(|f| f.filename == "video.mp4").unwrap();
851 assert_eq!(mp4.mime_type, "video/mp4");
852 let json = files.iter().find(|f| f.filename == "info.json").unwrap();
853 assert_eq!(json.mime_type, "application/json");
854
855 for file in &files {
857 assert!(
858 matches!(&file.data, FileData::Path(_)),
859 "collect_output_files should return FileData::Path, got Bytes for {}",
860 file.filename,
861 );
862 }
863
864 let _ = std::fs::remove_dir_all(&dir);
865 }
866
867 #[test]
868 fn test_collect_output_files_preserves_subdirectory_in_filename() {
869 let dir = std::env::temp_dir().join("bnto-test-collect-subdirs");
870 let _ = std::fs::remove_dir_all(&dir);
871 std::fs::create_dir_all(dir.join("subdir")).unwrap();
872 std::fs::write(dir.join("file.txt"), b"data").unwrap();
873 std::fs::write(dir.join("subdir").join("video.mp4"), vec![0u8; 50]).unwrap();
874
875 let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
876 assert_eq!(files.len(), 2);
877
878 let names: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
879 assert!(names.contains(&"file.txt"));
880 assert!(names.contains(&"subdir/video.mp4"));
881
882 let _ = std::fs::remove_dir_all(&dir);
883 }
884
885 #[test]
886 fn test_collect_output_files_preserves_deeply_nested_path() {
887 let dir = std::env::temp_dir().join("bnto-test-collect-deep");
888 let _ = std::fs::remove_dir_all(&dir);
889 std::fs::create_dir_all(dir.join("a").join("b")).unwrap();
890 std::fs::write(dir.join("a").join("b").join("deep.mp4"), vec![0u8; 10]).unwrap();
891
892 let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
893 assert_eq!(files.len(), 1);
894 assert_eq!(files[0].filename, "a/b/deep.mp4");
895
896 let _ = std::fs::remove_dir_all(&dir);
897 }
898
899 #[test]
900 fn test_collect_output_files_multiple_subdirs_preserve_structure() {
901 let dir = std::env::temp_dir().join("bnto-test-collect-multi-subdirs");
902 let _ = std::fs::remove_dir_all(&dir);
903 std::fs::create_dir_all(dir.join("Alpha Legion")).unwrap();
904 std::fs::create_dir_all(dir.join("Suboden Khan")).unwrap();
905 std::fs::write(dir.join("Alpha Legion").join("part1.mp4"), b"vid1").unwrap();
906 std::fs::write(dir.join("Suboden Khan").join("part2.mp4"), b"vid2").unwrap();
907
908 let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
909 assert_eq!(files.len(), 2);
910
911 let mut names: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
912 names.sort();
913 assert_eq!(
914 names,
915 vec!["Alpha Legion/part1.mp4", "Suboden Khan/part2.mp4"]
916 );
917
918 let _ = std::fs::remove_dir_all(&dir);
919 }
920
921 #[test]
922 fn test_collect_output_files_empty_dir() {
923 let dir = std::env::temp_dir().join("bnto-test-collect-empty");
924 let _ = std::fs::remove_dir_all(&dir);
925 std::fs::create_dir_all(&dir).unwrap();
926
927 let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
928 assert!(files.is_empty());
929
930 let _ = std::fs::remove_dir_all(&dir);
931 }
932
933 #[test]
934 fn test_collect_output_files_rejects_oversized_file() {
935 let dir = std::env::temp_dir().join("bnto-test-collect-oversized");
936 let _ = std::fs::remove_dir_all(&dir);
937 std::fs::create_dir_all(&dir).unwrap();
938 std::fs::write(dir.join("big.mp4"), vec![0u8; 2 * 1024 * 1024]).unwrap();
940
941 let result = collect_output_files(&dir, 1);
942 match result {
943 Err(e) => {
944 let msg = e.to_string();
945 assert!(msg.contains("big.mp4"), "Error should name the file");
946 assert!(msg.contains("1 MB limit"), "Error should state the limit");
947 }
948 Ok(_) => panic!("Expected error for oversized file"),
949 }
950
951 let _ = std::fs::remove_dir_all(&dir);
952 }
953
954 #[test]
955 fn test_collect_output_files_accepts_file_under_limit() {
956 let dir = std::env::temp_dir().join("bnto-test-collect-under-limit");
957 let _ = std::fs::remove_dir_all(&dir);
958 std::fs::create_dir_all(&dir).unwrap();
959 std::fs::write(dir.join("small.mp4"), vec![0u8; 512 * 1024]).unwrap();
961
962 let files = collect_output_files(&dir, 1).unwrap();
963 assert_eq!(files.len(), 1);
964 assert_eq!(files[0].filename, "small.mp4");
965
966 let _ = std::fs::remove_dir_all(&dir);
967 }
968
969 #[test]
970 fn test_output_dir_placeholder_substitution() {
971 let args = [
972 "-o".to_string(),
973 "{{output_dir}}/%(title)s.%(ext)s".to_string(),
974 "--verbose".to_string(),
975 ];
976 let dir_str = "/tmp/bnto-output";
977 let resolved: Vec<String> = args
978 .iter()
979 .map(|a| a.replace(OUTPUT_DIR_PLACEHOLDER, dir_str))
980 .collect();
981 assert_eq!(resolved[0], "-o");
982 assert_eq!(resolved[1], "/tmp/bnto-output/%(title)s.%(ext)s");
983 assert_eq!(resolved[2], "--verbose");
984 }
985
986 #[test]
989 fn test_resolve_url_appended_when_no_placeholder() {
990 let mut args = vec!["--no-playlist".to_string(), "-o".to_string()];
991 let mut params = serde_json::Map::new();
992 params.insert("url".into(), "https://example.com/video".into());
993 resolve_input_placeholders(&mut args, ¶ms);
994 assert_eq!(args.len(), 3);
995 assert_eq!(args[2], "https://example.com/video");
996 }
997
998 #[test]
999 fn test_resolve_url_placeholder_substituted() {
1000 let mut args = vec!["--download".to_string(), "{{url}}".to_string()];
1001 let mut params = serde_json::Map::new();
1002 params.insert("url".into(), "https://example.com/video".into());
1003 resolve_input_placeholders(&mut args, ¶ms);
1004 assert_eq!(args.len(), 2);
1005 assert_eq!(args[1], "https://example.com/video");
1006 }
1007
1008 #[test]
1009 fn test_resolve_input_placeholder_substituted() {
1010 let mut args = vec!["process".to_string(), "{{input}}".to_string()];
1011 let mut params = serde_json::Map::new();
1012 params.insert("url".into(), "https://example.com/data".into());
1013 resolve_input_placeholders(&mut args, ¶ms);
1014 assert_eq!(args.len(), 2);
1015 assert_eq!(args[1], "https://example.com/data");
1016 }
1017
1018 #[test]
1019 fn test_resolve_no_url_no_change() {
1020 let mut args = vec!["--help".to_string()];
1021 let params = serde_json::Map::new();
1022 resolve_input_placeholders(&mut args, ¶ms);
1023 assert_eq!(args.len(), 1);
1024 assert_eq!(args[0], "--help");
1025 }
1026
1027 #[test]
1028 fn test_resolve_url_not_appended_when_placeholder_exists() {
1029 let mut args = vec!["{{url}}".to_string(), "--verbose".to_string()];
1030 let mut params = serde_json::Map::new();
1031 params.insert("url".into(), "https://example.com".into());
1032 resolve_input_placeholders(&mut args, ¶ms);
1033 assert_eq!(args.len(), 2);
1035 assert_eq!(args[0], "https://example.com");
1036 }
1037
1038 #[test]
1041 fn test_flatten_flat_strings_included() {
1042 let args = vec![
1043 serde_json::Value::String("--no-playlist".into()),
1044 serde_json::Value::String("--newline".into()),
1045 ];
1046 assert_eq!(
1047 flatten_conditional_args(&args),
1048 vec!["--no-playlist", "--newline"]
1049 );
1050 }
1051
1052 #[test]
1053 fn test_flatten_empty_strings_dropped() {
1054 let args = vec![
1055 serde_json::Value::String("--always".into()),
1056 serde_json::Value::String("".into()),
1057 serde_json::Value::String("--also-always".into()),
1058 ];
1059 assert_eq!(
1060 flatten_conditional_args(&args),
1061 vec!["--always", "--also-always"]
1062 );
1063 }
1064
1065 #[test]
1066 fn test_flatten_nested_array_all_non_empty_flattened() {
1067 let args = vec![
1068 serde_json::Value::String("--always".into()),
1069 serde_json::Value::Array(vec![
1070 serde_json::Value::String("--cookies-from-browser".into()),
1071 serde_json::Value::String("chrome".into()),
1072 ]),
1073 ];
1074 assert_eq!(
1075 flatten_conditional_args(&args),
1076 vec!["--always", "--cookies-from-browser", "chrome"]
1077 );
1078 }
1079
1080 #[test]
1081 fn test_flatten_nested_array_any_empty_drops_group() {
1082 let args = vec![
1083 serde_json::Value::String("--always".into()),
1084 serde_json::Value::Array(vec![
1085 serde_json::Value::String("--cookies-from-browser".into()),
1086 serde_json::Value::String("".into()),
1087 ]),
1088 ];
1089 assert_eq!(flatten_conditional_args(&args), vec!["--always"]);
1090 }
1091
1092 #[test]
1093 fn test_flatten_nested_array_with_non_string_drops_group() {
1094 let args = vec![serde_json::Value::Array(vec![
1095 serde_json::Value::String("--flag".into()),
1096 serde_json::Value::Number(serde_json::Number::from(42)),
1097 ])];
1098 assert!(
1099 flatten_conditional_args(&args).is_empty(),
1100 "Non-string element means group.len() != strings.len()"
1101 );
1102 }
1103
1104 #[test]
1105 fn test_flatten_non_string_types_dropped() {
1106 let args = vec![
1107 serde_json::Value::String("--keep".into()),
1108 serde_json::Value::Number(serde_json::Number::from(42)),
1109 serde_json::Value::Bool(true),
1110 serde_json::Value::Null,
1111 ];
1112 assert_eq!(flatten_conditional_args(&args), vec!["--keep"]);
1113 }
1114
1115 #[test]
1116 fn test_flatten_empty_array_produces_nothing() {
1117 let args = vec![serde_json::Value::Array(vec![])];
1118 assert!(flatten_conditional_args(&args).is_empty());
1121 }
1122
1123 #[test]
1124 fn test_flatten_mixed_conditional_scenario() {
1125 let args = vec![
1127 serde_json::Value::String("--no-playlist".into()),
1128 serde_json::Value::String("--newline".into()),
1129 serde_json::Value::Array(vec![
1130 serde_json::Value::String("--cookies-from-browser".into()),
1131 serde_json::Value::String("chrome".into()),
1132 ]),
1133 serde_json::Value::Array(vec![
1134 serde_json::Value::String("-S".into()),
1135 serde_json::Value::String("res:720".into()),
1136 ]),
1137 serde_json::Value::String("".into()), serde_json::Value::String("".into()), ];
1140 assert_eq!(
1141 flatten_conditional_args(&args),
1142 vec![
1143 "--no-playlist",
1144 "--newline",
1145 "--cookies-from-browser",
1146 "chrome",
1147 "-S",
1148 "res:720",
1149 ]
1150 );
1151 }
1152
1153 #[test]
1154 fn test_flatten_all_groups_disabled() {
1155 let args = vec![
1157 serde_json::Value::String("--no-playlist".into()),
1158 serde_json::Value::Array(vec![
1159 serde_json::Value::String("--cookies-from-browser".into()),
1160 serde_json::Value::String("".into()),
1161 ]),
1162 serde_json::Value::Array(vec![
1163 serde_json::Value::String("-S".into()),
1164 serde_json::Value::String("".into()),
1165 ]),
1166 serde_json::Value::String("".into()),
1167 serde_json::Value::String("".into()),
1168 ];
1169 assert_eq!(flatten_conditional_args(&args), vec!["--no-playlist"]);
1170 }
1171}