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        let tmp = std::env::temp_dir();
292        WorkflowTemplate::new(
293            "qc-review",
294            "Automated QC analysis, report generation, and approve/reject decision",
295            TemplateCategory::QC,
296        )
297        .with_parameter(TemplateParam::required("media_path", ParamType::FilePath))
298        .with_parameter(TemplateParam::optional(
299            "qc_profile",
300            ParamType::Enum(vec![
301                "broadcast".to_string(),
302                "streaming".to_string(),
303                "cinema".to_string(),
304            ]),
305            "broadcast",
306        ))
307        .with_parameter(TemplateParam::optional(
308            "report_output",
309            ParamType::FilePath,
310            tmp.join("oximedia-qc-report.json")
311                .to_string_lossy()
312                .into_owned(),
313        ))
314        .with_parameter(TemplateParam::optional(
315            "auto_approve_threshold",
316            ParamType::Float,
317            "0.95",
318        ))
319        .with_step(
320            TemplateStep::new("analyze", ErrorAction::Fail)
321                .with_param("input", "{{media_path}}")
322                .with_param("profile", "{{qc_profile}}")
323                .with_param("checks", "loudness,black_frames,freeze,color_bars"),
324        )
325        .with_step(
326            TemplateStep::new("generate_report", ErrorAction::Notify)
327                .with_param("output", "{{report_output}}")
328                .with_param("format", "json"),
329        )
330        .with_step(
331            TemplateStep::new("approve_or_reject", ErrorAction::Fail)
332                .with_param("auto_threshold", "{{auto_approve_threshold}}")
333                .with_param("manual_review_on_fail", "true"),
334        )
335    }
336
337    /// Get all built-in templates.
338    #[must_use]
339    pub fn all() -> Vec<WorkflowTemplate> {
340        vec![
341            Self::ingest_and_transcode(),
342            Self::archive_workflow(),
343            Self::qc_review(),
344        ]
345    }
346
347    /// Find a template by name.
348    #[must_use]
349    pub fn find_by_name(name: &str) -> Option<WorkflowTemplate> {
350        Self::all().into_iter().find(|t| t.name == name)
351    }
352
353    /// Find templates by category.
354    #[must_use]
355    pub fn find_by_category(category: &TemplateCategory) -> Vec<WorkflowTemplate> {
356        Self::all()
357            .into_iter()
358            .filter(|t| &t.category == category)
359            .collect()
360    }
361}
362
363/// Instantiates a workflow template with provided parameters.
364pub struct TemplateInstantiator;
365
366impl TemplateInstantiator {
367    /// Instantiate a template with provided parameters, returning a workflow JSON string.
368    ///
369    /// # Errors
370    ///
371    /// Returns an error string if required parameters are missing.
372    pub fn instantiate(
373        template: &WorkflowTemplate,
374        params: HashMap<std::string::String, std::string::String>,
375    ) -> Result<std::string::String, std::string::String> {
376        // Validate required parameters
377        for param in &template.parameters {
378            if param.required && !params.contains_key(&param.name) {
379                return Err(format!("Required parameter '{}' is missing", param.name));
380            }
381        }
382
383        // Collect all params with defaults applied
384        let mut resolved_params: HashMap<std::string::String, std::string::String> = HashMap::new();
385        for param in &template.parameters {
386            if let Some(value) = params.get(&param.name) {
387                resolved_params.insert(param.name.clone(), value.clone());
388            } else if let Some(default) = &param.default_value {
389                resolved_params.insert(param.name.clone(), default.clone());
390            }
391        }
392
393        // Build JSON representation
394        let steps_json: Vec<std::string::String> = template
395            .steps
396            .iter()
397            .map(|step| {
398                let params_json: Vec<std::string::String> = step
399                    .params
400                    .iter()
401                    .map(|(k, v)| {
402                        let resolved = substitute_params(v, &resolved_params);
403                        format!("\"{k}\":\"{resolved}\"")
404                    })
405                    .collect();
406
407                let on_error_str = match &step.on_error {
408                    ErrorAction::Fail => "\"fail\"",
409                    ErrorAction::Skip => "\"skip\"",
410                    ErrorAction::Notify => "\"notify\"",
411                    ErrorAction::Retry(n) => {
412                        // We'll inline the retry value
413                        return format!(
414                            "{{\"step_type\":\"{}\",\"on_error\":{{\"retry\":{}}},\"params\":{{{}}}}}",
415                            step.step_type,
416                            n,
417                            params_json.join(",")
418                        );
419                    }
420                };
421
422                format!(
423                    "{{\"step_type\":\"{}\",\"on_error\":{},\"params\":{{{}}}}}",
424                    step.step_type,
425                    on_error_str,
426                    params_json.join(",")
427                )
428            })
429            .collect();
430
431        let params_json: Vec<std::string::String> = resolved_params
432            .iter()
433            .map(|(k, v)| format!("\"{k}\":\"{v}\""))
434            .collect();
435
436        Ok(format!(
437            "{{\"name\":\"{}\",\"category\":\"{:?}\",\"params\":{{{}}},\"steps\":[{}]}}",
438            template.name,
439            template.category,
440            params_json.join(","),
441            steps_json.join(",")
442        ))
443    }
444}
445
446/// Substitute `{{param_name}}` placeholders in a value string.
447fn substitute_params(
448    value: &str,
449    params: &HashMap<std::string::String, std::string::String>,
450) -> std::string::String {
451    let mut result = value.to_string();
452    for (key, val) in params {
453        let placeholder = format!("{{{{{key}}}}}");
454        result = result.replace(&placeholder, val);
455    }
456    result
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn test_ingest_and_transcode_template() {
465        let template = TemplateLibrary::ingest_and_transcode();
466        assert_eq!(template.name, "ingest-and-transcode");
467        assert_eq!(template.category, TemplateCategory::Ingest);
468        assert!(!template.steps.is_empty());
469        assert!(!template.parameters.is_empty());
470    }
471
472    #[test]
473    fn test_archive_workflow_template() {
474        let template = TemplateLibrary::archive_workflow();
475        assert_eq!(template.name, "archive-workflow");
476        assert_eq!(template.category, TemplateCategory::Archive);
477        assert_eq!(template.steps.len(), 4);
478    }
479
480    #[test]
481    fn test_qc_review_template() {
482        let template = TemplateLibrary::qc_review();
483        assert_eq!(template.name, "qc-review");
484        assert_eq!(template.category, TemplateCategory::QC);
485        assert_eq!(template.steps.len(), 3);
486    }
487
488    #[test]
489    fn test_template_library_all() {
490        let all = TemplateLibrary::all();
491        assert_eq!(all.len(), 3);
492    }
493
494    #[test]
495    fn test_find_by_name() {
496        let template = TemplateLibrary::find_by_name("archive-workflow");
497        assert!(template.is_some());
498        assert_eq!(
499            template.expect("should succeed in test").name,
500            "archive-workflow"
501        );
502    }
503
504    #[test]
505    fn test_find_by_name_not_found() {
506        let template = TemplateLibrary::find_by_name("nonexistent");
507        assert!(template.is_none());
508    }
509
510    #[test]
511    fn test_find_by_category() {
512        let templates = TemplateLibrary::find_by_category(&TemplateCategory::Ingest);
513        assert!(!templates.is_empty());
514        for t in &templates {
515            assert_eq!(t.category, TemplateCategory::Ingest);
516        }
517    }
518
519    #[test]
520    fn test_instantiate_with_required_params() {
521        let template = TemplateLibrary::ingest_and_transcode();
522        let mut params = HashMap::new();
523        params.insert("watch_folder".to_string(), "/mnt/ingest".to_string());
524        params.insert("output_folder".to_string(), "/mnt/output".to_string());
525
526        let result = TemplateInstantiator::instantiate(&template, params);
527        assert!(result.is_ok());
528        let json = result.expect("should succeed in test");
529        assert!(json.contains("ingest-and-transcode"));
530        assert!(json.contains("/mnt/ingest"));
531    }
532
533    #[test]
534    fn test_instantiate_missing_required_param() {
535        let template = TemplateLibrary::ingest_and_transcode();
536        let params = HashMap::new(); // Missing required params
537
538        let result = TemplateInstantiator::instantiate(&template, params);
539        assert!(result.is_err());
540        let err = result.unwrap_err();
541        assert!(err.contains("Required parameter"));
542    }
543
544    #[test]
545    fn test_instantiate_with_defaults() {
546        let template = TemplateLibrary::archive_workflow();
547        let mut params = HashMap::new();
548        params.insert("source_path".to_string(), "/src".to_string());
549        params.insert("archive_destination".to_string(), "/archive".to_string());
550        // Not providing optional params - defaults should be used
551
552        let result = TemplateInstantiator::instantiate(&template, params);
553        assert!(result.is_ok());
554        let json = result.expect("should succeed in test");
555        assert!(json.contains("sha256")); // default checksum algorithm
556    }
557
558    #[test]
559    fn test_param_type_enum() {
560        let param = TemplateParam::optional(
561            "preset",
562            ParamType::Enum(vec!["a".to_string(), "b".to_string()]),
563            "a",
564        );
565        assert_eq!(param.name, "preset");
566        assert!(!param.required);
567        assert_eq!(param.default_value, Some("a".to_string()));
568    }
569
570    #[test]
571    fn test_error_action_retry() {
572        let step = TemplateStep::new("transcode", ErrorAction::Retry(3));
573        assert_eq!(step.on_error, ErrorAction::Retry(3));
574    }
575
576    #[test]
577    fn test_substitute_params() {
578        let mut params = HashMap::new();
579        params.insert("folder".to_string(), "/mnt/data".to_string());
580        let result = substitute_params("input={{folder}}/file.mp4", &params);
581        assert_eq!(result, "input=/mnt/data/file.mp4");
582    }
583}