Skip to main content

bnto_engine/
deps.rs

1// Dependency checker — verifies that external tools required by processors
2// are available on the host system. WASM has no shell, so this module is
3// only useful in CLI/desktop contexts where ProcessContext is a NativeContext.
4
5use bnto_core::{BntoError, Dependency, NodeRegistry, PipelineDefinition};
6use std::collections::HashSet;
7
8/// Result of checking a single dependency.
9#[derive(Debug, Clone)]
10pub struct DependencyStatus {
11    pub dependency: Dependency,
12    pub found: bool,
13    /// Installed version string (if detected via `--version`).
14    pub installed_version: Option<String>,
15    /// Whether the installed version satisfies the constraint.
16    /// `None` if no constraint was specified or version couldn't be determined.
17    pub version_satisfied: Option<bool>,
18}
19
20/// Collect unique dependencies required by a pipeline definition.
21///
22/// Merges two sources: recipe-level deps (`definition.requires`) first,
23/// then per-node processor deps (from `metadata().requires`). Deduplicates
24/// by binary name — recipe-level deps take precedence when both declare
25/// the same binary.
26pub 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    // Recipe-level deps first — these take precedence.
34    for dep in &definition.requires {
35        if seen.insert(dep.binary.clone()) {
36            deps.push(dep.clone());
37        }
38    }
39
40    // Then per-node processor deps (existing logic).
41    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        // Recurse into container children.
61        if let Some(children) = &node.children {
62            collect_from_nodes(children, registry, seen, deps);
63        }
64    }
65}
66
67/// Collect unique dependencies from ALL registered processors in the registry.
68pub 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
81/// Check whether each dependency's binary is available on the system
82/// and whether its version satisfies any declared constraint.
83///
84/// Uses `which <binary>` to probe the PATH. If the dependency has a
85/// non-empty `version` constraint, runs `<binary> --version` and
86/// validates the output against the constraint.
87pub 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            // Only check version if the binary exists and has a constraint.
96            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
118/// Check dependencies for a pipeline definition and return an error if any are missing.
119///
120/// Intended as a pre-flight check before `run_pipeline()`. Returns `Ok(())`
121/// when all dependencies are satisfied, or `Err(BntoError)` listing the
122/// missing binaries with install hints.
123pub 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
160/// Check that all required secrets declared by a pipeline are available.
161///
162/// Parallel to `check_pipeline_dependencies()` — this is the pre-flight
163/// check for env vars. Returns `Ok(())` when all required secrets are
164/// present, or `Err(BntoError)` listing the missing ones.
165pub 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// =============================================================================
186// Tests
187// =============================================================================
188
189#[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    // --- Mock processor that declares dependencies ---
202
203    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    /// A no-dep processor (like existing browser-only ones).
300    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    /// Mock context where `which` always fails (simulates missing deps).
326    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    /// Mock context where `which` always succeeds.
344    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    /// Build a definition with recipe-level `requires` and the given nodes.
371    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    // --- collect_pipeline_dependencies ---
399
400    #[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, &registry);
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, &registry);
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, &registry);
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, &registry);
434        // ffmpeg appears in both, but should be deduplicated
435        assert_eq!(deps.len(), 2); // ffmpeg + yt-dlp
436        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    // --- collect_pipeline_dependencies: recipe-level requires ---
442
443    #[test]
444    fn test_collect_recipe_only_deps() {
445        // Recipe declares deps but no nodes have processor deps.
446        let def = make_definition_with_requires(&["input", "output"], vec![ytdlp_dep()]);
447        let registry = NodeRegistry::new();
448        let deps = collect_pipeline_dependencies(&def, &registry);
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        // Recipe has no requires but nodes have processor deps.
456        // This is the existing behavior — must still work.
457        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, &registry);
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        // Recipe declares yt-dlp, node declares ffmpeg — both should appear.
468        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, &registry);
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        // Recipe-level deps should appear before node-level deps in the list.
484        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, &registry);
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        // Both recipe and node declare ffmpeg — should appear only once.
504        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, &registry);
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        // Recipe has explicit empty requires — should behave like no requires.
518        let def = make_definition_with_requires(&["input", "output"], vec![]);
519        let registry = NodeRegistry::new();
520        let deps = collect_pipeline_dependencies(&def, &registry);
521        assert!(deps.is_empty());
522    }
523
524    #[test]
525    fn test_check_pipeline_dependencies_catches_recipe_deps() {
526        // Pre-flight check should catch missing recipe-level deps too.
527        let def = make_definition_with_requires(&["input", "output"], vec![ytdlp_dep()]);
528        let registry = NodeRegistry::new();
529        let result = check_pipeline_dependencies(&def, &registry, &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    // --- collect_all_dependencies ---
537
538    #[test]
539    fn test_collect_all_from_empty_registry() {
540        let registry = NodeRegistry::new();
541        let deps = collect_all_dependencies(&registry);
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(&registry);
552        assert_eq!(deps.len(), 2); // ffmpeg + yt-dlp (deduplicated)
553    }
554
555    // --- check_dependencies ---
556
557    #[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    // --- check_pipeline_dependencies ---
590
591    #[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, &registry, &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, &registry, &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, &registry, &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, &registry, &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    // --- Version constraint integration ---
634
635    /// Mock context that returns a path for `which` and version output
636    /// for `<binary> --version`. Simulates a system with ffmpeg 6.1.1.
637    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()]; // requires >=6.0
672        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()]; // requires >=6.0
683        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()]; // no version constraint
694        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, &registry, &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, &registry, &ctx);
722        assert!(result.is_ok());
723    }
724
725    // --- check_pipeline_secrets ---
726
727    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    /// Mock context that provides specific env vars.
750    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}