1#![allow(dead_code)]
48
49use std::collections::HashMap;
50
51#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct BundleStep {
58 pub id: String,
60 pub action: String,
62 pub params: Vec<(String, String)>,
64 pub depends_on: Vec<String>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct WorkflowBundle {
75 pub name: String,
77 pub version: String,
79 pub steps: Vec<BundleStep>,
81 pub metadata: HashMap<String, String>,
83}
84
85impl WorkflowBundle {
86 #[must_use]
95 pub fn to_json(&self) -> String {
96 let steps_json = self
97 .steps
98 .iter()
99 .map(bundle_step_to_json)
100 .collect::<Vec<_>>()
101 .join(",");
102
103 let metadata_json = kv_map_to_json(&self.metadata);
104
105 format!(
106 r#"{{"name":"{}","version":"{}","steps":[{}],"metadata":{}}}"#,
107 escape_json(&self.name),
108 escape_json(&self.version),
109 steps_json,
110 metadata_json,
111 )
112 }
113
114 pub fn from_json(s: &str) -> Result<Self, String> {
121 let name = extract_string(s, "name").ok_or_else(|| "missing field: name".to_string())?;
122 let version =
123 extract_string(s, "version").ok_or_else(|| "missing field: version".to_string())?;
124
125 let steps =
126 extract_steps_array(s).ok_or_else(|| "missing or invalid field: steps".to_string())?;
127
128 let metadata = extract_metadata_map(s).unwrap_or_default();
129
130 Ok(Self {
131 name,
132 version,
133 steps,
134 metadata,
135 })
136 }
137
138 pub fn validate(&self) -> Result<(), Vec<String>> {
150 let mut errors = Vec::new();
151
152 let mut seen_ids: HashMap<&str, usize> = HashMap::new();
154 for (i, step) in self.steps.iter().enumerate() {
155 if step.id.is_empty() {
156 errors.push(format!("step[{i}] has an empty id"));
157 continue;
158 }
159 if let Some(prev) = seen_ids.insert(step.id.as_str(), i) {
160 errors.push(format!(
161 "duplicate step id '{}' at indices {prev} and {i}",
162 step.id
163 ));
164 }
165 }
166
167 for step in &self.steps {
169 for dep in &step.depends_on {
170 if !seen_ids.contains_key(dep.as_str()) {
171 errors.push(format!(
172 "step '{}' depends_on unknown step id '{dep}'",
173 step.id
174 ));
175 }
176 }
177 }
178
179 if errors.is_empty() {
180 Ok(())
181 } else {
182 Err(errors)
183 }
184 }
185
186 #[must_use]
192 pub fn new(name: impl Into<String>) -> Self {
193 Self {
194 name: name.into(),
195 version: "1.0.0".to_string(),
196 steps: Vec::new(),
197 metadata: HashMap::new(),
198 }
199 }
200
201 pub fn add_workflow(&mut self, workflow: WorkflowBundle) {
207 let existing_ids: std::collections::HashSet<String> =
208 self.steps.iter().map(|s| s.id.clone()).collect();
209
210 for mut step in workflow.steps {
211 if existing_ids.contains(&step.id) {
212 step.id = format!("{}_{}", step.id, self.steps.len());
213 }
214 self.steps.push(step);
215 }
216 }
217
218 #[must_use]
222 pub fn export_json(&self) -> String {
223 self.to_json()
224 }
225}
226
227fn escape_json(s: &str) -> String {
237 let mut out = String::with_capacity(s.len() + 4);
238 for ch in s.chars() {
239 match ch {
240 '"' => out.push_str("\\\""),
241 '\\' => out.push_str("\\\\"),
242 '\n' => out.push_str("\\n"),
243 '\r' => out.push_str("\\r"),
244 '\t' => out.push_str("\\t"),
245 c => out.push(c),
246 }
247 }
248 out
249}
250
251fn params_to_json(params: &[(String, String)]) -> String {
253 let items = params
254 .iter()
255 .map(|(k, v)| format!("[\"{}\",\"{}\"]", escape_json(k), escape_json(v)))
256 .collect::<Vec<_>>()
257 .join(",");
258 format!("[{items}]")
259}
260
261fn string_vec_to_json(v: &[String]) -> String {
263 let items = v
264 .iter()
265 .map(|s| format!("\"{}\"", escape_json(s)))
266 .collect::<Vec<_>>()
267 .join(",");
268 format!("[{items}]")
269}
270
271fn kv_map_to_json(map: &HashMap<String, String>) -> String {
273 let mut pairs: Vec<(&String, &String)> = map.iter().collect();
274 pairs.sort_by_key(|(k, _)| k.as_str());
276 let items = pairs
277 .iter()
278 .map(|(k, v)| format!("\"{}\":\"{}\"", escape_json(k), escape_json(v)))
279 .collect::<Vec<_>>()
280 .join(",");
281 format!("{{{items}}}")
282}
283
284fn bundle_step_to_json(step: &BundleStep) -> String {
286 format!(
287 r#"{{"id":"{}","action":"{}","params":{},"depends_on":{}}}"#,
288 escape_json(&step.id),
289 escape_json(&step.action),
290 params_to_json(&step.params),
291 string_vec_to_json(&step.depends_on),
292 )
293}
294
295fn extract_string(json: &str, key: &str) -> Option<String> {
301 let needle = format!("\"{}\":\"", key);
302 let start = json.find(&needle)? + needle.len();
303 let rest = &json[start..];
304 let mut value = String::new();
305 let mut chars = rest.chars().peekable();
306 loop {
307 match chars.next()? {
308 '"' => break,
309 '\\' => match chars.next()? {
310 '"' => value.push('"'),
311 '\\' => value.push('\\'),
312 'n' => value.push('\n'),
313 'r' => value.push('\r'),
314 't' => value.push('\t'),
315 c => {
316 value.push('\\');
317 value.push(c);
318 }
319 },
320 c => value.push(c),
321 }
322 }
323 Some(value)
324}
325
326fn extract_array_content<'a>(json: &'a str, key: &str) -> Option<&'a str> {
329 let needle = format!("\"{}\":[", key);
330 let start = json.find(&needle)? + needle.len();
331 let rest = &json[start..];
332 let mut depth = 1i32;
334 let mut inside_string = false;
336 let mut escape_next = false;
337 for (i, c) in rest.char_indices() {
338 if escape_next {
339 escape_next = false;
340 continue;
341 }
342 if inside_string {
343 if c == '\\' {
344 escape_next = true;
345 } else if c == '"' {
346 inside_string = false;
347 }
348 continue;
349 }
350 match c {
351 '"' => {
352 inside_string = true;
353 }
354 '[' => {
355 depth += 1;
356 }
357 ']' => {
358 depth -= 1;
359 if depth == 0 {
360 return Some(&rest[..i]);
361 }
362 }
363 _ => {}
364 }
365 }
366 None
367}
368
369fn extract_steps_array(json: &str) -> Option<Vec<BundleStep>> {
371 let content = extract_array_content(json, "steps")?;
372 if content.trim().is_empty() {
373 return Some(Vec::new());
374 }
375 let mut steps = Vec::new();
377 let mut remaining = content;
378 loop {
379 remaining = remaining.trim_start();
380 if remaining.is_empty() {
381 break;
382 }
383 if !remaining.starts_with('{') {
384 break;
385 }
386 let (obj, rest) = extract_first_object(remaining)?;
387 if let Some(step) = parse_bundle_step(obj) {
388 steps.push(step);
389 }
390 remaining = rest.trim_start_matches(',').trim_start();
391 }
392 Some(steps)
393}
394
395fn extract_first_object(s: &str) -> Option<(&str, &str)> {
398 if !s.starts_with('{') {
399 return None;
400 }
401 let mut depth = 0i32;
402 let mut inside_string = false;
403 let mut escape_next = false;
404 for (i, c) in s.char_indices() {
405 if escape_next {
406 escape_next = false;
407 continue;
408 }
409 if inside_string {
410 if c == '\\' {
411 escape_next = true;
412 } else if c == '"' {
413 inside_string = false;
414 }
415 continue;
416 }
417 match c {
418 '"' => {
419 inside_string = true;
420 }
421 '{' => {
422 depth += 1;
423 }
424 '}' => {
425 depth -= 1;
426 if depth == 0 {
427 return Some((&s[..=i], &s[i + 1..]));
428 }
429 }
430 _ => {}
431 }
432 }
433 None
434}
435
436fn parse_bundle_step(json: &str) -> Option<BundleStep> {
438 let id = extract_string(json, "id")?;
439 let action = extract_string(json, "action")?;
440 let params = extract_params_array(json).unwrap_or_default();
441 let depends_on = extract_string_array(json, "depends_on").unwrap_or_default();
442
443 Some(BundleStep {
444 id,
445 action,
446 params,
447 depends_on,
448 })
449}
450
451fn extract_params_array(json: &str) -> Option<Vec<(String, String)>> {
453 let content = extract_array_content(json, "params")?;
454 if content.trim().is_empty() {
455 return Some(Vec::new());
456 }
457
458 let mut pairs = Vec::new();
459 let mut remaining = content;
460 loop {
461 remaining = remaining.trim_start();
462 if remaining.is_empty() {
463 break;
464 }
465 if !remaining.starts_with('[') {
466 break;
467 }
468 let end = remaining.find(']')?;
470 let inner = &remaining[1..end];
471 let k = extract_nth_string(inner, 0)?;
473 let v = extract_nth_string(inner, 1)?;
474 pairs.push((k, v));
475 remaining = &remaining[end + 1..];
476 remaining = remaining.trim_start_matches(',').trim_start();
477 }
478 Some(pairs)
479}
480
481fn extract_nth_string(s: &str, n: usize) -> Option<String> {
484 let mut strings: Vec<String> = Vec::new();
486 let bytes = s.as_bytes();
487 let mut i = 0usize;
488
489 while i < bytes.len() {
490 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t' || bytes[i] == b',') {
492 i += 1;
493 }
494 if i >= bytes.len() {
495 break;
496 }
497 if bytes[i] != b'"' {
498 i += 1;
500 continue;
501 }
502 i += 1; let mut value = String::new();
505 loop {
506 if i >= bytes.len() {
507 break;
508 }
509 if bytes[i] == b'"' {
510 i += 1;
511 break;
512 }
513 if bytes[i] == b'\\' {
514 i += 1;
515 if i >= bytes.len() {
516 break;
517 }
518 match bytes[i] {
519 b'"' => {
520 value.push('"');
521 i += 1;
522 }
523 b'\\' => {
524 value.push('\\');
525 i += 1;
526 }
527 b'n' => {
528 value.push('\n');
529 i += 1;
530 }
531 b'r' => {
532 value.push('\r');
533 i += 1;
534 }
535 b't' => {
536 value.push('\t');
537 i += 1;
538 }
539 c => {
540 value.push('\\');
541 value.push(c as char);
542 i += 1;
543 }
544 }
545 } else {
546 value.push(bytes[i] as char);
547 i += 1;
548 }
549 }
550 strings.push(value);
551 if strings.len() > n {
552 break;
553 }
554 }
555
556 strings.into_iter().nth(n)
557}
558
559fn extract_string_array(json: &str, key: &str) -> Option<Vec<String>> {
561 let content = extract_array_content(json, key)?;
562 if content.trim().is_empty() {
563 return Some(Vec::new());
564 }
565
566 let mut items = Vec::new();
567 let mut remaining = content;
568 loop {
569 remaining = remaining.trim_start();
570 if remaining.is_empty() {
571 break;
572 }
573 if !remaining.starts_with('"') {
574 break;
575 }
576 remaining = &remaining[1..]; let mut value = String::new();
578 let bytes = remaining.as_bytes();
579 let mut j = 0usize;
580 loop {
581 if j >= bytes.len() {
582 break;
583 }
584 if bytes[j] == b'"' {
585 j += 1;
586 break;
587 }
588 if bytes[j] == b'\\' {
589 j += 1;
590 if j >= bytes.len() {
591 break;
592 }
593 match bytes[j] {
594 b'"' => {
595 value.push('"');
596 }
597 b'\\' => {
598 value.push('\\');
599 }
600 b'n' => {
601 value.push('\n');
602 }
603 b'r' => {
604 value.push('\r');
605 }
606 b't' => {
607 value.push('\t');
608 }
609 c => {
610 value.push('\\');
611 value.push(c as char);
612 }
613 }
614 j += 1;
615 } else {
616 value.push(bytes[j] as char);
617 j += 1;
618 }
619 }
620 remaining = &remaining[j..];
621 items.push(value);
622 remaining = remaining.trim_start_matches(',').trim_start();
623 }
624 Some(items)
625}
626
627fn extract_metadata_map(json: &str) -> Option<HashMap<String, String>> {
629 let needle = "\"metadata\":{";
630 let start = json.find(needle)? + needle.len();
631 let rest = &json[start..];
632 let end = rest.find('}')?;
633 let content = &rest[..end];
634
635 let mut map = HashMap::new();
636 if content.trim().is_empty() {
637 return Some(map);
638 }
639
640 let mut remaining = content;
642 loop {
643 remaining = remaining.trim_start();
644 if remaining.is_empty() {
645 break;
646 }
647 if !remaining.starts_with('"') {
648 break;
649 }
650
651 let k = extract_next_string(&mut remaining)?;
652 remaining = remaining.trim_start();
653 if !remaining.starts_with(':') {
654 break;
655 }
656 remaining = &remaining[1..];
657 remaining = remaining.trim_start();
658 if !remaining.starts_with('"') {
659 break;
660 }
661 let v = extract_next_string(&mut remaining)?;
662 map.insert(k, v);
663 remaining = remaining.trim_start_matches(',').trim_start();
664 }
665 Some(map)
666}
667
668fn extract_next_string(s: &mut &str) -> Option<String> {
671 let src = *s;
672 if !src.starts_with('"') {
673 return None;
674 }
675 let bytes = src.as_bytes();
676 let mut value = String::new();
677 let mut i = 1usize; loop {
679 if i >= bytes.len() {
680 return None;
681 }
682 if bytes[i] == b'"' {
683 i += 1;
684 break;
685 }
686 if bytes[i] == b'\\' {
687 i += 1;
688 if i >= bytes.len() {
689 return None;
690 }
691 match bytes[i] {
692 b'"' => {
693 value.push('"');
694 }
695 b'\\' => {
696 value.push('\\');
697 }
698 b'n' => {
699 value.push('\n');
700 }
701 b'r' => {
702 value.push('\r');
703 }
704 b't' => {
705 value.push('\t');
706 }
707 c => {
708 value.push('\\');
709 value.push(c as char);
710 }
711 }
712 i += 1;
713 } else {
714 value.push(bytes[i] as char);
715 i += 1;
716 }
717 }
718 *s = &src[i..];
719 Some(value)
720}
721
722#[cfg(test)]
727mod tests {
728 use super::*;
729
730 fn ingest_bundle() -> WorkflowBundle {
731 let mut meta = HashMap::new();
732 meta.insert("author".to_string(), "team-media".to_string());
733 meta.insert("env".to_string(), "prod".to_string());
734
735 WorkflowBundle {
736 name: "ingest-pipeline".to_string(),
737 version: "2.0.0".to_string(),
738 steps: vec![
739 BundleStep {
740 id: "ingest".to_string(),
741 action: "transcode".to_string(),
742 params: vec![
743 ("input".to_string(), "/raw.mp4".to_string()),
744 ("preset".to_string(), "broadcast".to_string()),
745 ],
746 depends_on: vec![],
747 },
748 BundleStep {
749 id: "qc".to_string(),
750 action: "quality-check".to_string(),
751 params: vec![("level".to_string(), "strict".to_string())],
752 depends_on: vec!["ingest".to_string()],
753 },
754 BundleStep {
755 id: "upload".to_string(),
756 action: "transfer".to_string(),
757 params: vec![("dest".to_string(), "s3://bucket/out/".to_string())],
758 depends_on: vec!["ingest".to_string(), "qc".to_string()],
759 },
760 ],
761 metadata: meta,
762 }
763 }
764
765 #[test]
770 fn to_json_and_back() {
771 let original = ingest_bundle();
772 let json = original.to_json();
773 let parsed = WorkflowBundle::from_json(&json).expect("parse should succeed");
774 assert_eq!(parsed.name, original.name);
775 assert_eq!(parsed.version, original.version);
776 assert_eq!(parsed.steps.len(), original.steps.len());
777 assert_eq!(parsed.steps[0].id, "ingest");
778 assert_eq!(parsed.steps[1].depends_on, vec!["ingest"]);
779 assert_eq!(parsed.steps[2].depends_on, vec!["ingest", "qc"]);
780 }
781
782 #[test]
783 fn round_trip_preserves_metadata() {
784 let bundle = ingest_bundle();
785 let json = bundle.to_json();
786 let parsed = WorkflowBundle::from_json(&json).expect("parse");
787 assert_eq!(
788 parsed.metadata.get("author").map(|s| s.as_str()),
789 Some("team-media")
790 );
791 assert_eq!(parsed.metadata.get("env").map(|s| s.as_str()), Some("prod"));
792 }
793
794 #[test]
795 fn round_trip_preserves_params() {
796 let bundle = ingest_bundle();
797 let json = bundle.to_json();
798 let parsed = WorkflowBundle::from_json(&json).expect("parse");
799 let first_step = &parsed.steps[0];
800 assert_eq!(
801 first_step.params,
802 vec![
803 ("input".to_string(), "/raw.mp4".to_string()),
804 ("preset".to_string(), "broadcast".to_string()),
805 ]
806 );
807 }
808
809 #[test]
810 fn empty_bundle_round_trip() {
811 let bundle = WorkflowBundle {
812 name: "empty".to_string(),
813 version: "0.1.0".to_string(),
814 steps: vec![],
815 metadata: HashMap::new(),
816 };
817 let json = bundle.to_json();
818 let parsed = WorkflowBundle::from_json(&json).expect("parse");
819 assert_eq!(parsed.name, "empty");
820 assert!(parsed.steps.is_empty());
821 }
822
823 #[test]
824 fn from_json_missing_name_returns_error() {
825 let bad = r#"{"version":"1.0","steps":[],"metadata":{}}"#;
826 assert!(WorkflowBundle::from_json(bad).is_err());
827 }
828
829 #[test]
834 fn validate_ok_for_valid_bundle() {
835 let bundle = ingest_bundle();
836 assert!(bundle.validate().is_ok());
837 }
838
839 #[test]
840 fn validate_detects_duplicate_ids() {
841 let mut bundle = ingest_bundle();
842 bundle.steps.push(BundleStep {
843 id: "ingest".to_string(), action: "dup-action".to_string(),
845 params: vec![],
846 depends_on: vec![],
847 });
848 let result = bundle.validate();
849 assert!(result.is_err());
850 let errors = result.err().unwrap();
851 assert!(errors.iter().any(|e| e.contains("duplicate")));
852 }
853
854 #[test]
855 fn validate_detects_missing_dependency() {
856 let mut bundle = ingest_bundle();
857 bundle.steps[2].depends_on.push("ghost-step".to_string());
858 let result = bundle.validate();
859 assert!(result.is_err());
860 let errors = result.err().unwrap();
861 assert!(errors.iter().any(|e| e.contains("ghost-step")));
862 }
863
864 #[test]
865 fn validate_reports_all_errors() {
866 let bundle = WorkflowBundle {
867 name: "bad".to_string(),
868 version: "1.0".to_string(),
869 steps: vec![
870 BundleStep {
871 id: "dup".to_string(),
872 action: "a".to_string(),
873 params: vec![],
874 depends_on: vec![],
875 },
876 BundleStep {
877 id: "dup".to_string(),
878 action: "b".to_string(),
879 params: vec![],
880 depends_on: vec!["missing".to_string()],
881 },
882 ],
883 metadata: HashMap::new(),
884 };
885 let result = bundle.validate();
886 assert!(result.is_err());
887 let errors = result.err().unwrap();
888 assert!(errors.len() >= 2);
890 }
891}