1use std::{collections::HashMap, sync::Arc};
2
3use crate::tasks::error::TaskError;
4#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
6#[derive(Debug, Clone)]
7pub struct TaskConfig {
8 pub command: String,
10
11 pub args: Option<Vec<String>>,
13
14 pub working_dir: Option<String>,
16
17 pub env: Option<HashMap<String, String>>,
19
20 pub timeout_ms: Option<u64>,
22
23 pub enable_stdin: Option<bool>,
25
26 pub ready_indicator: Option<String>,
28
29 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 pub fn new(command: impl Into<String>) -> Self {
59 TaskConfig {
60 command: command.into(),
61 ..Default::default()
62 }
63 }
64
65 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 pub fn working_dir(mut self, dir: impl Into<String>) -> Self {
77 self.working_dir = Some(dir.into());
78 self
79 }
80
81 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 pub fn timeout_ms(mut self, timeout: u64) -> Self {
94 self.timeout_ms = Some(timeout);
95 self
96 }
97 pub fn enable_stdin(mut self, b: bool) -> Self {
99 self.enable_stdin = Some(b);
100 self
101 }
102 pub fn ready_indicator(mut self, indicator: impl Into<String>) -> Self {
104 self.ready_indicator = Some(indicator.into());
105 self
106 }
107
108 pub fn ready_indicator_source(mut self, source: StreamSource) -> Self {
110 self.ready_indicator_source = Some(source);
111 self
112 }
113
114 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 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 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 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 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 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 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 let config = TaskConfig::new("echo").args(["hello"]);
289 assert!(config.validate().is_ok());
290
291 let config = TaskConfig::new("");
293 assert!(matches!(
294 config.validate(),
295 Err(TaskError::InvalidConfiguration(_))
296 ));
297
298 let config = TaskConfig::new(" echo ");
300 assert!(matches!(
301 config.validate(),
302 Err(TaskError::InvalidConfiguration(_))
303 ));
304
305 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 let config = TaskConfig::new("echo").timeout_ms(0);
315 assert!(matches!(
316 config.validate(),
317 Err(TaskError::InvalidConfiguration(_))
318 ));
319
320 let config = TaskConfig::new("echo").timeout_ms(30);
322 assert!(config.validate().is_ok());
323
324 let config = TaskConfig::new("echo").args([""]);
326 assert!(matches!(
327 config.validate(),
328 Err(TaskError::InvalidConfiguration(_))
329 ));
330
331 let config = TaskConfig::new("echo").args([" hello "]);
333 assert!(matches!(
334 config.validate(),
335 Err(TaskError::InvalidConfiguration(_))
336 ));
337
338 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 let config = TaskConfig::new("echo").working_dir("/non/existent/dir");
348 assert!(matches!(
349 config.validate(),
350 Err(TaskError::InvalidConfiguration(_))
351 ));
352
353 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 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 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 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 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 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 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 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 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 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 let mut config = TaskConfig::new("echo");
437 config.ready_indicator = Some(" READY ".to_string());
438 assert!(config.validate().is_ok());
439
440 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}