tcrm_task/tasks/
config.rs

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