Skip to main content

oximedia_workflow/
templates.rs

1//! Workflow template library.
2//!
3//! Provides pre-built workflow templates for common media processing pipelines
4//! such as ingest-and-transcode, archive, and QC review workflows.
5
6#![allow(dead_code)]
7
8use std::collections::HashMap;
9
10/// Category of workflow template.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum TemplateCategory {
13    /// Ingest workflows (file watch, validation, ingestion).
14    Ingest,
15    /// Transcode workflows.
16    Transcode,
17    /// Delivery workflows (packaging, distribution).
18    Delivery,
19    /// Archive workflows.
20    Archive,
21    /// Quality control workflows.
22    QC,
23    /// Distribution workflows (CDN upload, broadcast).
24    Distribution,
25}
26
27/// Parameter type for template parameters.
28#[derive(Debug, Clone, PartialEq)]
29pub enum ParamType {
30    /// String value.
31    String,
32    /// Integer value.
33    Integer,
34    /// Floating-point value.
35    Float,
36    /// Boolean value.
37    Bool,
38    /// File path value.
39    FilePath,
40    /// Enumeration (one of the provided options).
41    Enum(Vec<std::string::String>),
42}
43
44/// Template parameter definition.
45#[derive(Debug, Clone)]
46pub struct TemplateParam {
47    /// Parameter name.
48    pub name: std::string::String,
49    /// Parameter type.
50    pub param_type: ParamType,
51    /// Default value (as string representation).
52    pub default_value: Option<std::string::String>,
53    /// Whether this parameter is required.
54    pub required: bool,
55}
56
57impl TemplateParam {
58    /// Create a required parameter.
59    #[must_use]
60    pub fn required(name: impl Into<std::string::String>, param_type: ParamType) -> Self {
61        Self {
62            name: name.into(),
63            param_type,
64            default_value: None,
65            required: true,
66        }
67    }
68
69    /// Create an optional parameter with a default.
70    #[must_use]
71    pub fn optional(
72        name: impl Into<std::string::String>,
73        param_type: ParamType,
74        default_value: impl Into<std::string::String>,
75    ) -> Self {
76        Self {
77            name: name.into(),
78            param_type,
79            default_value: Some(default_value.into()),
80            required: false,
81        }
82    }
83}
84
85/// Action to take when a step fails.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum ErrorAction {
88    /// Fail the entire workflow.
89    Fail,
90    /// Retry the step up to N times.
91    Retry(u32),
92    /// Skip this step and continue.
93    Skip,
94    /// Send a notification and continue.
95    Notify,
96}
97
98/// A single step in a workflow template.
99#[derive(Debug, Clone)]
100pub struct TemplateStep {
101    /// Step type identifier (e.g. "validate", "transcode", "deliver").
102    pub step_type: std::string::String,
103    /// Parameters for this step.
104    pub params: HashMap<std::string::String, std::string::String>,
105    /// Action to take on error.
106    pub on_error: ErrorAction,
107}
108
109impl TemplateStep {
110    /// Create a new template step.
111    #[must_use]
112    pub fn new(step_type: impl Into<std::string::String>, on_error: ErrorAction) -> Self {
113        Self {
114            step_type: step_type.into(),
115            params: HashMap::new(),
116            on_error,
117        }
118    }
119
120    /// Add a parameter to the step.
121    #[must_use]
122    pub fn with_param(
123        mut self,
124        key: impl Into<std::string::String>,
125        value: impl Into<std::string::String>,
126    ) -> Self {
127        self.params.insert(key.into(), value.into());
128        self
129    }
130}
131
132/// A workflow template.
133#[derive(Debug, Clone)]
134pub struct WorkflowTemplate {
135    /// Template name.
136    pub name: std::string::String,
137    /// Template description.
138    pub description: std::string::String,
139    /// Template category.
140    pub category: TemplateCategory,
141    /// Template parameters.
142    pub parameters: Vec<TemplateParam>,
143    /// Template steps.
144    pub steps: Vec<TemplateStep>,
145}
146
147impl WorkflowTemplate {
148    /// Create a new workflow template.
149    #[must_use]
150    pub fn new(
151        name: impl Into<std::string::String>,
152        description: impl Into<std::string::String>,
153        category: TemplateCategory,
154    ) -> Self {
155        Self {
156            name: name.into(),
157            description: description.into(),
158            category,
159            parameters: Vec::new(),
160            steps: Vec::new(),
161        }
162    }
163
164    /// Add a parameter definition.
165    #[must_use]
166    pub fn with_parameter(mut self, param: TemplateParam) -> Self {
167        self.parameters.push(param);
168        self
169    }
170
171    /// Add a step.
172    #[must_use]
173    pub fn with_step(mut self, step: TemplateStep) -> Self {
174        self.steps.push(step);
175        self
176    }
177}
178
179/// Library of pre-built workflow templates.
180pub struct TemplateLibrary;
181
182impl TemplateLibrary {
183    /// Ingest and transcode template.
184    ///
185    /// Steps: watch folder → validate → transcode → deliver.
186    #[must_use]
187    pub fn ingest_and_transcode() -> WorkflowTemplate {
188        WorkflowTemplate::new(
189            "ingest-and-transcode",
190            "Watch folder for incoming media, validate, transcode, and deliver",
191            TemplateCategory::Ingest,
192        )
193        .with_parameter(TemplateParam::required("watch_folder", ParamType::FilePath))
194        .with_parameter(TemplateParam::required(
195            "output_folder",
196            ParamType::FilePath,
197        ))
198        .with_parameter(TemplateParam::optional(
199            "transcode_preset",
200            ParamType::Enum(vec![
201                "broadcast_hd".to_string(),
202                "web_h264".to_string(),
203                "archive_prores".to_string(),
204            ]),
205            "web_h264",
206        ))
207        .with_parameter(TemplateParam::optional(
208            "validate_on_ingest",
209            ParamType::Bool,
210            "true",
211        ))
212        .with_step(
213            TemplateStep::new("watch_folder", ErrorAction::Fail)
214                .with_param("path", "{{watch_folder}}")
215                .with_param("pattern", "*.{mxf,mp4,mov}"),
216        )
217        .with_step(
218            TemplateStep::new("validate", ErrorAction::Retry(2))
219                .with_param("enabled", "{{validate_on_ingest}}")
220                .with_param("checks", "format,integrity,container"),
221        )
222        .with_step(
223            TemplateStep::new("transcode", ErrorAction::Retry(1))
224                .with_param("preset", "{{transcode_preset}}")
225                .with_param("output", "{{output_folder}}"),
226        )
227        .with_step(
228            TemplateStep::new("deliver", ErrorAction::Notify)
229                .with_param("destination", "{{output_folder}}")
230                .with_param("verify_checksum", "true"),
231        )
232    }
233
234    /// Archive workflow template.
235    ///
236    /// Steps: validate → checksum → archive → notify.
237    #[must_use]
238    pub fn archive_workflow() -> WorkflowTemplate {
239        WorkflowTemplate::new(
240            "archive-workflow",
241            "Validate media, compute checksums, archive to long-term storage, and notify",
242            TemplateCategory::Archive,
243        )
244        .with_parameter(TemplateParam::required("source_path", ParamType::FilePath))
245        .with_parameter(TemplateParam::required(
246            "archive_destination",
247            ParamType::FilePath,
248        ))
249        .with_parameter(TemplateParam::optional(
250            "checksum_algorithm",
251            ParamType::Enum(vec![
252                "md5".to_string(),
253                "sha256".to_string(),
254                "xxhash".to_string(),
255            ]),
256            "sha256",
257        ))
258        .with_parameter(TemplateParam::optional(
259            "notify_email",
260            ParamType::String,
261            "",
262        ))
263        .with_step(
264            TemplateStep::new("validate", ErrorAction::Fail)
265                .with_param("source", "{{source_path}}")
266                .with_param("strict", "true"),
267        )
268        .with_step(
269            TemplateStep::new("checksum", ErrorAction::Fail)
270                .with_param("algorithm", "{{checksum_algorithm}}")
271                .with_param("output_manifest", "true"),
272        )
273        .with_step(
274            TemplateStep::new("archive", ErrorAction::Retry(3))
275                .with_param("destination", "{{archive_destination}}")
276                .with_param("verify_after_copy", "true"),
277        )
278        .with_step(
279            TemplateStep::new("notify", ErrorAction::Skip)
280                .with_param("channel", "email")
281                .with_param("to", "{{notify_email}}")
282                .with_param("message", "Archive complete"),
283        )
284    }
285
286    /// QC review workflow template.
287    ///
288    /// Steps: analyze → report → approve/reject.
289    #[must_use]
290    pub fn qc_review() -> WorkflowTemplate {
291        WorkflowTemplate::new(
292            "qc-review",
293            "Automated QC analysis, report generation, and approve/reject decision",
294            TemplateCategory::QC,
295        )
296        .with_parameter(TemplateParam::required("media_path", ParamType::FilePath))
297        .with_parameter(TemplateParam::optional(
298            "qc_profile",
299            ParamType::Enum(vec![
300                "broadcast".to_string(),
301                "streaming".to_string(),
302                "cinema".to_string(),
303            ]),
304            "broadcast",
305        ))
306        .with_parameter(TemplateParam::optional(
307            "report_output",
308            ParamType::FilePath,
309            "/tmp/qc_report.json",
310        ))
311        .with_parameter(TemplateParam::optional(
312            "auto_approve_threshold",
313            ParamType::Float,
314            "0.95",
315        ))
316        .with_step(
317            TemplateStep::new("analyze", ErrorAction::Fail)
318                .with_param("input", "{{media_path}}")
319                .with_param("profile", "{{qc_profile}}")
320                .with_param("checks", "loudness,black_frames,freeze,color_bars"),
321        )
322        .with_step(
323            TemplateStep::new("generate_report", ErrorAction::Notify)
324                .with_param("output", "{{report_output}}")
325                .with_param("format", "json"),
326        )
327        .with_step(
328            TemplateStep::new("approve_or_reject", ErrorAction::Fail)
329                .with_param("auto_threshold", "{{auto_approve_threshold}}")
330                .with_param("manual_review_on_fail", "true"),
331        )
332    }
333
334    /// Get all built-in templates.
335    #[must_use]
336    pub fn all() -> Vec<WorkflowTemplate> {
337        vec![
338            Self::ingest_and_transcode(),
339            Self::archive_workflow(),
340            Self::qc_review(),
341        ]
342    }
343
344    /// Find a template by name.
345    #[must_use]
346    pub fn find_by_name(name: &str) -> Option<WorkflowTemplate> {
347        Self::all().into_iter().find(|t| t.name == name)
348    }
349
350    /// Find templates by category.
351    #[must_use]
352    pub fn find_by_category(category: &TemplateCategory) -> Vec<WorkflowTemplate> {
353        Self::all()
354            .into_iter()
355            .filter(|t| &t.category == category)
356            .collect()
357    }
358}
359
360/// Instantiates a workflow template with provided parameters.
361pub struct TemplateInstantiator;
362
363impl TemplateInstantiator {
364    /// Instantiate a template with provided parameters, returning a workflow JSON string.
365    ///
366    /// # Errors
367    ///
368    /// Returns an error string if required parameters are missing.
369    pub fn instantiate(
370        template: &WorkflowTemplate,
371        params: HashMap<std::string::String, std::string::String>,
372    ) -> Result<std::string::String, std::string::String> {
373        // Validate required parameters
374        for param in &template.parameters {
375            if param.required && !params.contains_key(&param.name) {
376                return Err(format!("Required parameter '{}' is missing", param.name));
377            }
378        }
379
380        // Collect all params with defaults applied
381        let mut resolved_params: HashMap<std::string::String, std::string::String> = HashMap::new();
382        for param in &template.parameters {
383            if let Some(value) = params.get(&param.name) {
384                resolved_params.insert(param.name.clone(), value.clone());
385            } else if let Some(default) = &param.default_value {
386                resolved_params.insert(param.name.clone(), default.clone());
387            }
388        }
389
390        // Build JSON representation
391        let steps_json: Vec<std::string::String> = template
392            .steps
393            .iter()
394            .map(|step| {
395                let params_json: Vec<std::string::String> = step
396                    .params
397                    .iter()
398                    .map(|(k, v)| {
399                        let resolved = substitute_params(v, &resolved_params);
400                        format!("\"{k}\":\"{resolved}\"")
401                    })
402                    .collect();
403
404                let on_error_str = match &step.on_error {
405                    ErrorAction::Fail => "\"fail\"",
406                    ErrorAction::Skip => "\"skip\"",
407                    ErrorAction::Notify => "\"notify\"",
408                    ErrorAction::Retry(n) => {
409                        // We'll inline the retry value
410                        return format!(
411                            "{{\"step_type\":\"{}\",\"on_error\":{{\"retry\":{}}},\"params\":{{{}}}}}",
412                            step.step_type,
413                            n,
414                            params_json.join(",")
415                        );
416                    }
417                };
418
419                format!(
420                    "{{\"step_type\":\"{}\",\"on_error\":{},\"params\":{{{}}}}}",
421                    step.step_type,
422                    on_error_str,
423                    params_json.join(",")
424                )
425            })
426            .collect();
427
428        let params_json: Vec<std::string::String> = resolved_params
429            .iter()
430            .map(|(k, v)| format!("\"{k}\":\"{v}\""))
431            .collect();
432
433        Ok(format!(
434            "{{\"name\":\"{}\",\"category\":\"{:?}\",\"params\":{{{}}},\"steps\":[{}]}}",
435            template.name,
436            template.category,
437            params_json.join(","),
438            steps_json.join(",")
439        ))
440    }
441}
442
443/// Substitute `{{param_name}}` placeholders in a value string.
444fn substitute_params(
445    value: &str,
446    params: &HashMap<std::string::String, std::string::String>,
447) -> std::string::String {
448    let mut result = value.to_string();
449    for (key, val) in params {
450        let placeholder = format!("{{{{{key}}}}}");
451        result = result.replace(&placeholder, val);
452    }
453    result
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn test_ingest_and_transcode_template() {
462        let template = TemplateLibrary::ingest_and_transcode();
463        assert_eq!(template.name, "ingest-and-transcode");
464        assert_eq!(template.category, TemplateCategory::Ingest);
465        assert!(!template.steps.is_empty());
466        assert!(!template.parameters.is_empty());
467    }
468
469    #[test]
470    fn test_archive_workflow_template() {
471        let template = TemplateLibrary::archive_workflow();
472        assert_eq!(template.name, "archive-workflow");
473        assert_eq!(template.category, TemplateCategory::Archive);
474        assert_eq!(template.steps.len(), 4);
475    }
476
477    #[test]
478    fn test_qc_review_template() {
479        let template = TemplateLibrary::qc_review();
480        assert_eq!(template.name, "qc-review");
481        assert_eq!(template.category, TemplateCategory::QC);
482        assert_eq!(template.steps.len(), 3);
483    }
484
485    #[test]
486    fn test_template_library_all() {
487        let all = TemplateLibrary::all();
488        assert_eq!(all.len(), 3);
489    }
490
491    #[test]
492    fn test_find_by_name() {
493        let template = TemplateLibrary::find_by_name("archive-workflow");
494        assert!(template.is_some());
495        assert_eq!(
496            template.expect("should succeed in test").name,
497            "archive-workflow"
498        );
499    }
500
501    #[test]
502    fn test_find_by_name_not_found() {
503        let template = TemplateLibrary::find_by_name("nonexistent");
504        assert!(template.is_none());
505    }
506
507    #[test]
508    fn test_find_by_category() {
509        let templates = TemplateLibrary::find_by_category(&TemplateCategory::Ingest);
510        assert!(!templates.is_empty());
511        for t in &templates {
512            assert_eq!(t.category, TemplateCategory::Ingest);
513        }
514    }
515
516    #[test]
517    fn test_instantiate_with_required_params() {
518        let template = TemplateLibrary::ingest_and_transcode();
519        let mut params = HashMap::new();
520        params.insert("watch_folder".to_string(), "/mnt/ingest".to_string());
521        params.insert("output_folder".to_string(), "/mnt/output".to_string());
522
523        let result = TemplateInstantiator::instantiate(&template, params);
524        assert!(result.is_ok());
525        let json = result.expect("should succeed in test");
526        assert!(json.contains("ingest-and-transcode"));
527        assert!(json.contains("/mnt/ingest"));
528    }
529
530    #[test]
531    fn test_instantiate_missing_required_param() {
532        let template = TemplateLibrary::ingest_and_transcode();
533        let params = HashMap::new(); // Missing required params
534
535        let result = TemplateInstantiator::instantiate(&template, params);
536        assert!(result.is_err());
537        let err = result.unwrap_err();
538        assert!(err.contains("Required parameter"));
539    }
540
541    #[test]
542    fn test_instantiate_with_defaults() {
543        let template = TemplateLibrary::archive_workflow();
544        let mut params = HashMap::new();
545        params.insert("source_path".to_string(), "/src".to_string());
546        params.insert("archive_destination".to_string(), "/archive".to_string());
547        // Not providing optional params - defaults should be used
548
549        let result = TemplateInstantiator::instantiate(&template, params);
550        assert!(result.is_ok());
551        let json = result.expect("should succeed in test");
552        assert!(json.contains("sha256")); // default checksum algorithm
553    }
554
555    #[test]
556    fn test_param_type_enum() {
557        let param = TemplateParam::optional(
558            "preset",
559            ParamType::Enum(vec!["a".to_string(), "b".to_string()]),
560            "a",
561        );
562        assert_eq!(param.name, "preset");
563        assert!(!param.required);
564        assert_eq!(param.default_value, Some("a".to_string()));
565    }
566
567    #[test]
568    fn test_error_action_retry() {
569        let step = TemplateStep::new("transcode", ErrorAction::Retry(3));
570        assert_eq!(step.on_error, ErrorAction::Retry(3));
571    }
572
573    #[test]
574    fn test_substitute_params() {
575        let mut params = HashMap::new();
576        params.insert("folder".to_string(), "/mnt/data".to_string());
577        let result = substitute_params("input={{folder}}/file.mp4", &params);
578        assert_eq!(result, "input=/mnt/data/file.mp4");
579    }
580}