1use bnto_core::{BntoError, Dependency, NodeRegistry, PipelineDefinition};
6use std::collections::HashSet;
7
8#[derive(Debug, Clone)]
10pub struct DependencyStatus {
11 pub dependency: Dependency,
12 pub found: bool,
13 pub installed_version: Option<String>,
15 pub version_satisfied: Option<bool>,
18}
19
20pub fn collect_pipeline_dependencies(
27 definition: &PipelineDefinition,
28 registry: &NodeRegistry,
29) -> Vec<Dependency> {
30 let mut seen = HashSet::new();
31 let mut deps = Vec::new();
32
33 for dep in &definition.requires {
35 if seen.insert(dep.binary.clone()) {
36 deps.push(dep.clone());
37 }
38 }
39
40 collect_from_nodes(&definition.nodes, registry, &mut seen, &mut deps);
42 deps
43}
44
45fn collect_from_nodes(
46 nodes: &[bnto_core::PipelineNode],
47 registry: &NodeRegistry,
48 seen: &mut HashSet<String>,
49 deps: &mut Vec<Dependency>,
50) {
51 let empty_params = serde_json::Map::new();
52 for node in nodes {
53 if let Some(processor) = registry.resolve(&node.node_type, &empty_params) {
54 for dep in &processor.metadata().requires {
55 if seen.insert(dep.binary.clone()) {
56 deps.push(dep.clone());
57 }
58 }
59 }
60 if let Some(children) = &node.children {
62 collect_from_nodes(children, registry, seen, deps);
63 }
64 }
65}
66
67pub fn collect_all_dependencies(registry: &NodeRegistry) -> Vec<Dependency> {
69 let mut seen = HashSet::new();
70 let mut deps = Vec::new();
71 for metadata in registry.catalog() {
72 for dep in metadata.requires {
73 if seen.insert(dep.binary.clone()) {
74 deps.push(dep);
75 }
76 }
77 }
78 deps
79}
80
81pub fn check_dependencies(
88 deps: &[Dependency],
89 ctx: &dyn bnto_core::ProcessContext,
90) -> Vec<DependencyStatus> {
91 deps.iter()
92 .map(|dep| {
93 let found = ctx.run_command("which", &[&dep.binary]).is_ok();
94
95 let (installed_version, version_satisfied) = if found && !dep.version.is_empty() {
97 match bnto_core::check_version(&dep.binary, &dep.version, ctx) {
98 bnto_core::VersionCheckResult::Checked {
99 installed,
100 satisfied,
101 } => (Some(installed), Some(satisfied)),
102 bnto_core::VersionCheckResult::Skipped => (None, None),
103 }
104 } else {
105 (None, None)
106 };
107
108 DependencyStatus {
109 dependency: dep.clone(),
110 found,
111 installed_version,
112 version_satisfied,
113 }
114 })
115 .collect()
116}
117
118pub fn check_pipeline_dependencies(
124 definition: &PipelineDefinition,
125 registry: &NodeRegistry,
126 ctx: &dyn bnto_core::ProcessContext,
127) -> Result<(), BntoError> {
128 let deps = collect_pipeline_dependencies(definition, registry);
129 if deps.is_empty() {
130 return Ok(());
131 }
132
133 let statuses = check_dependencies(&deps, ctx);
134
135 let mut messages: Vec<String> = Vec::new();
136
137 for s in &statuses {
138 if !s.found {
139 let hint = &s.dependency.install_hint;
140 messages.push(format!(" - {} (install: {})", s.dependency.binary, hint));
141 } else if s.version_satisfied == Some(false) {
142 let installed = s.installed_version.as_deref().unwrap_or("unknown");
143 messages.push(format!(
144 " - {} (installed: {}, requires: {})",
145 s.dependency.binary, installed, s.dependency.version
146 ));
147 }
148 }
149
150 if messages.is_empty() {
151 return Ok(());
152 }
153
154 Err(BntoError::InvalidInput(format!(
155 "Dependency requirements not met:\n{}",
156 messages.join("\n")
157 )))
158}
159
160pub fn check_pipeline_secrets(
166 definition: &PipelineDefinition,
167 ctx: &dyn bnto_core::ProcessContext,
168) -> Result<(), BntoError> {
169 if definition.secrets.is_empty() {
170 return Ok(());
171 }
172
173 let statuses = bnto_core::secrets::check_secrets(&definition.secrets, ctx);
174 let missing = bnto_core::secrets::missing_required(&statuses);
175
176 if missing.is_empty() {
177 return Ok(());
178 }
179
180 Err(BntoError::InvalidInput(
181 bnto_core::secrets::format_missing_error(&missing),
182 ))
183}
184
185#[cfg(test)]
190mod tests {
191 use super::*;
192 use bnto_core::NodeProcessor;
193 use bnto_core::NoopContext;
194 use bnto_core::context::ProcessContext;
195 use bnto_core::errors::BntoError;
196 use bnto_core::metadata::{Dependency, InputCardinality, NodeCategory, NodeMetadata};
197 use bnto_core::processor::{NodeInput, NodeOutput, OutputFile};
198 use bnto_core::progress::ProgressReporter;
199 use std::path::{Path, PathBuf};
200
201 struct FfmpegProcessor;
204
205 impl NodeProcessor for FfmpegProcessor {
206 fn name(&self) -> &str {
207 "video-transcode"
208 }
209
210 fn process(
211 &self,
212 input: NodeInput,
213 _progress: &ProgressReporter,
214 _ctx: &dyn ProcessContext,
215 ) -> Result<NodeOutput, BntoError> {
216 Ok(NodeOutput {
217 files: vec![OutputFile {
218 data: input.data,
219 filename: input.filename,
220 mime_type: "video/mp4".to_string(),
221 metadata: serde_json::Map::new(),
222 }],
223 metadata: serde_json::Map::new(),
224 })
225 }
226
227 fn metadata(&self) -> NodeMetadata {
228 NodeMetadata {
229 node_type: "video-transcode".to_string(),
230 name: "Transcode Video".to_string(),
231 description: "Transcode video using ffmpeg.".to_string(),
232 category: NodeCategory::Data,
233 accepts: vec!["video/*".to_string()],
234 platforms: vec!["cli".to_string()],
235 parameters: vec![],
236 input_cardinality: InputCardinality::PerFile,
237 requires: vec![Dependency {
238 binary: "ffmpeg".to_string(),
239 version: ">=6.0".to_string(),
240 install_hint: "brew install ffmpeg".to_string(),
241 homepage: "https://ffmpeg.org".to_string(),
242 }],
243 }
244 }
245 }
246
247 struct YtDlpProcessor;
248
249 impl NodeProcessor for YtDlpProcessor {
250 fn name(&self) -> &str {
251 "video-download"
252 }
253
254 fn process(
255 &self,
256 input: NodeInput,
257 _progress: &ProgressReporter,
258 _ctx: &dyn ProcessContext,
259 ) -> Result<NodeOutput, BntoError> {
260 Ok(NodeOutput {
261 files: vec![OutputFile {
262 data: input.data,
263 filename: input.filename,
264 mime_type: "video/mp4".to_string(),
265 metadata: serde_json::Map::new(),
266 }],
267 metadata: serde_json::Map::new(),
268 })
269 }
270
271 fn metadata(&self) -> NodeMetadata {
272 NodeMetadata {
273 node_type: "video-download".to_string(),
274 name: "Download Video".to_string(),
275 description: "Download video using yt-dlp.".to_string(),
276 category: NodeCategory::Data,
277 accepts: vec![],
278 platforms: vec!["cli".to_string()],
279 parameters: vec![],
280 input_cardinality: InputCardinality::PerFile,
281 requires: vec![
282 Dependency {
283 binary: "yt-dlp".to_string(),
284 version: String::new(),
285 install_hint: "brew install yt-dlp".to_string(),
286 homepage: "https://github.com/yt-dlp/yt-dlp".to_string(),
287 },
288 Dependency {
289 binary: "ffmpeg".to_string(),
290 version: ">=6.0".to_string(),
291 install_hint: "brew install ffmpeg".to_string(),
292 homepage: "https://ffmpeg.org".to_string(),
293 },
294 ],
295 }
296 }
297 }
298
299 struct NoDepsProcessor;
301
302 impl NodeProcessor for NoDepsProcessor {
303 fn name(&self) -> &str {
304 "no-deps"
305 }
306
307 fn process(
308 &self,
309 input: NodeInput,
310 _progress: &ProgressReporter,
311 _ctx: &dyn ProcessContext,
312 ) -> Result<NodeOutput, BntoError> {
313 Ok(NodeOutput {
314 files: vec![OutputFile {
315 data: input.data,
316 filename: input.filename,
317 mime_type: "application/octet-stream".to_string(),
318 metadata: serde_json::Map::new(),
319 }],
320 metadata: serde_json::Map::new(),
321 })
322 }
323 }
324
325 struct AllMissingContext;
327
328 impl ProcessContext for AllMissingContext {
329 fn run_command(&self, _cmd: &str, _args: &[&str]) -> Result<Vec<u8>, BntoError> {
330 Err(BntoError::ProcessingFailed("not found".to_string()))
331 }
332 fn temp_file(&self, _suffix: &str) -> Result<PathBuf, BntoError> {
333 Err(BntoError::ProcessingFailed("not available".to_string()))
334 }
335 fn env_var(&self, _key: &str) -> Option<String> {
336 None
337 }
338 fn work_dir(&self) -> Result<&Path, BntoError> {
339 Err(BntoError::ProcessingFailed("not available".to_string()))
340 }
341 }
342
343 struct AllFoundContext;
345
346 impl ProcessContext for AllFoundContext {
347 fn run_command(&self, _cmd: &str, _args: &[&str]) -> Result<Vec<u8>, BntoError> {
348 Ok(b"/usr/local/bin/found".to_vec())
349 }
350 fn temp_file(&self, _suffix: &str) -> Result<PathBuf, BntoError> {
351 Err(BntoError::ProcessingFailed("not available".to_string()))
352 }
353 fn env_var(&self, _key: &str) -> Option<String> {
354 None
355 }
356 fn work_dir(&self) -> Result<&Path, BntoError> {
357 Err(BntoError::ProcessingFailed("not available".to_string()))
358 }
359 }
360
361 fn make_definition(node_types: &[&str]) -> PipelineDefinition {
362 let json = serde_json::json!({
363 "nodes": node_types.iter().enumerate().map(|(i, t)| {
364 serde_json::json!({ "id": format!("n{i}"), "type": t })
365 }).collect::<Vec<_>>()
366 });
367 serde_json::from_value(json).unwrap()
368 }
369
370 fn make_definition_with_requires(
372 node_types: &[&str],
373 requires: Vec<Dependency>,
374 ) -> PipelineDefinition {
375 let mut def = make_definition(node_types);
376 def.requires = requires;
377 def
378 }
379
380 fn ytdlp_dep() -> Dependency {
381 Dependency {
382 binary: "yt-dlp".to_string(),
383 version: String::new(),
384 install_hint: "brew install yt-dlp".to_string(),
385 homepage: String::new(),
386 }
387 }
388
389 fn ffmpeg_dep() -> Dependency {
390 Dependency {
391 binary: "ffmpeg".to_string(),
392 version: ">=6.0".to_string(),
393 install_hint: "brew install ffmpeg".to_string(),
394 homepage: "https://ffmpeg.org".to_string(),
395 }
396 }
397
398 #[test]
401 fn test_collect_empty_pipeline_returns_no_deps() {
402 let def = make_definition(&["input", "output"]);
403 let registry = NodeRegistry::new();
404 let deps = collect_pipeline_dependencies(&def, ®istry);
405 assert!(deps.is_empty());
406 }
407
408 #[test]
409 fn test_collect_pipeline_with_no_dep_processor() {
410 let mut registry = NodeRegistry::new();
411 registry.register("no-deps", Box::new(NoDepsProcessor));
412 let def = make_definition(&["input", "no-deps", "output"]);
413 let deps = collect_pipeline_dependencies(&def, ®istry);
414 assert!(deps.is_empty());
415 }
416
417 #[test]
418 fn test_collect_pipeline_with_ffmpeg_dep() {
419 let mut registry = NodeRegistry::new();
420 registry.register("video-transcode", Box::new(FfmpegProcessor));
421 let def = make_definition(&["input", "video-transcode", "output"]);
422 let deps = collect_pipeline_dependencies(&def, ®istry);
423 assert_eq!(deps.len(), 1);
424 assert_eq!(deps[0].binary, "ffmpeg");
425 }
426
427 #[test]
428 fn test_collect_deduplicates_shared_deps() {
429 let mut registry = NodeRegistry::new();
430 registry.register("video-transcode", Box::new(FfmpegProcessor));
431 registry.register("video-download", Box::new(YtDlpProcessor));
432 let def = make_definition(&["input", "video-transcode", "video-download", "output"]);
433 let deps = collect_pipeline_dependencies(&def, ®istry);
434 assert_eq!(deps.len(), 2); let binaries: Vec<&str> = deps.iter().map(|d| d.binary.as_str()).collect();
437 assert!(binaries.contains(&"ffmpeg"));
438 assert!(binaries.contains(&"yt-dlp"));
439 }
440
441 #[test]
444 fn test_collect_recipe_only_deps() {
445 let def = make_definition_with_requires(&["input", "output"], vec![ytdlp_dep()]);
447 let registry = NodeRegistry::new();
448 let deps = collect_pipeline_dependencies(&def, ®istry);
449 assert_eq!(deps.len(), 1);
450 assert_eq!(deps[0].binary, "yt-dlp");
451 }
452
453 #[test]
454 fn test_collect_node_only_deps_unchanged() {
455 let mut registry = NodeRegistry::new();
458 registry.register("video-transcode", Box::new(FfmpegProcessor));
459 let def = make_definition(&["input", "video-transcode", "output"]);
460 let deps = collect_pipeline_dependencies(&def, ®istry);
461 assert_eq!(deps.len(), 1);
462 assert_eq!(deps[0].binary, "ffmpeg");
463 }
464
465 #[test]
466 fn test_collect_merged_recipe_and_node_deps() {
467 let mut registry = NodeRegistry::new();
469 registry.register("video-transcode", Box::new(FfmpegProcessor));
470 let def = make_definition_with_requires(
471 &["input", "video-transcode", "output"],
472 vec![ytdlp_dep()],
473 );
474 let deps = collect_pipeline_dependencies(&def, ®istry);
475 assert_eq!(deps.len(), 2);
476 let binaries: Vec<&str> = deps.iter().map(|d| d.binary.as_str()).collect();
477 assert!(binaries.contains(&"yt-dlp"));
478 assert!(binaries.contains(&"ffmpeg"));
479 }
480
481 #[test]
482 fn test_collect_recipe_deps_come_first() {
483 let mut registry = NodeRegistry::new();
485 registry.register("video-transcode", Box::new(FfmpegProcessor));
486 let def = make_definition_with_requires(
487 &["input", "video-transcode", "output"],
488 vec![ytdlp_dep()],
489 );
490 let deps = collect_pipeline_dependencies(&def, ®istry);
491 assert_eq!(
492 deps[0].binary, "yt-dlp",
493 "Recipe-level dep should come first"
494 );
495 assert_eq!(
496 deps[1].binary, "ffmpeg",
497 "Node-level dep should come second"
498 );
499 }
500
501 #[test]
502 fn test_collect_deduplicated_recipe_and_node_deps() {
503 let mut registry = NodeRegistry::new();
505 registry.register("video-transcode", Box::new(FfmpegProcessor));
506 let def = make_definition_with_requires(
507 &["input", "video-transcode", "output"],
508 vec![ffmpeg_dep()],
509 );
510 let deps = collect_pipeline_dependencies(&def, ®istry);
511 assert_eq!(deps.len(), 1, "Duplicate ffmpeg should be deduplicated");
512 assert_eq!(deps[0].binary, "ffmpeg");
513 }
514
515 #[test]
516 fn test_collect_empty_recipe_requires() {
517 let def = make_definition_with_requires(&["input", "output"], vec![]);
519 let registry = NodeRegistry::new();
520 let deps = collect_pipeline_dependencies(&def, ®istry);
521 assert!(deps.is_empty());
522 }
523
524 #[test]
525 fn test_check_pipeline_dependencies_catches_recipe_deps() {
526 let def = make_definition_with_requires(&["input", "output"], vec![ytdlp_dep()]);
528 let registry = NodeRegistry::new();
529 let result = check_pipeline_dependencies(&def, ®istry, &AllMissingContext);
530 assert!(result.is_err());
531 let err_msg = result.unwrap_err().to_string();
532 assert!(err_msg.contains("yt-dlp"));
533 assert!(err_msg.contains("brew install yt-dlp"));
534 }
535
536 #[test]
539 fn test_collect_all_from_empty_registry() {
540 let registry = NodeRegistry::new();
541 let deps = collect_all_dependencies(®istry);
542 assert!(deps.is_empty());
543 }
544
545 #[test]
546 fn test_collect_all_deduplicates() {
547 let mut registry = NodeRegistry::new();
548 registry.register("video-transcode", Box::new(FfmpegProcessor));
549 registry.register("video-download", Box::new(YtDlpProcessor));
550 registry.register("no-deps", Box::new(NoDepsProcessor));
551 let deps = collect_all_dependencies(®istry);
552 assert_eq!(deps.len(), 2); }
554
555 #[test]
558 fn test_check_all_missing() {
559 let deps = vec![Dependency {
560 binary: "ffmpeg".to_string(),
561 version: String::new(),
562 install_hint: "brew install ffmpeg".to_string(),
563 homepage: String::new(),
564 }];
565 let statuses = check_dependencies(&deps, &AllMissingContext);
566 assert_eq!(statuses.len(), 1);
567 assert!(!statuses[0].found);
568 }
569
570 #[test]
571 fn test_check_all_found() {
572 let deps = vec![Dependency {
573 binary: "ffmpeg".to_string(),
574 version: String::new(),
575 install_hint: "brew install ffmpeg".to_string(),
576 homepage: String::new(),
577 }];
578 let statuses = check_dependencies(&deps, &AllFoundContext);
579 assert_eq!(statuses.len(), 1);
580 assert!(statuses[0].found);
581 }
582
583 #[test]
584 fn test_check_empty_deps_returns_empty() {
585 let statuses = check_dependencies(&[], &NoopContext);
586 assert!(statuses.is_empty());
587 }
588
589 #[test]
592 fn test_preflight_no_deps_ok() {
593 let mut registry = NodeRegistry::new();
594 registry.register("no-deps", Box::new(NoDepsProcessor));
595 let def = make_definition(&["input", "no-deps", "output"]);
596 let result = check_pipeline_dependencies(&def, ®istry, &NoopContext);
597 assert!(result.is_ok());
598 }
599
600 #[test]
601 fn test_preflight_missing_dep_returns_error() {
602 let mut registry = NodeRegistry::new();
603 registry.register("video-transcode", Box::new(FfmpegProcessor));
604 let def = make_definition(&["input", "video-transcode", "output"]);
605 let result = check_pipeline_dependencies(&def, ®istry, &AllMissingContext);
606 assert!(result.is_err());
607 let err_msg = result.unwrap_err().to_string();
608 assert!(err_msg.contains("ffmpeg"));
609 assert!(err_msg.contains("brew install ffmpeg"));
610 }
611
612 #[test]
613 fn test_preflight_all_found_ok() {
614 let mut registry = NodeRegistry::new();
615 registry.register("video-transcode", Box::new(FfmpegProcessor));
616 let def = make_definition(&["input", "video-transcode", "output"]);
617 let result = check_pipeline_dependencies(&def, ®istry, &AllFoundContext);
618 assert!(result.is_ok());
619 }
620
621 #[test]
622 fn test_preflight_error_lists_all_missing() {
623 let mut registry = NodeRegistry::new();
624 registry.register("video-download", Box::new(YtDlpProcessor));
625 let def = make_definition(&["input", "video-download", "output"]);
626 let result = check_pipeline_dependencies(&def, ®istry, &AllMissingContext);
627 assert!(result.is_err());
628 let err_msg = result.unwrap_err().to_string();
629 assert!(err_msg.contains("yt-dlp"));
630 assert!(err_msg.contains("ffmpeg"));
631 }
632
633 struct VersionedContext {
638 version_output: String,
639 }
640
641 impl VersionedContext {
642 fn new(version_output: &str) -> Self {
643 Self {
644 version_output: version_output.to_string(),
645 }
646 }
647 }
648
649 impl ProcessContext for VersionedContext {
650 fn run_command(&self, cmd: &str, _args: &[&str]) -> Result<Vec<u8>, BntoError> {
651 if cmd == "which" {
652 Ok(b"/usr/local/bin/found".to_vec())
653 } else {
654 Ok(self.version_output.as_bytes().to_vec())
655 }
656 }
657 fn temp_file(&self, _suffix: &str) -> Result<PathBuf, BntoError> {
658 Err(BntoError::ProcessingFailed("mock".to_string()))
659 }
660 fn env_var(&self, _key: &str) -> Option<String> {
661 None
662 }
663 fn work_dir(&self) -> Result<&Path, BntoError> {
664 Err(BntoError::ProcessingFailed("mock".to_string()))
665 }
666 }
667
668 #[test]
669 fn test_check_deps_version_satisfied() {
670 let ctx = VersionedContext::new("ffmpeg version 6.1.1 Copyright");
671 let deps = vec![ffmpeg_dep()]; let statuses = check_dependencies(&deps, &ctx);
673 assert_eq!(statuses.len(), 1);
674 assert!(statuses[0].found);
675 assert_eq!(statuses[0].installed_version.as_deref(), Some("6.1.1"));
676 assert_eq!(statuses[0].version_satisfied, Some(true));
677 }
678
679 #[test]
680 fn test_check_deps_version_unsatisfied() {
681 let ctx = VersionedContext::new("ffmpeg version 5.0.2 Copyright");
682 let deps = vec![ffmpeg_dep()]; let statuses = check_dependencies(&deps, &ctx);
684 assert_eq!(statuses.len(), 1);
685 assert!(statuses[0].found);
686 assert_eq!(statuses[0].installed_version.as_deref(), Some("5.0.2"));
687 assert_eq!(statuses[0].version_satisfied, Some(false));
688 }
689
690 #[test]
691 fn test_check_deps_no_version_constraint() {
692 let ctx = VersionedContext::new("yt-dlp 2024.12.23");
693 let deps = vec![ytdlp_dep()]; let statuses = check_dependencies(&deps, &ctx);
695 assert_eq!(statuses.len(), 1);
696 assert!(statuses[0].found);
697 assert!(statuses[0].installed_version.is_none());
698 assert!(statuses[0].version_satisfied.is_none());
699 }
700
701 #[test]
702 fn test_preflight_version_mismatch_returns_error() {
703 let ctx = VersionedContext::new("ffmpeg version 5.0.2 Copyright");
704 let mut registry = NodeRegistry::new();
705 registry.register("video-transcode", Box::new(FfmpegProcessor));
706 let def = make_definition(&["input", "video-transcode", "output"]);
707 let result = check_pipeline_dependencies(&def, ®istry, &ctx);
708 assert!(result.is_err());
709 let err_msg = result.unwrap_err().to_string();
710 assert!(err_msg.contains("ffmpeg"));
711 assert!(err_msg.contains("5.0.2"));
712 assert!(err_msg.contains(">=6.0"));
713 }
714
715 #[test]
716 fn test_preflight_version_satisfied_passes() {
717 let ctx = VersionedContext::new("ffmpeg version 6.1.1 Copyright");
718 let mut registry = NodeRegistry::new();
719 registry.register("video-transcode", Box::new(FfmpegProcessor));
720 let def = make_definition(&["input", "video-transcode", "output"]);
721 let result = check_pipeline_dependencies(&def, ®istry, &ctx);
722 assert!(result.is_ok());
723 }
724
725 fn make_definition_with_secrets(secrets: Vec<bnto_core::SecretDef>) -> PipelineDefinition {
728 let mut def = make_definition(&["input", "output"]);
729 def.secrets = secrets;
730 def
731 }
732
733 fn required_secret(key: &str) -> bnto_core::SecretDef {
734 bnto_core::SecretDef {
735 key: key.to_string(),
736 description: String::new(),
737 required: true,
738 }
739 }
740
741 fn optional_secret(key: &str) -> bnto_core::SecretDef {
742 bnto_core::SecretDef {
743 key: key.to_string(),
744 description: String::new(),
745 required: false,
746 }
747 }
748
749 struct EnvContext {
751 vars: std::collections::HashMap<String, String>,
752 }
753
754 impl EnvContext {
755 fn new(pairs: &[(&str, &str)]) -> Self {
756 Self {
757 vars: pairs
758 .iter()
759 .map(|(k, v)| (k.to_string(), v.to_string()))
760 .collect(),
761 }
762 }
763 }
764
765 impl ProcessContext for EnvContext {
766 fn run_command(&self, _cmd: &str, _args: &[&str]) -> Result<Vec<u8>, BntoError> {
767 Err(BntoError::ProcessingFailed("mock".to_string()))
768 }
769 fn temp_file(&self, _suffix: &str) -> Result<PathBuf, BntoError> {
770 Err(BntoError::ProcessingFailed("mock".to_string()))
771 }
772 fn env_var(&self, key: &str) -> Option<String> {
773 self.vars.get(key).cloned()
774 }
775 fn work_dir(&self) -> Result<&Path, BntoError> {
776 Err(BntoError::ProcessingFailed("mock".to_string()))
777 }
778 }
779
780 #[test]
781 fn test_secrets_preflight_no_secrets_ok() {
782 let def = make_definition(&["input", "output"]);
783 let result = check_pipeline_secrets(&def, &NoopContext);
784 assert!(result.is_ok());
785 }
786
787 #[test]
788 fn test_secrets_preflight_all_present_ok() {
789 let def = make_definition_with_secrets(vec![required_secret("API_KEY")]);
790 let ctx = EnvContext::new(&[("API_KEY", "sk-123")]);
791 let result = check_pipeline_secrets(&def, &ctx);
792 assert!(result.is_ok());
793 }
794
795 #[test]
796 fn test_secrets_preflight_required_missing_fails() {
797 let def = make_definition_with_secrets(vec![required_secret("API_KEY")]);
798 let result = check_pipeline_secrets(&def, &NoopContext);
799 assert!(result.is_err());
800 let msg = result.unwrap_err().to_string();
801 assert!(msg.contains("API_KEY"));
802 assert!(msg.contains("~/.config/bnto/.env"));
803 }
804
805 #[test]
806 fn test_secrets_preflight_optional_missing_ok() {
807 let def = make_definition_with_secrets(vec![optional_secret("OPTIONAL_KEY")]);
808 let result = check_pipeline_secrets(&def, &NoopContext);
809 assert!(result.is_ok());
810 }
811
812 #[test]
813 fn test_secrets_preflight_mixed_missing_only_required() {
814 let def = make_definition_with_secrets(vec![
815 required_secret("REQUIRED_KEY"),
816 optional_secret("OPTIONAL_KEY"),
817 ]);
818 let result = check_pipeline_secrets(&def, &NoopContext);
819 assert!(result.is_err());
820 let msg = result.unwrap_err().to_string();
821 assert!(msg.contains("REQUIRED_KEY"));
822 assert!(!msg.contains("OPTIONAL_KEY"));
823 }
824}