1#![allow(dead_code)]
31
32use std::collections::HashMap;
33use std::time::{SystemTime, UNIX_EPOCH};
34
35#[derive(Debug, Clone, PartialEq)]
41pub struct PauseCheckpoint {
42 pub workflow_id: String,
44 pub step_index: usize,
46 pub completed_steps: Vec<String>,
48 pub pending_steps: Vec<String>,
50 pub state_data: HashMap<String, String>,
52 pub paused_at: u64,
54 pub pause_reason: String,
56}
57
58impl PauseCheckpoint {
59 #[must_use]
63 pub fn new(workflow_id: impl Into<String>, step_index: usize) -> Self {
64 let paused_at = SystemTime::now()
65 .duration_since(UNIX_EPOCH)
66 .map(|d| d.as_millis() as u64)
67 .unwrap_or(0);
68 Self {
69 workflow_id: workflow_id.into(),
70 step_index,
71 completed_steps: Vec::new(),
72 pending_steps: Vec::new(),
73 state_data: HashMap::new(),
74 paused_at,
75 pause_reason: String::new(),
76 }
77 }
78
79 #[must_use]
81 pub fn with_completed(mut self, step: impl Into<String>) -> Self {
82 self.completed_steps.push(step.into());
83 self
84 }
85
86 #[must_use]
88 pub fn with_pending(mut self, step: impl Into<String>) -> Self {
89 self.pending_steps.push(step.into());
90 self
91 }
92
93 #[must_use]
95 pub fn with_state(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
96 self.state_data.insert(key.into(), value.into());
97 self
98 }
99
100 #[must_use]
102 pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
103 self.pause_reason = reason.into();
104 self
105 }
106
107 #[must_use]
109 pub fn remaining_steps(&self) -> usize {
110 self.pending_steps.len()
111 }
112
113 #[must_use]
117 pub fn progress_percent(&self) -> f32 {
118 let done = self.completed_steps.len();
119 let total = done + self.pending_steps.len();
120 if total == 0 {
121 return 0.0;
122 }
123 done as f32 / total as f32
124 }
125
126 #[must_use]
134 pub fn to_json(&self) -> String {
135 let completed = json_string_array(&self.completed_steps);
136 let pending = json_string_array(&self.pending_steps);
137 let state = json_string_map(&self.state_data);
138
139 format!(
140 concat!(
141 "{{",
142 "\"workflow_id\":{wid},",
143 "\"step_index\":{si},",
144 "\"completed_steps\":{completed},",
145 "\"pending_steps\":{pending},",
146 "\"state_data\":{state},",
147 "\"paused_at\":{pa},",
148 "\"pause_reason\":{reason}",
149 "}}"
150 ),
151 wid = json_escape_string(&self.workflow_id),
152 si = self.step_index,
153 completed = completed,
154 pending = pending,
155 state = state,
156 pa = self.paused_at,
157 reason = json_escape_string(&self.pause_reason),
158 )
159 }
160
161 pub fn from_json(json: &str) -> Result<Self, String> {
170 let trimmed = json.trim();
171 if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
172 return Err("Not a JSON object".to_string());
173 }
174
175 let workflow_id =
176 parse_json_string(trimmed, "workflow_id").ok_or("Missing field: workflow_id")?;
177 let step_index =
178 parse_json_u64(trimmed, "step_index").ok_or("Missing field: step_index")? as usize;
179 let completed_steps =
180 parse_json_string_array(trimmed, "completed_steps").unwrap_or_default();
181 let pending_steps = parse_json_string_array(trimmed, "pending_steps").unwrap_or_default();
182 let state_data = parse_json_string_map(trimmed, "state_data").unwrap_or_default();
183 let paused_at = parse_json_u64(trimmed, "paused_at").unwrap_or(0);
184 let pause_reason = parse_json_string(trimmed, "pause_reason").unwrap_or_default();
185
186 Ok(Self {
187 workflow_id,
188 step_index,
189 completed_steps,
190 pending_steps,
191 state_data,
192 paused_at,
193 pause_reason,
194 })
195 }
196}
197
198#[derive(Debug, Default)]
207pub struct PauseCheckpointStore {
208 checkpoints: HashMap<String, PauseCheckpoint>,
209}
210
211impl PauseCheckpointStore {
212 #[must_use]
214 pub fn new() -> Self {
215 Self::default()
216 }
217
218 pub fn save(&mut self, checkpoint: PauseCheckpoint) {
220 self.checkpoints
221 .insert(checkpoint.workflow_id.clone(), checkpoint);
222 }
223
224 #[must_use]
226 pub fn load(&self, workflow_id: &str) -> Option<&PauseCheckpoint> {
227 self.checkpoints.get(workflow_id)
228 }
229
230 pub fn delete(&mut self, workflow_id: &str) -> bool {
232 self.checkpoints.remove(workflow_id).is_some()
233 }
234
235 #[must_use]
237 pub fn list_paused(&self) -> Vec<&PauseCheckpoint> {
238 self.checkpoints.values().collect()
239 }
240
241 #[must_use]
243 pub fn count(&self) -> usize {
244 self.checkpoints.len()
245 }
246}
247
248#[derive(Debug, Default)]
270pub struct PauseResumeController {
271 store: PauseCheckpointStore,
272}
273
274impl PauseResumeController {
275 #[must_use]
277 pub fn new() -> Self {
278 Self::default()
279 }
280
281 pub fn pause(&mut self, workflow_id: u64) {
286 let key = workflow_id.to_string();
287 let cp = PauseCheckpoint::new(key, 0).with_reason("Paused by PauseResumeController");
288 self.store.save(cp);
289 }
290
291 pub fn resume(&mut self, workflow_id: u64) {
295 self.store.delete(&workflow_id.to_string());
296 }
297
298 #[must_use]
300 pub fn is_paused(&self, workflow_id: u64) -> bool {
301 self.store.load(&workflow_id.to_string()).is_some()
302 }
303
304 #[must_use]
306 pub fn paused_count(&self) -> usize {
307 self.store.count()
308 }
309
310 #[must_use]
312 pub fn paused_ids(&self) -> Vec<u64> {
313 self.store
314 .list_paused()
315 .iter()
316 .filter_map(|cp| cp.workflow_id.parse::<u64>().ok())
317 .collect()
318 }
319}
320
321fn json_escape_string(s: &str) -> String {
327 let mut out = String::with_capacity(s.len() + 2);
328 out.push('"');
329 for c in s.chars() {
330 match c {
331 '"' => out.push_str("\\\""),
332 '\\' => out.push_str("\\\\"),
333 '\n' => out.push_str("\\n"),
334 '\r' => out.push_str("\\r"),
335 '\t' => out.push_str("\\t"),
336 c => out.push(c),
337 }
338 }
339 out.push('"');
340 out
341}
342
343fn json_string_array(items: &[String]) -> String {
345 let mut out = String::from("[");
346 for (i, item) in items.iter().enumerate() {
347 if i > 0 {
348 out.push(',');
349 }
350 out.push_str(&json_escape_string(item));
351 }
352 out.push(']');
353 out
354}
355
356fn json_string_map(map: &HashMap<String, String>) -> String {
358 let mut out = String::from("{");
359 let mut first = true;
360 for (k, v) in map {
361 if !first {
362 out.push(',');
363 }
364 first = false;
365 out.push_str(&json_escape_string(k));
366 out.push(':');
367 out.push_str(&json_escape_string(v));
368 }
369 out.push('}');
370 out
371}
372
373fn find_value_start(json: &str, key: &str) -> Option<usize> {
381 let search = format!("\"{}\":", key);
382 json.find(&search).map(|pos| pos + search.len())
383}
384
385fn parse_json_string(json: &str, key: &str) -> Option<String> {
387 let after_colon = find_value_start(json, key)?;
388 let rest = json[after_colon..].trim_start();
389 if !rest.starts_with('"') {
390 return None;
391 }
392 let inner = &rest[1..]; let mut result = String::new();
394 let mut chars = inner.chars().peekable();
395 loop {
396 match chars.next()? {
397 '"' => break,
398 '\\' => match chars.next()? {
399 '"' => result.push('"'),
400 '\\' => result.push('\\'),
401 'n' => result.push('\n'),
402 'r' => result.push('\r'),
403 't' => result.push('\t'),
404 c => result.push(c),
405 },
406 c => result.push(c),
407 }
408 }
409 Some(result)
410}
411
412fn parse_json_u64(json: &str, key: &str) -> Option<u64> {
414 let after_colon = find_value_start(json, key)?;
415 let rest = json[after_colon..].trim_start();
416 let end = rest
417 .find(|c: char| !c.is_ascii_digit())
418 .unwrap_or(rest.len());
419 rest[..end].parse().ok()
420}
421
422fn parse_json_string_array(json: &str, key: &str) -> Option<Vec<String>> {
426 let after_colon = find_value_start(json, key)?;
427 let rest = json[after_colon..].trim_start();
428 if !rest.starts_with('[') {
429 return None;
430 }
431 let bracket_end = find_matching_bracket(rest, '[', ']')?;
432 let inner = &rest[1..bracket_end]; let mut result = Vec::new();
434 let mut remaining = inner.trim();
435 while !remaining.is_empty() {
436 if !remaining.starts_with('"') {
437 break;
438 }
439 let (s, consumed) = parse_one_json_string(remaining)?;
440 result.push(s);
441 remaining = remaining[consumed..].trim();
442 if remaining.starts_with(',') {
443 remaining = remaining[1..].trim();
444 }
445 }
446 Some(result)
447}
448
449fn parse_json_string_map(json: &str, key: &str) -> Option<HashMap<String, String>> {
453 let after_colon = find_value_start(json, key)?;
454 let rest = json[after_colon..].trim_start();
455 if !rest.starts_with('{') {
456 return None;
457 }
458 let brace_end = find_matching_bracket(rest, '{', '}')?;
459 let inner = &rest[1..brace_end]; let mut result = HashMap::new();
461 let mut remaining = inner.trim();
462 while !remaining.is_empty() {
463 if !remaining.starts_with('"') {
464 break;
465 }
466 let (k, k_consumed) = parse_one_json_string(remaining)?;
467 remaining = remaining[k_consumed..].trim();
468 if !remaining.starts_with(':') {
469 break;
470 }
471 remaining = remaining[1..].trim();
472 let (v, v_consumed) = parse_one_json_string(remaining)?;
473 result.insert(k, v);
474 remaining = remaining[v_consumed..].trim();
475 if remaining.starts_with(',') {
476 remaining = remaining[1..].trim();
477 }
478 }
479 Some(result)
480}
481
482fn parse_one_json_string(s: &str) -> Option<(String, usize)> {
486 if !s.starts_with('"') {
487 return None;
488 }
489 let mut result = String::new();
490 let mut chars = s[1..].char_indices();
491 let mut consumed = 1usize; loop {
493 let (i, c) = chars.next()?;
494 match c {
495 '"' => {
496 consumed += i + c.len_utf8();
497 break;
498 }
499 '\\' => {
500 let (_, next) = chars.next()?;
501 consumed += 1;
502 match next {
503 '"' => result.push('"'),
504 '\\' => result.push('\\'),
505 'n' => result.push('\n'),
506 'r' => result.push('\r'),
507 't' => result.push('\t'),
508 c => result.push(c),
509 }
510 }
511 c => result.push(c),
512 }
513 }
514 Some((result, consumed))
515}
516
517fn find_matching_bracket(s: &str, open: char, close: char) -> Option<usize> {
519 let mut depth: usize = 0;
520 let mut in_string = false;
521 let mut prev_backslash = false;
522 for (i, c) in s.char_indices() {
523 if prev_backslash {
524 prev_backslash = false;
525 continue;
526 }
527 if in_string {
528 if c == '\\' {
529 prev_backslash = true;
530 } else if c == '"' {
531 in_string = false;
532 }
533 continue;
534 }
535 if c == '"' {
536 in_string = true;
537 } else if c == open {
538 depth += 1;
539 } else if c == close {
540 depth -= 1;
541 if depth == 0 {
542 return Some(i);
543 }
544 }
545 }
546 None
547}
548
549#[cfg(test)]
554mod tests {
555 use super::*;
556
557 #[test]
558 fn test_new_checkpoint_fields() {
559 let cp = PauseCheckpoint::new("wf-001", 3);
560 assert_eq!(cp.workflow_id, "wf-001");
561 assert_eq!(cp.step_index, 3);
562 assert!(cp.completed_steps.is_empty());
563 assert!(cp.pending_steps.is_empty());
564 assert!(cp.state_data.is_empty());
565 assert!(cp.paused_at > 0);
566 assert!(cp.pause_reason.is_empty());
567 }
568
569 #[test]
570 fn test_progress_percent_partial() {
571 let cp = PauseCheckpoint::new("wf-002", 2)
572 .with_completed("step-a")
573 .with_completed("step-b")
574 .with_pending("step-c")
575 .with_pending("step-d");
576 let progress = cp.progress_percent();
577 assert!(
579 (progress - 0.5).abs() < f32::EPSILON,
580 "expected 0.5, got {progress}"
581 );
582 }
583
584 #[test]
585 fn test_progress_percent_all_done() {
586 let cp = PauseCheckpoint::new("wf-003", 2)
587 .with_completed("a")
588 .with_completed("b");
589 assert!((cp.progress_percent() - 1.0).abs() < f32::EPSILON);
590 }
591
592 #[test]
593 fn test_progress_percent_none_done() {
594 let cp = PauseCheckpoint::new("wf-004", 0)
595 .with_pending("a")
596 .with_pending("b");
597 assert!((cp.progress_percent() - 0.0).abs() < f32::EPSILON);
598 }
599
600 #[test]
601 fn test_to_json_from_json_round_trip() {
602 let original = PauseCheckpoint::new("wf-roundtrip", 1)
603 .with_completed("ingest")
604 .with_pending("transcode")
605 .with_pending("upload")
606 .with_state("source", "/data/clip.mxf")
607 .with_reason("Waiting for budget approval");
608
609 let json = original.to_json();
610 let restored = PauseCheckpoint::from_json(&json).expect("from_json should succeed");
611
612 assert_eq!(restored.workflow_id, original.workflow_id);
613 assert_eq!(restored.step_index, original.step_index);
614 assert_eq!(restored.completed_steps, original.completed_steps);
615 assert_eq!(restored.pending_steps, original.pending_steps);
616 assert_eq!(restored.state_data, original.state_data);
617 assert_eq!(restored.paused_at, original.paused_at);
618 assert_eq!(restored.pause_reason, original.pause_reason);
619 }
620
621 #[test]
622 fn test_save_load_delete() {
623 let mut store = PauseCheckpointStore::new();
624 let cp = PauseCheckpoint::new("wf-store", 0);
625
626 store.save(cp.clone());
627 assert_eq!(store.count(), 1);
628
629 let loaded = store.load("wf-store").expect("should find it");
630 assert_eq!(loaded.workflow_id, "wf-store");
631
632 assert!(store.delete("wf-store"));
633 assert_eq!(store.count(), 0);
634 assert!(store.load("wf-store").is_none());
635 }
636
637 #[test]
638 fn test_list_paused_returns_all() {
639 let mut store = PauseCheckpointStore::new();
640 store.save(PauseCheckpoint::new("wf-alpha", 0));
641 store.save(PauseCheckpoint::new("wf-beta", 1));
642 store.save(PauseCheckpoint::new("wf-gamma", 2));
643
644 let list = store.list_paused();
645 assert_eq!(list.len(), 3);
646 }
647
648 #[test]
649 fn test_remaining_steps() {
650 let cp = PauseCheckpoint::new("wf-rem", 1)
651 .with_pending("step-x")
652 .with_pending("step-y")
653 .with_pending("step-z");
654 assert_eq!(cp.remaining_steps(), 3);
655 }
656
657 #[test]
658 fn test_with_state() {
659 let out = std::env::temp_dir()
660 .join("oximedia-workflow-pause-out.mp4")
661 .to_string_lossy()
662 .into_owned();
663 let cp = PauseCheckpoint::new("wf-state", 0)
664 .with_state("output_path", &out)
665 .with_state("fps", "24");
666 assert_eq!(
667 cp.state_data.get("output_path").map(String::as_str),
668 Some(out.as_str())
669 );
670 assert_eq!(cp.state_data.get("fps").map(String::as_str), Some("24"));
671 }
672
673 #[test]
674 fn test_from_json_malformed_returns_err() {
675 let result = PauseCheckpoint::from_json("not json at all");
676 assert!(result.is_err());
677 }
678
679 #[test]
680 fn test_delete_nonexistent_returns_false() {
681 let mut store = PauseCheckpointStore::new();
682 assert!(!store.delete("ghost-workflow"));
683 }
684}