Skip to main content

shell_tunnel/execution/
command.rs

1//! Command building and representation.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::time::Duration;
6
7/// A command to be executed in a shell session.
8#[derive(Debug, Clone)]
9pub struct Command {
10    /// The command line to execute.
11    pub command_line: String,
12    /// Working directory override (if any).
13    pub working_dir: Option<PathBuf>,
14    /// Environment variables to set.
15    pub env: HashMap<String, String>,
16    /// Maximum execution time.
17    pub timeout: Option<Duration>,
18    /// Whether to capture output.
19    pub capture_output: bool,
20    /// Cap on the output the result keeps, in bytes.
21    ///
22    /// `None` means [`super::executor::DEFAULT_MAX_OUTPUT_BYTES`]. There is no
23    /// value meaning "unbounded" — see that constant.
24    pub max_output_bytes: Option<u64>,
25}
26
27impl Command {
28    /// Create a new command with the given command line.
29    pub fn new(command_line: impl Into<String>) -> Self {
30        Self {
31            command_line: command_line.into(),
32            working_dir: None,
33            env: HashMap::new(),
34            timeout: None,
35            capture_output: true,
36            max_output_bytes: None,
37        }
38    }
39
40    /// Set the working directory.
41    pub fn working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
42        self.working_dir = Some(dir.into());
43        self
44    }
45
46    /// Add an environment variable.
47    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
48        self.env.insert(key.into(), value.into());
49        self
50    }
51
52    /// Add multiple environment variables.
53    pub fn envs<I, K, V>(mut self, vars: I) -> Self
54    where
55        I: IntoIterator<Item = (K, V)>,
56        K: Into<String>,
57        V: Into<String>,
58    {
59        for (k, v) in vars {
60            self.env.insert(k.into(), v.into());
61        }
62        self
63    }
64
65    /// Set the execution timeout.
66    pub fn timeout(mut self, duration: Duration) -> Self {
67        self.timeout = Some(duration);
68        self
69    }
70
71    /// Set whether to capture output.
72    pub fn capture_output(mut self, capture: bool) -> Self {
73        self.capture_output = capture;
74        self
75    }
76
77    /// Cap the output the result keeps.
78    pub fn max_output_bytes(mut self, bytes: u64) -> Self {
79        self.max_output_bytes = Some(bytes);
80        self
81    }
82
83    /// The deadline this command will actually run under.
84    ///
85    /// [`timeout`](Self::timeout) records what the caller *asked for*; this is
86    /// what they get. Absent, it is [`DEFAULT_TIMEOUT`]; present, it is bounded
87    /// by [`MIN_TIMEOUT`] and [`MAX_TIMEOUT`] — the range `docs/openapi.json`
88    /// has published all along without anything enforcing it.
89    ///
90    /// **This is deliberately the only place the deadline is computed.** It used
91    /// to be worked out twice — once in the blocking core to time the command
92    /// out, and once in `execute_async` to decide when a stalled streaming
93    /// consumer stops being waited on. Two copies of one rule is a bug waiting
94    /// for the first edit that reaches only one of them, and clamping was
95    /// exactly such an edit: applied to the first alone, a command would have
96    /// been killed at the ceiling while the stream went on being fed to a
97    /// consumer for the hours the caller originally named.
98    pub fn effective_timeout(&self) -> Duration {
99        self.timeout
100            .unwrap_or(super::executor::DEFAULT_TIMEOUT)
101            .clamp(super::executor::MIN_TIMEOUT, super::executor::MAX_TIMEOUT)
102    }
103}
104
105impl Default for Command {
106    fn default() -> Self {
107        Self::new("")
108    }
109}
110
111/// Builder for creating commands with fluent API.
112#[derive(Debug, Default)]
113pub struct CommandBuilder {
114    command_line: Option<String>,
115    working_dir: Option<PathBuf>,
116    env: HashMap<String, String>,
117    timeout: Option<Duration>,
118    capture_output: bool,
119    max_output_bytes: Option<u64>,
120}
121
122impl CommandBuilder {
123    /// Create a new command builder.
124    pub fn new() -> Self {
125        Self {
126            capture_output: true,
127            ..Default::default()
128        }
129    }
130
131    /// Set the command line.
132    pub fn command_line(mut self, cmd: impl Into<String>) -> Self {
133        self.command_line = Some(cmd.into());
134        self
135    }
136
137    /// Set the working directory.
138    pub fn working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
139        self.working_dir = Some(dir.into());
140        self
141    }
142
143    /// Add an environment variable.
144    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
145        self.env.insert(key.into(), value.into());
146        self
147    }
148
149    /// Set the execution timeout.
150    pub fn timeout(mut self, duration: Duration) -> Self {
151        self.timeout = Some(duration);
152        self
153    }
154
155    /// Set whether to capture output.
156    pub fn capture_output(mut self, capture: bool) -> Self {
157        self.capture_output = capture;
158        self
159    }
160
161    /// Build the command.
162    ///
163    /// Returns `None` if no command line was specified.
164    pub fn build(self) -> Option<Command> {
165        self.command_line.map(|cmd| Command {
166            command_line: cmd,
167            working_dir: self.working_dir,
168            env: self.env,
169            timeout: self.timeout,
170            capture_output: self.capture_output,
171            max_output_bytes: self.max_output_bytes,
172        })
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::execution::{DEFAULT_TIMEOUT, MAX_TIMEOUT, MIN_TIMEOUT};
180
181    /// Asking for nothing gets the default, and the default is inside the range.
182    #[test]
183    fn an_unset_timeout_is_the_default() {
184        assert_eq!(Command::new("echo hi").effective_timeout(), DEFAULT_TIMEOUT);
185        assert!(
186            DEFAULT_TIMEOUT >= MIN_TIMEOUT && DEFAULT_TIMEOUT <= MAX_TIMEOUT,
187            "the default must itself be a value a caller could have asked for"
188        );
189    }
190
191    /// A value inside the published range is honoured exactly.
192    #[test]
193    fn a_timeout_within_the_range_is_taken_as_asked() {
194        let asked = Duration::from_secs(45);
195        assert_eq!(
196            Command::new("echo hi").timeout(asked).effective_timeout(),
197            asked
198        );
199    }
200
201    /// Above the ceiling is clamped, not refused — the same shape
202    /// `max_output_bytes` uses, and the figure `docs/openapi.json` publishes.
203    ///
204    /// Nothing enforced this before: `timeout_secs: 999999999` was accepted and
205    /// honoured, so one caller could hold a blocking thread for decades while
206    /// the published reference said the maximum was 300.
207    #[test]
208    fn a_timeout_above_the_ceiling_is_clamped() {
209        let absurd = Duration::from_secs(999_999_999);
210        assert_eq!(
211            Command::new("echo hi").timeout(absurd).effective_timeout(),
212            MAX_TIMEOUT
213        );
214    }
215
216    /// Zero is raised to the floor rather than taken literally.
217    ///
218    /// Taken literally it is a deadline that has already passed, so the control
219    /// loop killed every such command on its first pass having run nothing —
220    /// while `docs/openapi.json` said `"minimum": 1`.
221    #[test]
222    fn a_zero_timeout_is_raised_to_the_floor() {
223        assert_eq!(
224            Command::new("echo hi")
225                .timeout(Duration::from_secs(0))
226                .effective_timeout(),
227            MIN_TIMEOUT
228        );
229    }
230
231    #[test]
232    fn test_command_new() {
233        let cmd = Command::new("ls -la");
234        assert_eq!(cmd.command_line, "ls -la");
235        assert!(cmd.working_dir.is_none());
236        assert!(cmd.env.is_empty());
237        assert!(cmd.timeout.is_none());
238        assert!(cmd.capture_output);
239    }
240
241    #[test]
242    fn test_command_builder_chain() {
243        let cmd = Command::new("cargo build")
244            .working_dir("/project")
245            .env("RUST_LOG", "debug")
246            .timeout(Duration::from_secs(60))
247            .capture_output(true);
248
249        assert_eq!(cmd.command_line, "cargo build");
250        assert_eq!(cmd.working_dir, Some(PathBuf::from("/project")));
251        assert_eq!(cmd.env.get("RUST_LOG"), Some(&"debug".to_string()));
252        assert_eq!(cmd.timeout, Some(Duration::from_secs(60)));
253    }
254
255    #[test]
256    fn test_command_envs() {
257        let vars = [("KEY1", "val1"), ("KEY2", "val2")];
258        let cmd = Command::new("echo").envs(vars);
259
260        assert_eq!(cmd.env.len(), 2);
261        assert_eq!(cmd.env.get("KEY1"), Some(&"val1".to_string()));
262        assert_eq!(cmd.env.get("KEY2"), Some(&"val2".to_string()));
263    }
264
265    #[test]
266    fn test_command_builder_build() {
267        let cmd = CommandBuilder::new()
268            .command_line("pwd")
269            .working_dir("/tmp")
270            .build();
271
272        assert!(cmd.is_some());
273        let cmd = cmd.unwrap();
274        assert_eq!(cmd.command_line, "pwd");
275        assert_eq!(cmd.working_dir, Some(PathBuf::from("/tmp")));
276    }
277
278    #[test]
279    fn test_command_builder_empty() {
280        let cmd = CommandBuilder::new().build();
281        assert!(cmd.is_none());
282    }
283}