Skip to main content

oximedia_workflow/
task_template.rs

1//! Task template instantiation for `oximedia-workflow`.
2//!
3//! [`TaskTemplate`] stores a parameterised task blueprint; [`TaskInstantiator`]
4//! resolves the template parameters against a provided key→value map and
5//! returns a concrete [`InstantiatedTask`] ready for execution.
6
7#![allow(dead_code)]
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12// ── Template parameter ────────────────────────────────────────────────────────
13
14/// A single named parameter that can be substituted into a task template.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct TemplateParam {
17    /// Parameter name used as the substitution key (`{{name}}`).
18    pub name: String,
19    /// Human-readable description shown in documentation / UIs.
20    pub description: String,
21    /// Optional default value used when the caller does not provide one.
22    pub default: Option<String>,
23    /// Whether this parameter must be supplied (no default).
24    pub required: bool,
25}
26
27impl TemplateParam {
28    /// Creates a required parameter with no default.
29    #[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    /// Creates an optional parameter with a default value.
40    #[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// ── Task template ─────────────────────────────────────────────────────────────
56
57/// A reusable task blueprint with substitutable `{{parameter}}` placeholders.
58///
59/// Parameter placeholders inside `command_template` use double-brace syntax:
60/// `{{param_name}}`.
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub struct TaskTemplate {
63    /// Unique name for this template (e.g. `"transcode-h264"`).
64    pub name: String,
65    /// Human-readable description of what this template does.
66    pub description: String,
67    /// Command string with `{{param}}` placeholders.
68    pub command_template: String,
69    /// Declared parameters for this template.
70    pub params: Vec<TemplateParam>,
71}
72
73impl TaskTemplate {
74    /// Creates a new task template.
75    #[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    /// Adds a parameter declaration to this template.
90    #[must_use]
91    pub fn with_param(mut self, param: TemplateParam) -> Self {
92        self.params.push(param);
93        self
94    }
95
96    /// Returns the names of all required (no default) parameters.
97    #[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// ── Instantiated task ─────────────────────────────────────────────────────────
108
109/// A fully resolved task produced by [`TaskInstantiator::instantiate`].
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct InstantiatedTask {
112    /// Name of the template that was used.
113    pub template_name: String,
114    /// Resolved command with all placeholders substituted.
115    pub command: String,
116    /// The parameter values actually used (including defaults).
117    pub resolved_params: HashMap<String, String>,
118}
119
120// ── Errors ────────────────────────────────────────────────────────────────────
121
122/// Errors that can occur during template instantiation.
123#[derive(Debug, Clone, PartialEq)]
124pub enum InstantiateError {
125    /// A required parameter was not provided and has no default.
126    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// ── Instantiator ──────────────────────────────────────────────────────────────
140
141/// Resolves [`TaskTemplate`] placeholders against caller-supplied values.
142#[derive(Debug, Default, Clone)]
143pub struct TaskInstantiator;
144
145impl TaskInstantiator {
146    /// Creates a new instantiator.
147    #[must_use]
148    pub fn new() -> Self {
149        Self
150    }
151
152    /// Instantiates `template` using the provided `args` map.
153    ///
154    /// Parameter resolution order:
155    /// 1. Value in `args`.
156    /// 2. Parameter `default` (if present).
157    /// 3. Error – [`InstantiateError::MissingParam`].
158    ///
159    /// # Errors
160    ///
161    /// Returns [`InstantiateError::MissingParam`] if a required parameter has
162    /// no value in `args` and no declared default.
163    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(&param.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        // Substitute placeholders in the command
182        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        // Also substitute any args that weren't declared as formal params
189        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// ── Tests ─────────────────────────────────────────────────────────────────────
205
206#[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(); // missing 'input' and 'output'
256        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        // 'output' missing
354        let err = inst.instantiate(&template, &args).unwrap_err();
355        assert!(matches!(err, InstantiateError::MissingParam(ref n) if n == "output"));
356    }
357}