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