1use serde::{Deserialize, Serialize};
2use std::{collections::HashMap, sync::Arc};
3
4use crate::tasks::error::TaskError;
5
6#[derive(Debug, Deserialize, Serialize, Clone)]
8pub struct TaskConfig {
9 pub command: String,
11
12 pub args: Option<Vec<String>>,
14
15 pub working_dir: Option<String>,
17
18 pub env: Option<HashMap<String, String>>,
20
21 pub timeout_ms: Option<u64>,
23
24 pub enable_stdin: Option<bool>,
26}
27
28pub type SharedTaskConfig = Arc<TaskConfig>;
29impl Default for TaskConfig {
30 fn default() -> Self {
31 TaskConfig {
32 command: String::new(),
33 args: None,
34 working_dir: None,
35 env: None,
36 timeout_ms: None,
37 enable_stdin: Some(false),
38 }
39 }
40}
41
42impl TaskConfig {
43 pub fn new(command: impl Into<String>) -> Self {
52 TaskConfig {
53 command: command.into(),
54 ..Default::default()
55 }
56 }
57
58 pub fn args<I, S>(mut self, args: I) -> Self
60 where
61 I: IntoIterator<Item = S>,
62 S: Into<String>,
63 {
64 self.args = Some(args.into_iter().map(Into::into).collect());
65 self
66 }
67
68 pub fn working_dir(mut self, dir: impl Into<String>) -> Self {
70 self.working_dir = Some(dir.into());
71 self
72 }
73
74 pub fn env<K, V, I>(mut self, env: I) -> Self
76 where
77 K: Into<String>,
78 V: Into<String>,
79 I: IntoIterator<Item = (K, V)>,
80 {
81 self.env = Some(env.into_iter().map(|(k, v)| (k.into(), v.into())).collect());
82 self
83 }
84
85 pub fn timeout_ms(mut self, timeout: u64) -> Self {
87 self.timeout_ms = Some(timeout);
88 self
89 }
90 pub fn enable_stdin(mut self, b: bool) -> Self {
92 self.enable_stdin = Some(b);
93 self
94 }
95
96 pub fn validate(&self) -> Result<(), TaskError> {
112 const MAX_COMMAND_LEN: usize = 4096;
113 const MAX_ARG_LEN: usize = 4096;
114 const MAX_WORKING_DIR_LEN: usize = 4096;
115 const MAX_ENV_KEY_LEN: usize = 1024;
116 const MAX_ENV_VALUE_LEN: usize = 4096;
117
118 if self.command.is_empty() {
120 return Err(TaskError::InvalidConfiguration(
121 "Command cannot be empty".to_string(),
122 ));
123 }
124 if self.command.trim() != self.command {
125 return Err(TaskError::InvalidConfiguration(
126 "Command cannot have leading or trailing whitespace".to_string(),
127 ));
128 }
129 if self.command.len() > MAX_COMMAND_LEN {
130 return Err(TaskError::InvalidConfiguration(
131 "Command length exceeds maximum allowed length".to_string(),
132 ));
133 }
134
135 if let Some(args) = &self.args {
137 for arg in args {
138 if arg.is_empty() {
139 return Err(TaskError::InvalidConfiguration(
140 "Arguments cannot be empty".to_string(),
141 ));
142 }
143 if arg.trim() != arg {
144 return Err(TaskError::InvalidConfiguration(format!(
145 "Argument '{}' cannot have leading/trailing whitespace",
146 arg
147 )));
148 }
149 if arg.len() > MAX_ARG_LEN {
150 return Err(TaskError::InvalidConfiguration(format!(
151 "Argument '{}' exceeds maximum length",
152 arg
153 )));
154 }
155 }
156 }
157
158 if let Some(dir) = &self.working_dir {
160 let path = std::path::Path::new(dir);
161 if !path.exists() {
162 return Err(TaskError::InvalidConfiguration(format!(
163 "Working directory '{}' does not exist",
164 dir
165 )));
166 }
167 if !path.is_dir() {
168 return Err(TaskError::InvalidConfiguration(format!(
169 "Working directory '{}' is not a directory",
170 dir
171 )));
172 }
173 if dir.trim() != dir {
174 return Err(TaskError::InvalidConfiguration(
175 "Working directory cannot have leading/trailing whitespace".to_string(),
176 ));
177 }
178 if dir.len() > MAX_WORKING_DIR_LEN {
179 return Err(TaskError::InvalidConfiguration(
180 "Working directory path exceeds maximum length".to_string(),
181 ));
182 }
183 }
184
185 if let Some(env) = &self.env {
187 for (k, v) in env {
188 if k.is_empty() {
189 return Err(TaskError::InvalidConfiguration(
190 "Environment variable key cannot be empty".to_string(),
191 ));
192 }
193 if k.contains('=') {
194 return Err(TaskError::InvalidConfiguration(format!(
195 "Environment variable key '{}' cannot contain '='",
196 k
197 )));
198 }
199 if k.contains(' ') {
200 return Err(TaskError::InvalidConfiguration(format!(
201 "Environment variable key '{}' cannot contain spaces",
202 k
203 )));
204 }
205 if k.len() > MAX_ENV_KEY_LEN {
206 return Err(TaskError::InvalidConfiguration(format!(
207 "Environment variable key '{}' exceeds maximum length",
208 k
209 )));
210 }
211 if v.trim() != v {
212 return Err(TaskError::InvalidConfiguration(format!(
213 "Environment variable '{}' value cannot have leading/trailing whitespace",
214 k
215 )));
216 }
217 if v.len() > MAX_ENV_VALUE_LEN {
218 return Err(TaskError::InvalidConfiguration(format!(
219 "Environment variable '{}' value exceeds maximum length",
220 k
221 )));
222 }
223 }
224 }
225
226 if let Some(timeout) = self.timeout_ms {
228 if timeout == 0 {
229 return Err(TaskError::InvalidConfiguration(
230 "Timeout must be greater than 0".to_string(),
231 ));
232 }
233 }
234
235 Ok(())
236 }
237}
238
239#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
240#[serde(rename_all = "lowercase")]
241pub enum StreamSource {
242 Stdout = 0,
243 Stderr = 1,
244}
245impl Default for StreamSource {
246 fn default() -> Self {
247 Self::Stdout
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use std::{collections::HashMap, env::temp_dir};
254
255 use crate::tasks::{config::TaskConfig, error::TaskError};
256
257 #[test]
258 fn validation() {
259 let config = TaskConfig::new("echo").args(["hello"]);
261 assert!(config.validate().is_ok());
262
263 let config = TaskConfig::new("");
265 assert!(matches!(
266 config.validate(),
267 Err(TaskError::InvalidConfiguration(_))
268 ));
269
270 let config = TaskConfig::new(" echo ");
272 assert!(matches!(
273 config.validate(),
274 Err(TaskError::InvalidConfiguration(_))
275 ));
276
277 let long_cmd = "a".repeat(4097);
279 let config = TaskConfig::new(long_cmd);
280 assert!(matches!(
281 config.validate(),
282 Err(TaskError::InvalidConfiguration(_))
283 ));
284
285 let config = TaskConfig::new("echo").timeout_ms(0);
287 assert!(matches!(
288 config.validate(),
289 Err(TaskError::InvalidConfiguration(_))
290 ));
291
292 let config = TaskConfig::new("echo").timeout_ms(30);
294 assert!(config.validate().is_ok());
295
296 let config = TaskConfig::new("echo").args([""]);
298 assert!(matches!(
299 config.validate(),
300 Err(TaskError::InvalidConfiguration(_))
301 ));
302
303 let config = TaskConfig::new("echo").args([" hello "]);
305 assert!(matches!(
306 config.validate(),
307 Err(TaskError::InvalidConfiguration(_))
308 ));
309
310 let long_arg = "a".repeat(4097);
312 let config = TaskConfig::new("echo").args([long_arg]);
313 assert!(matches!(
314 config.validate(),
315 Err(TaskError::InvalidConfiguration(_))
316 ));
317
318 let config = TaskConfig::new("echo").working_dir("/non/existent/dir");
320 assert!(matches!(
321 config.validate(),
322 Err(TaskError::InvalidConfiguration(_))
323 ));
324
325 let dir = temp_dir();
327 let config = TaskConfig::new("echo").working_dir(dir.as_path().to_str().unwrap());
328 assert!(config.validate().is_ok());
329
330 let dir = temp_dir();
332 let dir_str = format!(" {} ", dir.as_path().to_str().unwrap());
333 let config = TaskConfig::new("echo").working_dir(&dir_str);
334 assert!(matches!(
335 config.validate(),
336 Err(TaskError::InvalidConfiguration(_))
337 ));
338
339 let mut env = HashMap::new();
341 env.insert(String::new(), "value".to_string());
342 let config = TaskConfig::new("echo").env(env);
343 assert!(matches!(
344 config.validate(),
345 Err(TaskError::InvalidConfiguration(_))
346 ));
347
348 let mut env = HashMap::new();
350 env.insert("KEY WITH SPACE".to_string(), "value".to_string());
351 let config = TaskConfig::new("echo").env(env);
352 assert!(matches!(
353 config.validate(),
354 Err(TaskError::InvalidConfiguration(_))
355 ));
356
357 let mut env = HashMap::new();
359 env.insert("KEY=BAD".to_string(), "value".to_string());
360 let config = TaskConfig::new("echo").env(env);
361 assert!(matches!(
362 config.validate(),
363 Err(TaskError::InvalidConfiguration(_))
364 ));
365
366 let mut env = HashMap::new();
368 env.insert("A".repeat(1025), "value".to_string());
369 let config = TaskConfig::new("echo").env(env);
370 assert!(matches!(
371 config.validate(),
372 Err(TaskError::InvalidConfiguration(_))
373 ));
374
375 let mut env = HashMap::new();
377 env.insert("KEY".to_string(), " value ".to_string());
378 let config = TaskConfig::new("echo").env(env);
379 assert!(matches!(
380 config.validate(),
381 Err(TaskError::InvalidConfiguration(_))
382 ));
383
384 let mut env = HashMap::new();
386 env.insert("KEY".to_string(), "A".repeat(4097));
387 let config = TaskConfig::new("echo").env(env);
388 assert!(matches!(
389 config.validate(),
390 Err(TaskError::InvalidConfiguration(_))
391 ));
392
393 let mut env = HashMap::new();
395 env.insert("KEY".to_string(), "some value".to_string());
396 let config = TaskConfig::new("echo").env(env);
397 assert!(config.validate().is_ok());
398 }
399
400 #[test]
401 fn config_builder() {
402 let config = TaskConfig::new("cargo")
403 .args(["build", "--release"])
404 .working_dir("/home/user/project")
405 .env([("RUST_LOG", "debug"), ("CARGO_TARGET_DIR", "target")])
406 .timeout_ms(300)
407 .enable_stdin(true);
408
409 assert_eq!(config.command, "cargo");
410 assert_eq!(
411 config.args,
412 Some(vec!["build".to_string(), "--release".to_string()])
413 );
414 assert_eq!(config.working_dir, Some("/home/user/project".to_string()));
415 assert!(config.env.is_some());
416 assert_eq!(config.timeout_ms, Some(300));
417 assert_eq!(config.enable_stdin, Some(true));
418 }
419}