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}