Skip to main content

oximedia_workflow/
utils.rs

1//! Utility functions and helpers.
2
3use crate::error::{Result, WorkflowError};
4use crate::task::TaskType;
5use crate::workflow::Workflow;
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10/// Parse duration from string (e.g., "1h30m", "90s", "1.5h").
11pub fn parse_duration(s: &str) -> Result<Duration> {
12    let s = s.trim();
13
14    // Try parsing as seconds (plain number)
15    if let Ok(secs) = s.parse::<u64>() {
16        return Ok(Duration::from_secs(secs));
17    }
18
19    // Parse with units
20    let mut total_secs = 0u64;
21    let mut current_num = String::new();
22
23    for ch in s.chars() {
24        if ch.is_ascii_digit() || ch == '.' {
25            current_num.push(ch);
26        } else {
27            if current_num.is_empty() {
28                continue;
29            }
30
31            let value: f64 = current_num.parse().map_err(|_| {
32                WorkflowError::InvalidConfiguration(format!("Invalid duration: {s}"))
33            })?;
34
35            let unit_secs = match ch {
36                's' => 1,
37                'm' => 60,
38                'h' => 3600,
39                'd' => 86400,
40                _ => {
41                    return Err(WorkflowError::InvalidConfiguration(format!(
42                        "Invalid duration unit: {ch}"
43                    )))
44                }
45            };
46
47            total_secs += (value * f64::from(unit_secs)) as u64;
48            current_num.clear();
49        }
50    }
51
52    if total_secs == 0 {
53        return Err(WorkflowError::InvalidConfiguration(format!(
54            "Invalid duration: {s}"
55        )));
56    }
57
58    Ok(Duration::from_secs(total_secs))
59}
60
61/// Format duration as human-readable string.
62#[must_use]
63pub fn format_duration(duration: Duration) -> String {
64    let secs = duration.as_secs();
65
66    if secs < 60 {
67        return format!("{secs}s");
68    }
69
70    if secs < 3600 {
71        let mins = secs / 60;
72        let secs = secs % 60;
73        if secs == 0 {
74            return format!("{mins}m");
75        }
76        return format!("{mins}m{secs}s");
77    }
78
79    if secs < 86400 {
80        let hours = secs / 3600;
81        let mins = (secs % 3600) / 60;
82        if mins == 0 {
83            return format!("{hours}h");
84        }
85        return format!("{hours}h{mins}m");
86    }
87
88    let days = secs / 86400;
89    let hours = (secs % 86400) / 3600;
90    if hours == 0 {
91        return format!("{days}d");
92    }
93    format!("{days}d{hours}h")
94}
95
96/// Sanitize task name (remove invalid characters).
97#[must_use]
98pub fn sanitize_task_name(name: &str) -> String {
99    name.chars()
100        .map(|c| {
101            if c.is_alphanumeric() || c == '-' || c == '_' {
102                c
103            } else {
104                '_'
105            }
106        })
107        .collect()
108}
109
110/// Generate a unique task name.
111#[must_use]
112pub fn generate_task_name(prefix: &str, index: usize) -> String {
113    format!("{}-{}", sanitize_task_name(prefix), index)
114}
115
116/// Expand environment variables in a string.
117#[must_use]
118pub fn expand_env_vars(s: &str) -> String {
119    let mut result = s.to_string();
120
121    for (key, value) in std::env::vars() {
122        let placeholder = format!("${{{key}}}");
123        result = result.replace(&placeholder, &value);
124
125        let placeholder = format!("${key}");
126        result = result.replace(&placeholder, &value);
127    }
128
129    result
130}
131
132/// Expand template variables in a string.
133#[must_use]
134pub fn expand_template(template: &str, variables: &HashMap<String, String>) -> String {
135    let mut result = template.to_string();
136
137    for (key, value) in variables {
138        let placeholder = format!("{{{key}}}");
139        result = result.replace(&placeholder, value);
140
141        let placeholder = format!("${{{key}}}");
142        result = result.replace(&placeholder, value);
143    }
144
145    result
146}
147
148/// Calculate estimated workflow duration.
149#[must_use]
150pub fn estimate_workflow_duration(workflow: &Workflow) -> Duration {
151    let mut max_duration = Duration::ZERO;
152
153    // Get topological order
154    if let Ok(sorted_tasks) = workflow.topological_sort() {
155        let mut task_end_times: HashMap<crate::task::TaskId, Duration> = HashMap::new();
156
157        for &task_id in &sorted_tasks {
158            if let Some(task) = workflow.get_task(&task_id) {
159                // Calculate start time based on graph edges (workflow.get_dependencies)
160                // or the task-level dependency list, whichever is non-empty.
161                let mut start_time = Duration::ZERO;
162
163                let edge_deps = workflow.get_dependencies(&task_id);
164                let all_deps: Vec<crate::task::TaskId> = if edge_deps.is_empty() {
165                    task.dependencies.clone()
166                } else {
167                    edge_deps
168                };
169
170                for dep_id in &all_deps {
171                    if let Some(&dep_end_time) = task_end_times.get(dep_id) {
172                        start_time = start_time.max(dep_end_time);
173                    }
174                }
175
176                // Calculate end time
177                let end_time = start_time + task.timeout;
178                task_end_times.insert(task_id, end_time);
179
180                max_duration = max_duration.max(end_time);
181            }
182        }
183    }
184
185    max_duration
186}
187
188/// Find critical path in workflow.
189#[must_use]
190pub fn find_critical_path(workflow: &Workflow) -> Vec<crate::task::TaskId> {
191    let mut task_durations: HashMap<_, _> = HashMap::new();
192    let mut task_paths: HashMap<_, Vec<_>> = HashMap::new();
193
194    if let Ok(sorted_tasks) = workflow.topological_sort() {
195        for &task_id in &sorted_tasks {
196            if let Some(task) = workflow.get_task(&task_id) {
197                let deps = workflow.get_dependencies(&task_id);
198
199                if deps.is_empty() {
200                    task_durations.insert(task_id, task.timeout);
201                    task_paths.insert(task_id, vec![task_id]);
202                } else {
203                    let mut max_duration = Duration::ZERO;
204                    let mut max_path = Vec::new();
205
206                    for dep_id in deps {
207                        if let Some(&dep_duration) = task_durations.get(&dep_id) {
208                            let total_duration = dep_duration + task.timeout;
209                            if total_duration > max_duration {
210                                max_duration = total_duration;
211                                max_path = task_paths.get(&dep_id).cloned().unwrap_or_default();
212                            }
213                        }
214                    }
215
216                    max_path.push(task_id);
217                    task_durations.insert(task_id, max_duration);
218                    task_paths.insert(task_id, max_path);
219                }
220            }
221        }
222
223        // Find the path with maximum duration
224        let mut critical_path = Vec::new();
225        let mut max_duration = Duration::ZERO;
226
227        for (task_id, &duration) in &task_durations {
228            if duration > max_duration {
229                max_duration = duration;
230                critical_path = task_paths.get(task_id).cloned().unwrap_or_default();
231            }
232        }
233
234        return critical_path;
235    }
236
237    Vec::new()
238}
239
240/// Merge workflow configurations.
241#[must_use]
242pub fn merge_configs(
243    base: &crate::workflow::WorkflowConfig,
244    override_config: &crate::workflow::WorkflowConfig,
245) -> crate::workflow::WorkflowConfig {
246    let mut merged = base.clone();
247
248    if override_config.max_concurrent_tasks != base.max_concurrent_tasks {
249        merged.max_concurrent_tasks = override_config.max_concurrent_tasks;
250    }
251
252    if override_config.global_timeout.is_some() {
253        merged.global_timeout = override_config.global_timeout;
254    }
255
256    merged.fail_fast = override_config.fail_fast || base.fail_fast;
257    merged.continue_on_error = override_config.continue_on_error || base.continue_on_error;
258
259    // Merge variables
260    for (key, value) in &override_config.variables {
261        merged.variables.insert(key.clone(), value.clone());
262    }
263
264    merged
265}
266
267/// Clone a workflow with a new ID.
268#[must_use]
269pub fn clone_workflow(workflow: &Workflow, new_name: Option<String>) -> Workflow {
270    let mut cloned = Workflow::new(new_name.unwrap_or_else(|| format!("{}-copy", workflow.name)));
271    cloned.description = workflow.description.clone();
272    cloned.config = workflow.config.clone();
273    cloned.metadata = workflow.metadata.clone();
274
275    // Clone tasks with ID mapping
276    let mut id_map = HashMap::new();
277
278    for task in workflow.tasks.values() {
279        let mut new_task = task.clone();
280        let old_id = new_task.id;
281        new_task.id = crate::task::TaskId::new();
282        id_map.insert(old_id, new_task.id);
283        cloned.tasks.insert(new_task.id, new_task);
284    }
285
286    // Clone edges with remapped IDs
287    for edge in &workflow.edges {
288        if let (Some(&from_id), Some(&to_id)) = (id_map.get(&edge.from), id_map.get(&edge.to)) {
289            cloned.edges.push(crate::workflow::Edge {
290                from: from_id,
291                to: to_id,
292                condition: edge.condition.clone(),
293            });
294        }
295    }
296
297    cloned
298}
299
300/// Normalize file paths in workflow.
301pub fn normalize_paths(workflow: &mut Workflow, base_path: &Path) -> Result<()> {
302    for task in workflow.tasks.values_mut() {
303        match &mut task.task_type {
304            TaskType::Transcode { input, output, .. } => {
305                *input = normalize_path(input, base_path);
306                *output = normalize_path(output, base_path);
307            }
308            TaskType::QualityControl { input, .. } => {
309                *input = normalize_path(input, base_path);
310            }
311            TaskType::Analysis { input, output, .. } => {
312                *input = normalize_path(input, base_path);
313                if let Some(out) = output {
314                    *out = normalize_path(out, base_path);
315                }
316            }
317            TaskType::CustomScript { script, .. } => {
318                *script = normalize_path(script, base_path);
319            }
320            _ => {}
321        }
322    }
323
324    Ok(())
325}
326
327fn normalize_path(path: &Path, base_path: &Path) -> PathBuf {
328    if path.is_absolute() {
329        path.to_path_buf()
330    } else {
331        base_path.join(path)
332    }
333}
334
335/// Calculate task parallelism opportunities.
336#[must_use]
337pub fn calculate_parallelism(workflow: &Workflow) -> Vec<Vec<crate::task::TaskId>> {
338    let mut levels: Vec<Vec<crate::task::TaskId>> = Vec::new();
339
340    if let Ok(sorted_tasks) = workflow.topological_sort() {
341        let mut task_levels: HashMap<_, _> = HashMap::new();
342
343        for &task_id in &sorted_tasks {
344            let deps = workflow.get_dependencies(&task_id);
345
346            if deps.is_empty() {
347                task_levels.insert(task_id, 0);
348            } else {
349                let mut max_level = 0;
350                for dep_id in deps {
351                    if let Some(&level) = task_levels.get(&dep_id) {
352                        max_level = max_level.max(level + 1);
353                    }
354                }
355                task_levels.insert(task_id, max_level);
356            }
357        }
358
359        // Group tasks by level
360        let max_level = task_levels.values().max().copied().unwrap_or(0);
361        levels = vec![Vec::new(); max_level + 1];
362
363        for (task_id, level) in task_levels {
364            levels[level].push(task_id);
365        }
366    }
367
368    levels
369}
370
371/// Get workflow statistics.
372#[must_use]
373pub fn get_workflow_statistics(workflow: &Workflow) -> WorkflowStatistics {
374    let task_count = workflow.tasks.len();
375    let edge_count = workflow.edges.len();
376
377    let mut task_type_counts: HashMap<String, usize> = HashMap::new();
378
379    for task in workflow.tasks.values() {
380        let type_name = match &task.task_type {
381            TaskType::Transcode { .. } => "Transcode",
382            TaskType::QualityControl { .. } => "QualityControl",
383            TaskType::Transfer { .. } => "Transfer",
384            TaskType::Notification { .. } => "Notification",
385            TaskType::CustomScript { .. } => "CustomScript",
386            TaskType::Analysis { .. } => "Analysis",
387            TaskType::Conditional { .. } => "Conditional",
388            TaskType::Wait { .. } => "Wait",
389            TaskType::HttpRequest { .. } => "HttpRequest",
390        };
391
392        *task_type_counts.entry(type_name.to_string()).or_insert(0) += 1;
393    }
394
395    let root_count = workflow.get_root_tasks().len();
396    let leaf_count = workflow.get_leaf_tasks().len();
397
398    let estimated_duration = estimate_workflow_duration(workflow);
399    let critical_path = find_critical_path(workflow);
400
401    WorkflowStatistics {
402        task_count,
403        edge_count,
404        root_task_count: root_count,
405        leaf_task_count: leaf_count,
406        task_type_counts,
407        estimated_duration,
408        critical_path_length: critical_path.len(),
409    }
410}
411
412/// Workflow statistics.
413#[derive(Debug, Clone)]
414pub struct WorkflowStatistics {
415    /// Total number of tasks.
416    pub task_count: usize,
417    /// Total number of edges.
418    pub edge_count: usize,
419    /// Number of root tasks.
420    pub root_task_count: usize,
421    /// Number of leaf tasks.
422    pub leaf_task_count: usize,
423    /// Task type distribution.
424    pub task_type_counts: HashMap<String, usize>,
425    /// Estimated duration.
426    pub estimated_duration: Duration,
427    /// Critical path length.
428    pub critical_path_length: usize,
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use crate::task::Task;
435
436    #[test]
437    fn test_parse_duration() {
438        assert_eq!(
439            parse_duration("60").expect("should succeed in test"),
440            Duration::from_secs(60)
441        );
442        assert_eq!(
443            parse_duration("1m").expect("should succeed in test"),
444            Duration::from_secs(60)
445        );
446        assert_eq!(
447            parse_duration("1h").expect("should succeed in test"),
448            Duration::from_secs(3600)
449        );
450        assert_eq!(
451            parse_duration("1h30m").expect("should succeed in test"),
452            Duration::from_secs(5400)
453        );
454        assert_eq!(
455            parse_duration("1d").expect("should succeed in test"),
456            Duration::from_secs(86400)
457        );
458    }
459
460    #[test]
461    fn test_format_duration() {
462        assert_eq!(format_duration(Duration::from_secs(30)), "30s");
463        assert_eq!(format_duration(Duration::from_secs(60)), "1m");
464        assert_eq!(format_duration(Duration::from_secs(90)), "1m30s");
465        assert_eq!(format_duration(Duration::from_secs(3600)), "1h");
466        assert_eq!(format_duration(Duration::from_secs(3660)), "1h1m");
467    }
468
469    #[test]
470    fn test_sanitize_task_name() {
471        assert_eq!(sanitize_task_name("hello world"), "hello_world");
472        assert_eq!(sanitize_task_name("test-task"), "test-task");
473        assert_eq!(sanitize_task_name("test@task!"), "test_task_");
474    }
475
476    #[test]
477    fn test_generate_task_name() {
478        assert_eq!(generate_task_name("test", 1), "test-1");
479        assert_eq!(generate_task_name("my task", 5), "my_task-5");
480    }
481
482    #[test]
483    fn test_expand_template() {
484        let mut vars = HashMap::new();
485        vars.insert("name".to_string(), "world".to_string());
486        vars.insert("num".to_string(), "42".to_string());
487
488        let result = expand_template("Hello {name}, answer is {num}", &vars);
489        assert_eq!(result, "Hello world, answer is 42");
490    }
491
492    #[test]
493    fn test_estimate_workflow_duration() {
494        let mut workflow = Workflow::new("test");
495        let task1 = Task::new(
496            "task1",
497            TaskType::Wait {
498                duration: Duration::from_secs(10),
499            },
500        )
501        .with_timeout(Duration::from_secs(10));
502
503        let task2 = Task::new(
504            "task2",
505            TaskType::Wait {
506                duration: Duration::from_secs(20),
507            },
508        )
509        .with_timeout(Duration::from_secs(20));
510
511        let id1 = workflow.add_task(task1);
512        let id2 = workflow.add_task(task2);
513        workflow.add_edge(id1, id2).expect("should succeed in test");
514
515        let duration = estimate_workflow_duration(&workflow);
516        assert_eq!(duration, Duration::from_secs(30));
517    }
518
519    #[test]
520    fn test_clone_workflow() {
521        let mut workflow = Workflow::new("original");
522        let task = Task::new(
523            "task1",
524            TaskType::Wait {
525                duration: Duration::from_secs(1),
526            },
527        );
528        workflow.add_task(task);
529
530        let cloned = clone_workflow(&workflow, Some("cloned".to_string()));
531
532        assert_eq!(cloned.name, "cloned");
533        assert_eq!(cloned.tasks.len(), workflow.tasks.len());
534        assert_ne!(cloned.id, workflow.id);
535    }
536
537    #[test]
538    fn test_calculate_parallelism() {
539        let mut workflow = Workflow::new("test");
540
541        let task1 = Task::new(
542            "task1",
543            TaskType::Wait {
544                duration: Duration::from_secs(1),
545            },
546        );
547        let task2 = Task::new(
548            "task2",
549            TaskType::Wait {
550                duration: Duration::from_secs(1),
551            },
552        );
553        let task3 = Task::new(
554            "task3",
555            TaskType::Wait {
556                duration: Duration::from_secs(1),
557            },
558        );
559
560        let id1 = workflow.add_task(task1);
561        let id2 = workflow.add_task(task2);
562        let id3 = workflow.add_task(task3);
563
564        workflow.add_edge(id1, id3).expect("should succeed in test");
565        workflow.add_edge(id2, id3).expect("should succeed in test");
566
567        let levels = calculate_parallelism(&workflow);
568        assert_eq!(levels.len(), 2);
569        assert_eq!(levels[0].len(), 2); // task1 and task2 can run in parallel
570        assert_eq!(levels[1].len(), 1); // task3 runs after
571    }
572
573    #[test]
574    fn test_get_workflow_statistics() {
575        let mut workflow = Workflow::new("test");
576        let task = Task::new(
577            "task1",
578            TaskType::Wait {
579                duration: Duration::from_secs(1),
580            },
581        );
582        workflow.add_task(task);
583
584        let stats = get_workflow_statistics(&workflow);
585        assert_eq!(stats.task_count, 1);
586        assert_eq!(stats.edge_count, 0);
587    }
588
589    #[test]
590    fn test_merge_configs() {
591        let base = crate::workflow::WorkflowConfig {
592            max_concurrent_tasks: 2,
593            ..Default::default()
594        };
595
596        let override_config = crate::workflow::WorkflowConfig {
597            max_concurrent_tasks: 4,
598            fail_fast: true,
599            ..Default::default()
600        };
601
602        let merged = merge_configs(&base, &override_config);
603        assert_eq!(merged.max_concurrent_tasks, 4);
604        assert!(merged.fail_fast);
605    }
606}