tcrm_task/tasks/
config.rs

1use serde::{Deserialize, Serialize};
2use std::{collections::HashMap, sync::Arc};
3
4use crate::tasks::error::TaskError;
5
6/// Configuration for a task to be executed
7#[derive(Debug, Deserialize, Serialize, Clone)]
8pub struct TaskConfig {
9    /// The command or executable to run
10    pub command: String,
11
12    /// Arguments to pass to the command
13    pub args: Option<Vec<String>>,
14
15    /// Working directory for the command
16    pub working_dir: Option<String>,
17
18    /// Environment variables for the command
19    pub env: Option<HashMap<String, String>>,
20
21    /// Maximum allowed runtime in milliseconds
22    pub timeout_ms: Option<u64>,
23
24    /// Allow providing input to the task via stdin
25    pub enable_stdin: Option<bool>,
26}
27
28pub type SharedTaskConfig = Arc<TaskConfig>;
29impl Default for TaskConfig {
30    fn default() -> Self {
31        TaskConfig {
32            command: String::new(),
33            args: None,
34            working_dir: None,
35            env: None,
36            timeout_ms: None,
37            enable_stdin: Some(false),
38        }
39    }
40}
41
42impl TaskConfig {
43    /// Create a new task configuration with the given command
44    ///
45    /// # Example
46    /// ```rust
47    /// use tcrm_task::tasks::config::TaskConfig;
48    ///
49    /// let config = TaskConfig::new("echo");
50    /// ```
51    pub fn new(command: impl Into<String>) -> Self {
52        TaskConfig {
53            command: command.into(),
54            ..Default::default()
55        }
56    }
57
58    /// Set the arguments for the command
59    pub fn args<I, S>(mut self, args: I) -> Self
60    where
61        I: IntoIterator<Item = S>,
62        S: Into<String>,
63    {
64        self.args = Some(args.into_iter().map(Into::into).collect());
65        self
66    }
67
68    /// Set the working directory for the command
69    pub fn working_dir(mut self, dir: impl Into<String>) -> Self {
70        self.working_dir = Some(dir.into());
71        self
72    }
73
74    /// Set environment variables for the command
75    pub fn env<K, V, I>(mut self, env: I) -> Self
76    where
77        K: Into<String>,
78        V: Into<String>,
79        I: IntoIterator<Item = (K, V)>,
80    {
81        self.env = Some(env.into_iter().map(|(k, v)| (k.into(), v.into())).collect());
82        self
83    }
84
85    /// Set the maximum allowed runtime in milliseconds
86    pub fn timeout_ms(mut self, timeout: u64) -> Self {
87        self.timeout_ms = Some(timeout);
88        self
89    }
90    /// Enable or disable stdin for the task
91    pub fn enable_stdin(mut self, b: bool) -> Self {
92        self.enable_stdin = Some(b);
93        self
94    }
95
96    /// Validate the configuration
97    ///
98    /// Returns `Ok(())` if valid, or `TaskError::InvalidConfiguration` describing the problem
99    /// # Examples
100    ///
101    /// ```
102    /// use tcrm_task::tasks::config::TaskConfig;
103    /// // Valid config
104    /// let config = TaskConfig::new("echo");
105    /// assert!(config.validate().is_ok());
106    ///
107    /// // Invalid config (empty command)
108    /// let config = TaskConfig::new("");
109    /// assert!(config.validate().is_err());
110    /// ```
111    pub fn validate(&self) -> Result<(), TaskError> {
112        const MAX_COMMAND_LEN: usize = 4096;
113        const MAX_ARG_LEN: usize = 4096;
114        const MAX_WORKING_DIR_LEN: usize = 4096;
115        const MAX_ENV_KEY_LEN: usize = 1024;
116        const MAX_ENV_VALUE_LEN: usize = 4096;
117
118        // Validate command
119        if self.command.is_empty() {
120            return Err(TaskError::InvalidConfiguration(
121                "Command cannot be empty".to_string(),
122            ));
123        }
124        if self.command.trim() != self.command {
125            return Err(TaskError::InvalidConfiguration(
126                "Command cannot have leading or trailing whitespace".to_string(),
127            ));
128        }
129        if self.command.len() > MAX_COMMAND_LEN {
130            return Err(TaskError::InvalidConfiguration(
131                "Command length exceeds maximum allowed length".to_string(),
132            ));
133        }
134
135        // Validate arguments
136        if let Some(args) = &self.args {
137            for arg in args {
138                if arg.is_empty() {
139                    return Err(TaskError::InvalidConfiguration(
140                        "Arguments cannot be empty".to_string(),
141                    ));
142                }
143                if arg.trim() != arg {
144                    return Err(TaskError::InvalidConfiguration(format!(
145                        "Argument '{}' cannot have leading/trailing whitespace",
146                        arg
147                    )));
148                }
149                if arg.len() > MAX_ARG_LEN {
150                    return Err(TaskError::InvalidConfiguration(format!(
151                        "Argument '{}' exceeds maximum length",
152                        arg
153                    )));
154                }
155            }
156        }
157
158        // Validate working directory
159        if let Some(dir) = &self.working_dir {
160            let path = std::path::Path::new(dir);
161            if !path.exists() {
162                return Err(TaskError::InvalidConfiguration(format!(
163                    "Working directory '{}' does not exist",
164                    dir
165                )));
166            }
167            if !path.is_dir() {
168                return Err(TaskError::InvalidConfiguration(format!(
169                    "Working directory '{}' is not a directory",
170                    dir
171                )));
172            }
173            if dir.trim() != dir {
174                return Err(TaskError::InvalidConfiguration(
175                    "Working directory cannot have leading/trailing whitespace".to_string(),
176                ));
177            }
178            if dir.len() > MAX_WORKING_DIR_LEN {
179                return Err(TaskError::InvalidConfiguration(
180                    "Working directory path exceeds maximum length".to_string(),
181                ));
182            }
183        }
184
185        // Validate environment variables
186        if let Some(env) = &self.env {
187            for (k, v) in env {
188                if k.is_empty() {
189                    return Err(TaskError::InvalidConfiguration(
190                        "Environment variable key cannot be empty".to_string(),
191                    ));
192                }
193                if k.contains('=') {
194                    return Err(TaskError::InvalidConfiguration(format!(
195                        "Environment variable key '{}' cannot contain '='",
196                        k
197                    )));
198                }
199                if k.contains(' ') {
200                    return Err(TaskError::InvalidConfiguration(format!(
201                        "Environment variable key '{}' cannot contain spaces",
202                        k
203                    )));
204                }
205                if k.len() > MAX_ENV_KEY_LEN {
206                    return Err(TaskError::InvalidConfiguration(format!(
207                        "Environment variable key '{}' exceeds maximum length",
208                        k
209                    )));
210                }
211                if v.trim() != v {
212                    return Err(TaskError::InvalidConfiguration(format!(
213                        "Environment variable '{}' value cannot have leading/trailing whitespace",
214                        k
215                    )));
216                }
217                if v.len() > MAX_ENV_VALUE_LEN {
218                    return Err(TaskError::InvalidConfiguration(format!(
219                        "Environment variable '{}' value exceeds maximum length",
220                        k
221                    )));
222                }
223            }
224        }
225
226        // Validate timeout
227        if let Some(timeout) = self.timeout_ms {
228            if timeout == 0 {
229                return Err(TaskError::InvalidConfiguration(
230                    "Timeout must be greater than 0".to_string(),
231                ));
232            }
233        }
234
235        Ok(())
236    }
237}
238
239#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
240#[serde(rename_all = "lowercase")]
241pub enum StreamSource {
242    Stdout = 0,
243    Stderr = 1,
244}
245impl Default for StreamSource {
246    fn default() -> Self {
247        Self::Stdout
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use std::{collections::HashMap, env::temp_dir};
254
255    use crate::tasks::{config::TaskConfig, error::TaskError};
256
257    #[test]
258    fn validation() {
259        // Valid config
260        let config = TaskConfig::new("echo").args(["hello"]);
261        assert!(config.validate().is_ok());
262
263        // Empty command should fail
264        let config = TaskConfig::new("");
265        assert!(matches!(
266            config.validate(),
267            Err(TaskError::InvalidConfiguration(_))
268        ));
269
270        // Command with leading/trailing whitespace should fail
271        let config = TaskConfig::new("  echo  ");
272        assert!(matches!(
273            config.validate(),
274            Err(TaskError::InvalidConfiguration(_))
275        ));
276
277        // Command exceeding max length should fail
278        let long_cmd = "a".repeat(4097);
279        let config = TaskConfig::new(long_cmd);
280        assert!(matches!(
281            config.validate(),
282            Err(TaskError::InvalidConfiguration(_))
283        ));
284
285        // Zero timeout should fail
286        let config = TaskConfig::new("echo").timeout_ms(0);
287        assert!(matches!(
288            config.validate(),
289            Err(TaskError::InvalidConfiguration(_))
290        ));
291
292        // Valid timeout should pass
293        let config = TaskConfig::new("echo").timeout_ms(30);
294        assert!(config.validate().is_ok());
295
296        // Arguments with empty string should fail
297        let config = TaskConfig::new("echo").args([""]);
298        assert!(matches!(
299            config.validate(),
300            Err(TaskError::InvalidConfiguration(_))
301        ));
302
303        // Argument with leading/trailing whitespace should fail
304        let config = TaskConfig::new("echo").args([" hello "]);
305        assert!(matches!(
306            config.validate(),
307            Err(TaskError::InvalidConfiguration(_))
308        ));
309
310        // Argument exceeding max length should fail
311        let long_arg = "a".repeat(4097);
312        let config = TaskConfig::new("echo").args([long_arg]);
313        assert!(matches!(
314            config.validate(),
315            Err(TaskError::InvalidConfiguration(_))
316        ));
317
318        // Working directory that does not exist should fail
319        let config = TaskConfig::new("echo").working_dir("/non/existent/dir");
320        assert!(matches!(
321            config.validate(),
322            Err(TaskError::InvalidConfiguration(_))
323        ));
324
325        // Working directory with temp dir should pass
326        let dir = temp_dir();
327        let config = TaskConfig::new("echo").working_dir(dir.as_path().to_str().unwrap());
328        assert!(config.validate().is_ok());
329
330        // Working directory with whitespace should fail
331        let dir = temp_dir();
332        let dir_str = format!(" {} ", dir.as_path().to_str().unwrap());
333        let config = TaskConfig::new("echo").working_dir(&dir_str);
334        assert!(matches!(
335            config.validate(),
336            Err(TaskError::InvalidConfiguration(_))
337        ));
338
339        // Environment variable with empty key should fail
340        let mut env = HashMap::new();
341        env.insert(String::new(), "value".to_string());
342        let config = TaskConfig::new("echo").env(env);
343        assert!(matches!(
344            config.validate(),
345            Err(TaskError::InvalidConfiguration(_))
346        ));
347
348        // Environment variable with space in key should fail
349        let mut env = HashMap::new();
350        env.insert("KEY WITH SPACE".to_string(), "value".to_string());
351        let config = TaskConfig::new("echo").env(env);
352        assert!(matches!(
353            config.validate(),
354            Err(TaskError::InvalidConfiguration(_))
355        ));
356
357        // Environment variable with '=' in key should fail
358        let mut env = HashMap::new();
359        env.insert("KEY=BAD".to_string(), "value".to_string());
360        let config = TaskConfig::new("echo").env(env);
361        assert!(matches!(
362            config.validate(),
363            Err(TaskError::InvalidConfiguration(_))
364        ));
365
366        // Environment variable key exceeding max length should fail
367        let mut env = HashMap::new();
368        env.insert("A".repeat(1025), "value".to_string());
369        let config = TaskConfig::new("echo").env(env);
370        assert!(matches!(
371            config.validate(),
372            Err(TaskError::InvalidConfiguration(_))
373        ));
374
375        // Environment variable value with whitespace should fail
376        let mut env = HashMap::new();
377        env.insert("KEY".to_string(), " value ".to_string());
378        let config = TaskConfig::new("echo").env(env);
379        assert!(matches!(
380            config.validate(),
381            Err(TaskError::InvalidConfiguration(_))
382        ));
383
384        // Environment variable value exceeding max length should fail
385        let mut env = HashMap::new();
386        env.insert("KEY".to_string(), "A".repeat(4097));
387        let config = TaskConfig::new("echo").env(env);
388        assert!(matches!(
389            config.validate(),
390            Err(TaskError::InvalidConfiguration(_))
391        ));
392
393        // Environment variable key/value valid should pass
394        let mut env = HashMap::new();
395        env.insert("KEY".to_string(), "some value".to_string());
396        let config = TaskConfig::new("echo").env(env);
397        assert!(config.validate().is_ok());
398    }
399
400    #[test]
401    fn config_builder() {
402        let config = TaskConfig::new("cargo")
403            .args(["build", "--release"])
404            .working_dir("/home/user/project")
405            .env([("RUST_LOG", "debug"), ("CARGO_TARGET_DIR", "target")])
406            .timeout_ms(300)
407            .enable_stdin(true);
408
409        assert_eq!(config.command, "cargo");
410        assert_eq!(
411            config.args,
412            Some(vec!["build".to_string(), "--release".to_string()])
413        );
414        assert_eq!(config.working_dir, Some("/home/user/project".to_string()));
415        assert!(config.env.is_some());
416        assert_eq!(config.timeout_ms, Some(300));
417        assert_eq!(config.enable_stdin, Some(true));
418    }
419}