tcrm_task/tasks/
config.rs

1use std::{collections::HashMap, sync::Arc};
2
3use crate::tasks::{error::TaskError, validator::ConfigValidator};
4
5/// Configuration for a task to be executed.
6///
7/// `TaskConfig` defines all parameters needed to execute a system process securely.
8/// It includes the command, arguments, environment setup, timeouts, and monitoring options.
9///
10/// # Examples
11///
12/// ## Basic Command
13/// ```rust
14/// use tcrm_task::tasks::config::TaskConfig;
15///
16/// let config = TaskConfig::new("cmd")
17///     .args(["/C", "dir", "C:\\"]);
18/// ```
19///
20/// ## Complex Configuration
21/// ```rust
22/// use tcrm_task::tasks::config::{TaskConfig, StreamSource};
23/// use std::collections::HashMap;
24///
25/// let mut env = HashMap::new();
26/// env.insert("PATH".to_string(), "C:\\Windows\\System32".to_string());
27///
28/// let config = TaskConfig::new("cmd")
29///     .args(["/C", "echo", "Server started"])
30///     .working_dir("C:\\")
31///     .env(env)
32///     .timeout_ms(30000)
33///     .enable_stdin(true)
34///     .ready_indicator("Server started")
35///     .ready_indicator_source(StreamSource::Stdout);
36/// ```
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38#[derive(Debug, Clone)]
39pub struct TaskConfig {
40    /// The command or executable to run
41    pub command: String,
42
43    /// Arguments to pass to the command
44    pub args: Option<Vec<String>>,
45
46    /// Working directory for the command
47    pub working_dir: Option<String>,
48
49    /// Environment variables for the command
50    pub env: Option<HashMap<String, String>>,
51
52    /// Maximum allowed runtime in milliseconds
53    pub timeout_ms: Option<u64>,
54
55    /// Allow providing input to the task via stdin
56    pub enable_stdin: Option<bool>,
57
58    /// Optional string to indicate the task is ready (for long-running processes like servers)
59    pub ready_indicator: Option<String>,
60
61    /// Source of the ready indicator string (stdout/stderr)
62    pub ready_indicator_source: Option<StreamSource>,
63}
64
65pub type SharedTaskConfig = Arc<TaskConfig>;
66impl Default for TaskConfig {
67    fn default() -> Self {
68        TaskConfig {
69            command: String::new(),
70            args: None,
71            working_dir: None,
72            env: None,
73            timeout_ms: None,
74            enable_stdin: Some(false),
75            ready_indicator: None,
76            ready_indicator_source: Some(StreamSource::Stdout),
77        }
78    }
79}
80
81impl TaskConfig {
82    /// Create a new task configuration with the given command
83    ///
84    /// # Arguments
85    ///
86    /// * `command` - The executable command to run (e.g., "ls", "node", "python")
87    ///
88    /// # Examples
89    /// ```rust
90    /// use tcrm_task::tasks::config::TaskConfig;
91    ///
92    /// let config = TaskConfig::new("echo");
93    /// let config2 = TaskConfig::new("node".to_string());
94    /// ```
95    pub fn new(command: impl Into<String>) -> Self {
96        TaskConfig {
97            command: command.into(),
98            ..Default::default()
99        }
100    }
101
102    /// Set the arguments for the command
103    ///
104    /// # Arguments
105    ///
106    /// * `args` - Iterator of arguments to pass to the command
107    ///
108    /// # Examples
109    /// ```rust
110    /// use tcrm_task::tasks::config::TaskConfig;
111    ///
112    /// let config = TaskConfig::new("ls")
113    ///     .args(["-la", "/tmp"]);
114    ///     
115    /// let config2 = TaskConfig::new("cargo")
116    ///     .args(vec!["build", "--release"]);
117    /// ```
118    #[must_use]
119    pub fn args<I, S>(mut self, args: I) -> Self
120    where
121        I: IntoIterator<Item = S>,
122        S: Into<String>,
123    {
124        self.args = Some(args.into_iter().map(Into::into).collect());
125        self
126    }
127
128    /// Set the working directory for the command
129    ///
130    /// The working directory must exist when the task is executed.
131    ///
132    /// # Arguments
133    ///
134    /// * `dir` - Path to the working directory
135    ///
136    /// # Examples
137    /// ```rust
138    /// use tcrm_task::tasks::config::TaskConfig;
139    ///
140    /// let config = TaskConfig::new("ls")
141    ///     .working_dir("/tmp");
142    ///     
143    /// let config2 = TaskConfig::new("cargo")
144    ///     .working_dir("/path/to/project");
145    /// ```
146    #[must_use]
147    pub fn working_dir(mut self, dir: impl Into<String>) -> Self {
148        self.working_dir = Some(dir.into());
149        self
150    }
151
152    /// Set environment variables for the command
153    ///
154    /// # Arguments
155    ///
156    /// * `env` - Iterator of (key, value) pairs for environment variables
157    ///
158    /// # Examples
159    /// ```rust
160    /// use tcrm_task::tasks::config::TaskConfig;
161    /// use std::collections::HashMap;
162    ///
163    /// // Using tuples
164    /// let config = TaskConfig::new("node")
165    ///     .env([("NODE_ENV", "production"), ("PORT", "3000")]);
166    ///
167    /// // Using HashMap
168    /// let mut env = HashMap::new();
169    /// env.insert("RUST_LOG".to_string(), "debug".to_string());
170    /// let config2 = TaskConfig::new("cargo")
171    ///     .env(env);
172    /// ```
173    #[must_use]
174    pub fn env<K, V, I>(mut self, env: I) -> Self
175    where
176        K: Into<String>,
177        V: Into<String>,
178        I: IntoIterator<Item = (K, V)>,
179    {
180        self.env = Some(env.into_iter().map(|(k, v)| (k.into(), v.into())).collect());
181        self
182    }
183
184    /// Set the maximum allowed runtime in milliseconds
185    ///
186    /// If the task runs longer than this timeout, it will be terminated.
187    ///
188    /// # Arguments
189    ///
190    /// * `timeout` - Timeout in milliseconds (must be > 0)
191    ///
192    /// # Examples
193    /// ```rust
194    /// use tcrm_task::tasks::config::TaskConfig;
195    ///
196    /// // 30 second timeout
197    /// let config = TaskConfig::new("long-running-task")
198    ///     .timeout_ms(30000);
199    ///
200    /// // 5 minute timeout
201    /// let config2 = TaskConfig::new("build-script")
202    ///     .timeout_ms(300000);
203    /// ```
204    #[must_use]
205    pub fn timeout_ms(mut self, timeout: u64) -> Self {
206        self.timeout_ms = Some(timeout);
207        self
208    }
209
210    /// Enable or disable stdin for the task
211    ///
212    /// When enabled, you can send input to the process via the stdin channel.
213    ///
214    /// # Arguments
215    ///
216    /// * `b` - Whether to enable stdin input
217    ///
218    /// # Examples
219    /// ```rust
220    /// use tcrm_task::tasks::config::TaskConfig;
221    ///
222    /// // Interactive command that needs input
223    /// let config = TaskConfig::new("python")
224    ///     .args(["-i"])
225    ///     .enable_stdin(true);
226    /// ```
227    #[must_use]
228    pub fn enable_stdin(mut self, b: bool) -> Self {
229        self.enable_stdin = Some(b);
230        self
231    }
232
233    /// Set the ready indicator for the task
234    ///
235    /// For long-running processes (like servers), this string indicates when
236    /// the process is ready to accept requests. When this string appears in
237    /// the process output, a Ready event will be emitted.
238    ///
239    /// # Arguments
240    ///
241    /// * `indicator` - String to look for in process output
242    ///
243    /// # Examples
244    /// ```rust
245    /// use tcrm_task::tasks::config::TaskConfig;
246    ///
247    /// let config = TaskConfig::new("my-server")
248    ///     .ready_indicator("Server listening on port");
249    ///
250    /// let config2 = TaskConfig::new("database")
251    ///     .ready_indicator("Database ready for connections");
252    /// ```
253    #[must_use]
254    pub fn ready_indicator(mut self, indicator: impl Into<String>) -> Self {
255        self.ready_indicator = Some(indicator.into());
256        self
257    }
258
259    /// Set the source of the ready indicator
260    ///
261    /// Specifies whether to look for the ready indicator in stdout or stderr.
262    ///
263    /// # Arguments
264    ///
265    /// * `source` - Stream source (Stdout or Stderr)
266    ///
267    /// # Examples
268    /// ```rust
269    /// use tcrm_task::tasks::config::{TaskConfig, StreamSource};
270    ///
271    /// let config = TaskConfig::new("my-server")
272    ///     .ready_indicator("Ready")
273    ///     .ready_indicator_source(StreamSource::Stderr);
274    /// ```
275    #[must_use]
276    pub fn ready_indicator_source(mut self, source: StreamSource) -> Self {
277        self.ready_indicator_source = Some(source);
278        self
279    }
280
281    /// Validate the configuration
282    ///
283    /// Validates all configuration parameters.
284    /// This method should be called before executing the task to ensure
285    /// safe operation.
286    ///
287    /// # Validation Checks
288    /// - all fields length limits
289    /// - **Command**: Must not be empty, contain shell injection patterns
290    /// - **Arguments**: Must not contain null bytes or shell injection patterns  
291    /// - **Working Directory**: Must exist and be a valid directory
292    /// - **Environment Variables**: Keys must not contain spaces, '=', or null bytes
293    /// - **Timeout**: Must be greater than 0 if specified
294    /// - **Ready Indicator**: Must not be empty if specified
295    ///
296    /// # Returns
297    ///
298    /// - `Ok(())` if the configuration is valid
299    /// - `Err(TaskError::InvalidConfiguration)` with details if validation fails
300    ///
301    /// # Errors
302    ///
303    /// Returns a [`TaskError`] if any validation check fails:
304    /// - [`TaskError::InvalidConfiguration`] for configuration errors
305    /// - [`TaskError::IO`] for working directory validation failures
306    ///
307    /// # Examples
308    ///
309    /// ```rust
310    /// use tcrm_task::tasks::config::TaskConfig;
311    ///
312    /// // Valid config
313    /// let config = TaskConfig::new("echo")
314    ///     .args(["hello", "world"]);
315    /// assert!(config.validate().is_ok());
316    ///
317    /// // Invalid config (empty command)
318    /// let config = TaskConfig::new("");
319    /// assert!(config.validate().is_err());
320    ///
321    /// // Invalid config (zero timeout)
322    /// let config = TaskConfig::new("sleep")
323    ///     .timeout_ms(0);
324    /// assert!(config.validate().is_err());
325    /// ```
326    pub fn validate(&self) -> Result<(), TaskError> {
327        // Validate command
328        ConfigValidator::validate_command(&self.command)?;
329
330        // Validate ready_indicator
331        if let Some(indicator) = &self.ready_indicator
332            && indicator.is_empty()
333        {
334            return Err(TaskError::InvalidConfiguration(
335                "ready_indicator cannot be empty string".to_string(),
336            ));
337        }
338
339        // Validate arguments
340        if let Some(args) = &self.args {
341            ConfigValidator::validate_args(args)?;
342        }
343
344        // Validate working directory
345        if let Some(dir) = &self.working_dir {
346            ConfigValidator::validate_working_dir(dir)?;
347        }
348
349        // Validate environment variables
350        if let Some(env) = &self.env {
351            ConfigValidator::validate_env_vars(env)?;
352        }
353
354        // Validate timeout
355        if let Some(timeout) = self.timeout_ms
356            && timeout == 0
357        {
358            return Err(TaskError::InvalidConfiguration(
359                "Timeout must be greater than 0".to_string(),
360            ));
361        }
362
363        Ok(())
364    }
365}
366
367/// Specifies the source stream for output monitoring
368///
369/// Used with ready indicators to specify whether to monitor stdout or stderr
370/// for the ready signal from long-running processes.
371///
372/// # Examples
373///
374/// ```rust
375/// use tcrm_task::tasks::config::{TaskConfig, StreamSource};
376///
377/// // Monitor stdout for ready signal
378/// let config = TaskConfig::new("web-server")
379///     .ready_indicator("Server ready")
380///     .ready_indicator_source(StreamSource::Stdout);
381///
382/// // Monitor stderr for ready signal  
383/// let config2 = TaskConfig::new("database")
384///     .ready_indicator("Ready for connections")
385///     .ready_indicator_source(StreamSource::Stderr);
386/// ```
387#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
388#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
389#[derive(Debug, Clone, PartialEq)]
390pub enum StreamSource {
391    /// Standard output stream
392    Stdout = 0,
393    /// Standard error stream  
394    Stderr = 1,
395}
396impl Default for StreamSource {
397    fn default() -> Self {
398        Self::Stdout
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use std::{collections::HashMap, env::temp_dir};
405
406    use crate::tasks::{config::TaskConfig, error::TaskError};
407
408    #[test]
409    fn validation() {
410        // Valid config
411        let config = TaskConfig::new("echo").args(["hello"]);
412        assert!(config.validate().is_ok());
413
414        // Empty command should fail
415        let config = TaskConfig::new("");
416        assert!(matches!(
417            config.validate(),
418            Err(TaskError::InvalidConfiguration(_))
419        ));
420
421        // Command with leading/trailing whitespace should fail
422        let config = TaskConfig::new("  echo  ");
423        assert!(matches!(
424            config.validate(),
425            Err(TaskError::InvalidConfiguration(_))
426        ));
427
428        // Command exceeding max length should fail
429        let long_cmd = "a".repeat(4097);
430        let config = TaskConfig::new(long_cmd);
431        assert!(matches!(
432            config.validate(),
433            Err(TaskError::InvalidConfiguration(_))
434        ));
435
436        // Zero timeout should fail
437        let config = TaskConfig::new("echo").timeout_ms(0);
438        assert!(matches!(
439            config.validate(),
440            Err(TaskError::InvalidConfiguration(_))
441        ));
442
443        // Valid timeout should pass
444        let config = TaskConfig::new("echo").timeout_ms(30);
445        assert!(config.validate().is_ok());
446
447        // Arguments with empty string should fail
448        let config = TaskConfig::new("echo").args([""]);
449        assert!(matches!(
450            config.validate(),
451            Err(TaskError::InvalidConfiguration(_))
452        ));
453
454        // Argument with leading/trailing whitespace should fail
455        let config = TaskConfig::new("echo").args([" hello "]);
456        assert!(matches!(
457            config.validate(),
458            Err(TaskError::InvalidConfiguration(_))
459        ));
460
461        // Argument exceeding max length should fail
462        let long_arg = "a".repeat(4097);
463        let config = TaskConfig::new("echo").args([long_arg]);
464        assert!(matches!(
465            config.validate(),
466            Err(TaskError::InvalidConfiguration(_))
467        ));
468
469        // Working directory that does not exist should fail
470        let config = TaskConfig::new("echo").working_dir("/non/existent/dir");
471        assert!(matches!(
472            config.validate(),
473            Err(TaskError::InvalidConfiguration(_))
474        ));
475
476        // Working directory with temp dir should pass
477        let dir = temp_dir();
478        let config = TaskConfig::new("echo").working_dir(dir.as_path().to_str().unwrap());
479        assert!(config.validate().is_ok());
480
481        // Working directory with whitespace should fail
482        let dir = temp_dir();
483        let dir_str = format!(" {} ", dir.as_path().to_str().unwrap());
484        let config = TaskConfig::new("echo").working_dir(&dir_str);
485        assert!(matches!(
486            config.validate(),
487            Err(TaskError::InvalidConfiguration(_))
488        ));
489
490        // Environment variable with empty key should fail
491        let mut env = HashMap::new();
492        env.insert(String::new(), "value".to_string());
493        let config = TaskConfig::new("echo").env(env);
494        assert!(matches!(
495            config.validate(),
496            Err(TaskError::InvalidConfiguration(_))
497        ));
498
499        // Environment variable with space in key should fail
500        let mut env = HashMap::new();
501        env.insert("KEY WITH SPACE".to_string(), "value".to_string());
502        let config = TaskConfig::new("echo").env(env);
503        assert!(matches!(
504            config.validate(),
505            Err(TaskError::InvalidConfiguration(_))
506        ));
507
508        // Environment variable with '=' in key should fail
509        let mut env = HashMap::new();
510        env.insert("KEY=BAD".to_string(), "value".to_string());
511        let config = TaskConfig::new("echo").env(env);
512        assert!(matches!(
513            config.validate(),
514            Err(TaskError::InvalidConfiguration(_))
515        ));
516
517        // Environment variable key exceeding max length should fail
518        let mut env = HashMap::new();
519        env.insert("A".repeat(1025), "value".to_string());
520        let config = TaskConfig::new("echo").env(env);
521        assert!(matches!(
522            config.validate(),
523            Err(TaskError::InvalidConfiguration(_))
524        ));
525
526        // Environment variable value with whitespace should fail
527        let mut env = HashMap::new();
528        env.insert("KEY".to_string(), " value ".to_string());
529        let config = TaskConfig::new("echo").env(env);
530        assert!(matches!(
531            config.validate(),
532            Err(TaskError::InvalidConfiguration(_))
533        ));
534
535        // Environment variable value exceeding max length should fail
536        let mut env = HashMap::new();
537        env.insert("KEY".to_string(), "A".repeat(4097));
538        let config = TaskConfig::new("echo").env(env);
539        assert!(matches!(
540            config.validate(),
541            Err(TaskError::InvalidConfiguration(_))
542        ));
543
544        // Environment variable key/value valid should pass
545        let mut env = HashMap::new();
546        env.insert("KEY".to_string(), "some value".to_string());
547        let config = TaskConfig::new("echo").env(env);
548        assert!(config.validate().is_ok());
549
550        // ready_indicator: empty string should fail
551        let mut config = TaskConfig::new("echo");
552        config.ready_indicator = Some(String::new());
553        assert!(matches!(
554            config.validate(),
555            Err(TaskError::InvalidConfiguration(_))
556        ));
557
558        // ready_indicator: leading/trailing spaces should pass
559        let mut config = TaskConfig::new("echo");
560        config.ready_indicator = Some("  READY  ".to_string());
561        assert!(config.validate().is_ok());
562
563        // ready_indicator: normal string should pass
564        let mut config = TaskConfig::new("echo");
565        config.ready_indicator = Some("READY".to_string());
566        assert!(config.validate().is_ok());
567    }
568    #[test]
569    fn config_builder() {
570        let config = TaskConfig::new("cargo")
571            .args(["build", "--release"])
572            .working_dir("/home/user/project")
573            .env([("RUST_LOG", "debug"), ("CARGO_TARGET_DIR", "target")])
574            .timeout_ms(300)
575            .enable_stdin(true);
576
577        assert_eq!(config.command, "cargo");
578        assert_eq!(
579            config.args,
580            Some(vec!["build".to_string(), "--release".to_string()])
581        );
582        assert_eq!(config.working_dir, Some("/home/user/project".to_string()));
583        assert!(config.env.is_some());
584        assert_eq!(config.timeout_ms, Some(300));
585        assert_eq!(config.enable_stdin, Some(true));
586    }
587}