Skip to main content

bnto_core/
context.rs

1// Controlled system access for node processors.
2//
3// Processors that wrap external tools (yt-dlp, ffmpeg) need to run commands,
4// create temp files, and read env vars. This trait is the boundary:
5//   - Browser (WASM): `NoopContext` — all methods return errors
6//   - CLI (native): `NativeContext` — full `std::process` access
7//   - Desktop (future): `SandboxedContext` — scoped to approved directories
8
9use std::path::{Path, PathBuf};
10
11use crate::errors::BntoError;
12
13/// System access boundary for processors that need external tools.
14pub trait ProcessContext: Send + Sync {
15    /// Run an external command, capturing stdout.
16    fn run_command(&self, cmd: &str, args: &[&str]) -> Result<Vec<u8>, BntoError>;
17
18    /// Run an external command, streaming output lines via a callback.
19    ///
20    /// Calls `on_output` for each line of stdout and stderr as it arrives,
21    /// enabling live progress feedback from tools like yt-dlp.
22    /// Returns stdout bytes on success. Default falls back to `run_command()`.
23    fn run_command_streaming(
24        &self,
25        cmd: &str,
26        args: &[&str],
27        on_output: &dyn Fn(&str),
28    ) -> Result<Vec<u8>, BntoError> {
29        let _ = on_output;
30        self.run_command(cmd, args)
31    }
32
33    /// Return a unique temporary file path with atomic creation (mkstemp).
34    /// The file IS pre-created on disk to prevent TOCTOU races.
35    fn temp_file(&self, suffix: &str) -> Result<PathBuf, BntoError>;
36
37    /// Read an environment variable.
38    fn env_var(&self, key: &str) -> Option<String>;
39
40    /// Get the working directory for this execution.
41    fn work_dir(&self) -> Result<&Path, BntoError>;
42
43    /// Locate the executable `run_command(name, ..)` would spawn, without
44    /// spawning anything. Returns `None` when it can't be found — always in
45    /// the browser (WASM), which has no processes.
46    fn find_binary(&self, name: &str) -> Option<PathBuf> {
47        let _ = name;
48        None
49    }
50
51    /// Get the bnto home directory (~/.bnto/ by default).
52    /// Returns `None` in browser (WASM) — filesystem paths don't apply.
53    fn home_dir(&self) -> Option<&Path> {
54        None
55    }
56
57    /// Get the default output directory for recipe results (~/.bnto/output/).
58    /// Returns `None` in browser (WASM) — filesystem paths don't apply.
59    fn output_dir(&self) -> Option<PathBuf> {
60        None
61    }
62
63    /// True on a dry run: a node that writes decides what it would do and
64    /// writes nothing. Defaults to false.
65    fn dry_run(&self) -> bool {
66        false
67    }
68}
69
70/// No-op context for browser (WASM) execution.
71/// All system-access methods return errors — browser has no shell.
72pub struct NoopContext;
73
74impl ProcessContext for NoopContext {
75    fn run_command(&self, _cmd: &str, _args: &[&str]) -> Result<Vec<u8>, BntoError> {
76        Err(BntoError::ProcessingFailed(
77            "System commands not available in browser".to_string(),
78        ))
79    }
80
81    fn temp_file(&self, _suffix: &str) -> Result<PathBuf, BntoError> {
82        Err(BntoError::ProcessingFailed(
83            "Temp files not available in browser".to_string(),
84        ))
85    }
86
87    fn env_var(&self, _key: &str) -> Option<String> {
88        None
89    }
90
91    fn work_dir(&self) -> Result<&Path, BntoError> {
92        Err(BntoError::ProcessingFailed(
93            "Working directory not available in browser".to_string(),
94        ))
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn test_noop_context_run_command_returns_err() {
104        let ctx = NoopContext;
105        let result = ctx.run_command("ls", &[]);
106        assert!(result.is_err());
107        assert!(
108            result
109                .unwrap_err()
110                .to_string()
111                .contains("not available in browser")
112        );
113    }
114
115    #[test]
116    fn test_noop_context_temp_file_returns_err() {
117        let ctx = NoopContext;
118        let result = ctx.temp_file(".txt");
119        assert!(result.is_err());
120        assert!(
121            result
122                .unwrap_err()
123                .to_string()
124                .contains("not available in browser")
125        );
126    }
127
128    #[test]
129    fn test_noop_context_env_var_returns_none() {
130        let ctx = NoopContext;
131        assert_eq!(ctx.env_var("PATH"), None);
132    }
133
134    #[test]
135    fn test_noop_context_streaming_falls_back_to_run_command() {
136        let ctx = NoopContext;
137        let called = std::cell::Cell::new(false);
138        let result = ctx.run_command_streaming("ls", &[], &|_| called.set(true));
139        assert!(result.is_err());
140        assert!(
141            !called.get(),
142            "on_output should not be called in noop fallback"
143        );
144    }
145
146    #[test]
147    fn test_noop_context_work_dir_returns_err() {
148        let ctx = NoopContext;
149        let result = ctx.work_dir();
150        assert!(result.is_err());
151        assert!(
152            result
153                .unwrap_err()
154                .to_string()
155                .contains("not available in browser")
156        );
157    }
158
159    #[test]
160    fn test_noop_context_find_binary_returns_none() {
161        assert!(NoopContext.find_binary("git").is_none());
162    }
163
164    #[test]
165    fn test_noop_context_home_dir_returns_none() {
166        let ctx = NoopContext;
167        assert!(ctx.home_dir().is_none());
168    }
169
170    #[test]
171    fn test_noop_context_dry_run_is_false() {
172        assert!(!NoopContext.dry_run());
173    }
174
175    #[test]
176    fn test_noop_context_output_dir_returns_none() {
177        let ctx = NoopContext;
178        assert!(ctx.output_dir().is_none());
179    }
180}