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