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    /// Get the bnto home directory (~/.bnto/ by default).
44    /// Returns `None` in browser (WASM) — filesystem paths don't apply.
45    fn home_dir(&self) -> Option<&Path> {
46        None
47    }
48
49    /// Get the default output directory for recipe results (~/.bnto/output/).
50    /// Returns `None` in browser (WASM) — filesystem paths don't apply.
51    fn output_dir(&self) -> Option<PathBuf> {
52        None
53    }
54}
55
56/// No-op context for browser (WASM) execution.
57/// All system-access methods return errors — browser has no shell.
58pub struct NoopContext;
59
60impl ProcessContext for NoopContext {
61    fn run_command(&self, _cmd: &str, _args: &[&str]) -> Result<Vec<u8>, BntoError> {
62        Err(BntoError::ProcessingFailed(
63            "System commands not available in browser".to_string(),
64        ))
65    }
66
67    fn temp_file(&self, _suffix: &str) -> Result<PathBuf, BntoError> {
68        Err(BntoError::ProcessingFailed(
69            "Temp files not available in browser".to_string(),
70        ))
71    }
72
73    fn env_var(&self, _key: &str) -> Option<String> {
74        None
75    }
76
77    fn work_dir(&self) -> Result<&Path, BntoError> {
78        Err(BntoError::ProcessingFailed(
79            "Working directory not available in browser".to_string(),
80        ))
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn test_noop_context_run_command_returns_err() {
90        let ctx = NoopContext;
91        let result = ctx.run_command("ls", &[]);
92        assert!(result.is_err());
93        assert!(
94            result
95                .unwrap_err()
96                .to_string()
97                .contains("not available in browser")
98        );
99    }
100
101    #[test]
102    fn test_noop_context_temp_file_returns_err() {
103        let ctx = NoopContext;
104        let result = ctx.temp_file(".txt");
105        assert!(result.is_err());
106        assert!(
107            result
108                .unwrap_err()
109                .to_string()
110                .contains("not available in browser")
111        );
112    }
113
114    #[test]
115    fn test_noop_context_env_var_returns_none() {
116        let ctx = NoopContext;
117        assert_eq!(ctx.env_var("PATH"), None);
118    }
119
120    #[test]
121    fn test_noop_context_streaming_falls_back_to_run_command() {
122        let ctx = NoopContext;
123        let called = std::cell::Cell::new(false);
124        let result = ctx.run_command_streaming("ls", &[], &|_| called.set(true));
125        assert!(result.is_err());
126        assert!(
127            !called.get(),
128            "on_output should not be called in noop fallback"
129        );
130    }
131
132    #[test]
133    fn test_noop_context_work_dir_returns_err() {
134        let ctx = NoopContext;
135        let result = ctx.work_dir();
136        assert!(result.is_err());
137        assert!(
138            result
139                .unwrap_err()
140                .to_string()
141                .contains("not available in browser")
142        );
143    }
144
145    #[test]
146    fn test_noop_context_home_dir_returns_none() {
147        let ctx = NoopContext;
148        assert!(ctx.home_dir().is_none());
149    }
150
151    #[test]
152    fn test_noop_context_output_dir_returns_none() {
153        let ctx = NoopContext;
154        assert!(ctx.output_dir().is_none());
155    }
156}