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}