1#![allow(dead_code)]
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct TemplateParam {
17 pub name: String,
19 pub description: String,
21 pub default: Option<String>,
23 pub required: bool,
25}
26
27impl TemplateParam {
28 #[must_use]
30 pub fn required(name: impl Into<String>, description: impl Into<String>) -> Self {
31 Self {
32 name: name.into(),
33 description: description.into(),
34 default: None,
35 required: true,
36 }
37 }
38
39 #[must_use]
41 pub fn optional(
42 name: impl Into<String>,
43 description: impl Into<String>,
44 default: impl Into<String>,
45 ) -> Self {
46 Self {
47 name: name.into(),
48 description: description.into(),
49 default: Some(default.into()),
50 required: false,
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub struct TaskTemplate {
63 pub name: String,
65 pub description: String,
67 pub command_template: String,
69 pub params: Vec<TemplateParam>,
71}
72
73impl TaskTemplate {
74 #[must_use]
76 pub fn new(
77 name: impl Into<String>,
78 description: impl Into<String>,
79 command_template: impl Into<String>,
80 ) -> Self {
81 Self {
82 name: name.into(),
83 description: description.into(),
84 command_template: command_template.into(),
85 params: Vec::new(),
86 }
87 }
88
89 #[must_use]
91 pub fn with_param(mut self, param: TemplateParam) -> Self {
92 self.params.push(param);
93 self
94 }
95
96 #[must_use]
98 pub fn required_params(&self) -> Vec<&str> {
99 self.params
100 .iter()
101 .filter(|p| p.required)
102 .map(|p| p.name.as_str())
103 .collect()
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct InstantiatedTask {
112 pub template_name: String,
114 pub command: String,
116 pub resolved_params: HashMap<String, String>,
118}
119
120#[derive(Debug, Clone, PartialEq)]
124pub enum InstantiateError {
125 MissingParam(String),
127}
128
129impl std::fmt::Display for InstantiateError {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 match self {
132 Self::MissingParam(name) => write!(f, "required parameter '{name}' was not provided"),
133 }
134 }
135}
136
137impl std::error::Error for InstantiateError {}
138
139#[derive(Debug, Default, Clone)]
143pub struct TaskInstantiator;
144
145impl TaskInstantiator {
146 #[must_use]
148 pub fn new() -> Self {
149 Self
150 }
151
152 pub fn instantiate(
164 &self,
165 template: &TaskTemplate,
166 args: &HashMap<String, String>,
167 ) -> Result<InstantiatedTask, InstantiateError> {
168 let mut resolved: HashMap<String, String> = HashMap::new();
169
170 for param in &template.params {
171 let value = if let Some(v) = args.get(¶m.name) {
172 v.clone()
173 } else if let Some(ref default) = param.default {
174 default.clone()
175 } else {
176 return Err(InstantiateError::MissingParam(param.name.clone()));
177 };
178 resolved.insert(param.name.clone(), value);
179 }
180
181 let mut command = template.command_template.clone();
183 for (key, value) in &resolved {
184 let placeholder = format!("{{{{{key}}}}}");
185 command = command.replace(&placeholder, value);
186 }
187
188 for (key, value) in args {
190 if !resolved.contains_key(key) {
191 let placeholder = format!("{{{{{key}}}}}");
192 command = command.replace(&placeholder, value);
193 }
194 }
195
196 Ok(InstantiatedTask {
197 template_name: template.name.clone(),
198 command,
199 resolved_params: resolved,
200 })
201 }
202}
203
204#[cfg(test)]
207mod tests {
208 use super::*;
209
210 fn transcode_template() -> TaskTemplate {
211 TaskTemplate::new(
212 "transcode-h264",
213 "Transcode input to H.264",
214 "ffmpeg -i {{input}} -c:v libx264 -crf {{crf}} {{output}}",
215 )
216 .with_param(TemplateParam::required("input", "Source file path"))
217 .with_param(TemplateParam::required("output", "Destination file path"))
218 .with_param(TemplateParam::optional("crf", "CRF quality value", "23"))
219 }
220
221 #[test]
222 fn test_instantiate_all_provided() {
223 let inst = TaskInstantiator::new();
224 let template = transcode_template();
225 let mut args = HashMap::new();
226 args.insert("input".to_string(), "/src/video.mp4".to_string());
227 args.insert("output".to_string(), "/dst/video.mp4".to_string());
228 args.insert("crf".to_string(), "18".to_string());
229 let result = inst
230 .instantiate(&template, &args)
231 .expect("should succeed in test");
232 assert!(result.command.contains("/src/video.mp4"));
233 assert!(result.command.contains("/dst/video.mp4"));
234 assert!(result.command.contains("18"));
235 }
236
237 #[test]
238 fn test_instantiate_uses_default() {
239 let inst = TaskInstantiator::new();
240 let template = transcode_template();
241 let mut args = HashMap::new();
242 args.insert("input".to_string(), "/src/video.mp4".to_string());
243 args.insert("output".to_string(), "/dst/video.mp4".to_string());
244 let result = inst
245 .instantiate(&template, &args)
246 .expect("should succeed in test");
247 assert_eq!(result.resolved_params["crf"], "23");
248 assert!(result.command.contains("23"));
249 }
250
251 #[test]
252 fn test_instantiate_missing_required_errors() {
253 let inst = TaskInstantiator::new();
254 let template = transcode_template();
255 let args = HashMap::new(); let err = inst.instantiate(&template, &args).unwrap_err();
257 assert!(matches!(err, InstantiateError::MissingParam(_)));
258 }
259
260 #[test]
261 fn test_instantiate_template_name_preserved() {
262 let inst = TaskInstantiator::new();
263 let template = transcode_template();
264 let mut args = HashMap::new();
265 args.insert("input".to_string(), "a".to_string());
266 args.insert("output".to_string(), "b".to_string());
267 let result = inst
268 .instantiate(&template, &args)
269 .expect("should succeed in test");
270 assert_eq!(result.template_name, "transcode-h264");
271 }
272
273 #[test]
274 fn test_required_params_list() {
275 let template = transcode_template();
276 let req = template.required_params();
277 assert!(req.contains(&"input"));
278 assert!(req.contains(&"output"));
279 assert!(!req.contains(&"crf"));
280 }
281
282 #[test]
283 fn test_template_param_required() {
284 let p = TemplateParam::required("src", "Source path");
285 assert!(p.required);
286 assert!(p.default.is_none());
287 }
288
289 #[test]
290 fn test_template_param_optional() {
291 let p = TemplateParam::optional("quality", "Quality", "high");
292 assert!(!p.required);
293 assert_eq!(p.default.as_deref(), Some("high"));
294 }
295
296 #[test]
297 fn test_instantiate_error_display() {
298 let err = InstantiateError::MissingParam("input".to_string());
299 assert!(err.to_string().contains("input"));
300 }
301
302 #[test]
303 fn test_no_placeholders_unchanged() {
304 let inst = TaskInstantiator::new();
305 let template = TaskTemplate::new("noop", "No-op", "echo hello");
306 let args = HashMap::new();
307 let result = inst
308 .instantiate(&template, &args)
309 .expect("should succeed in test");
310 assert_eq!(result.command, "echo hello");
311 }
312
313 #[test]
314 fn test_multiple_same_param_uses() {
315 let inst = TaskInstantiator::new();
316 let template = TaskTemplate::new("copy", "Copy file", "cp {{src}} {{src}}.bak")
317 .with_param(TemplateParam::required("src", "Source"));
318 let mut args = HashMap::new();
319 args.insert("src".to_string(), "/file.txt".to_string());
320 let result = inst
321 .instantiate(&template, &args)
322 .expect("should succeed in test");
323 assert_eq!(result.command, "cp /file.txt /file.txt.bak");
324 }
325
326 #[test]
327 fn test_resolved_params_contains_all_declared() {
328 let inst = TaskInstantiator::new();
329 let template = transcode_template();
330 let mut args = HashMap::new();
331 args.insert("input".to_string(), "in.mp4".to_string());
332 args.insert("output".to_string(), "out.mp4".to_string());
333 let result = inst
334 .instantiate(&template, &args)
335 .expect("should succeed in test");
336 assert!(result.resolved_params.contains_key("crf"));
337 assert!(result.resolved_params.contains_key("input"));
338 assert!(result.resolved_params.contains_key("output"));
339 }
340
341 #[test]
342 fn test_template_with_no_params() {
343 let t = TaskTemplate::new("ping", "Ping host", "ping -c 1 localhost");
344 assert!(t.required_params().is_empty());
345 }
346
347 #[test]
348 fn test_instantiate_second_required_missing() {
349 let inst = TaskInstantiator::new();
350 let template = transcode_template();
351 let mut args = HashMap::new();
352 args.insert("input".to_string(), "in.mp4".to_string());
353 let err = inst.instantiate(&template, &args).unwrap_err();
355 assert!(matches!(err, InstantiateError::MissingParam(ref n) if n == "output"));
356 }
357}