Skip to main content

oximedia_workflow/
workflow_bundle.rs

1//! Portable workflow bundle import/export for OxiMedia.
2//!
3//! A [`WorkflowBundle`] is a self-contained, human-readable description of a
4//! workflow that can be serialized to JSON, transmitted, stored in version
5//! control, and imported into any OxiMedia installation.
6//!
7//! # Design
8//!
9//! The bundle uses string IDs and name-based dependency references instead of
10//! UUIDs so that hand-editing is ergonomic.  All JSON serialization in this
11//! module is **hand-written** — no `serde_json` derive is used — to remain
12//! independent of the serde ecosystem for this lightweight data model.
13//!
14//! # Example
15//!
16//! ```rust
17//! use oximedia_workflow::workflow_bundle::{WorkflowBundle, BundleStep};
18//!
19//! let mut bundle = WorkflowBundle {
20//!     name: "ingest-pipeline".to_string(),
21//!     version: "1.0.0".to_string(),
22//!     steps: vec![
23//!         BundleStep {
24//!             id: "ingest".to_string(),
25//!             action: "transcode".to_string(),
26//!             params: vec![("input".to_string(), "/raw.mp4".to_string())],
27//!             depends_on: vec![],
28//!         },
29//!         BundleStep {
30//!             id: "upload".to_string(),
31//!             action: "transfer".to_string(),
32//!             params: vec![("dest".to_string(), "s3://bucket/".to_string())],
33//!             depends_on: vec!["ingest".to_string()],
34//!         },
35//!     ],
36//!     metadata: std::collections::HashMap::new(),
37//! };
38//!
39//! let json = bundle.to_json();
40//! let parsed = WorkflowBundle::from_json(&json).expect("parse");
41//! assert_eq!(parsed.name, "ingest-pipeline");
42//! assert_eq!(parsed.steps.len(), 2);
43//!
44//! bundle.validate().expect("validation should pass");
45//! ```
46
47#![allow(dead_code)]
48
49use std::collections::HashMap;
50
51// ---------------------------------------------------------------------------
52// BundleStep
53// ---------------------------------------------------------------------------
54
55/// A single processing step within a [`WorkflowBundle`].
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct BundleStep {
58    /// Step identifier — must be unique within the bundle.
59    pub id: String,
60    /// Name of the action/handler to invoke (e.g. `"transcode"`, `"qc"`).
61    pub action: String,
62    /// Named parameters passed to the action.
63    pub params: Vec<(String, String)>,
64    /// IDs of other steps that must complete before this step can run.
65    pub depends_on: Vec<String>,
66}
67
68// ---------------------------------------------------------------------------
69// WorkflowBundle
70// ---------------------------------------------------------------------------
71
72/// A portable, self-contained workflow definition.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct WorkflowBundle {
75    /// Human-readable name for the workflow.
76    pub name: String,
77    /// Semantic version string (e.g. `"1.0.0"`).
78    pub version: String,
79    /// Ordered list of processing steps.
80    pub steps: Vec<BundleStep>,
81    /// Arbitrary key/value metadata (author, description, tags, …).
82    pub metadata: HashMap<String, String>,
83}
84
85impl WorkflowBundle {
86    // ------------------------------------------------------------------
87    // Serialization
88    // ------------------------------------------------------------------
89
90    /// Serialize this bundle to a compact JSON string.
91    ///
92    /// The output can be fed back into [`WorkflowBundle::from_json`] for a
93    /// lossless round-trip.
94    #[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    /// Deserialize a bundle from a JSON string produced by [`WorkflowBundle::to_json`].
115    ///
116    /// # Errors
117    ///
118    /// Returns a descriptive error string if required fields are missing or
119    /// cannot be parsed.
120    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    // ------------------------------------------------------------------
139    // Validation
140    // ------------------------------------------------------------------
141
142    /// Validate the bundle, checking for:
143    ///
144    /// 1. Duplicate step IDs.
145    /// 2. Dependency references to unknown step IDs.
146    ///
147    /// Returns `Ok(())` when all checks pass, or `Err(errors)` where `errors`
148    /// is a non-empty list of human-readable problem descriptions.
149    pub fn validate(&self) -> Result<(), Vec<String>> {
150        let mut errors = Vec::new();
151
152        // Build a set of known step IDs.
153        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        // Check dependency references.
168        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    // ------------------------------------------------------------------
187    // Convenience constructors and mutators
188    // ------------------------------------------------------------------
189
190    /// Create an empty bundle with the given name and version `"1.0.0"`.
191    #[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    /// Add a sub-workflow by embedding its steps into this bundle.
202    ///
203    /// Steps from `workflow` are appended to this bundle's step list.
204    /// If a step ID already exists in this bundle it is suffixed with
205    /// `"_<n>"` to avoid collisions.
206    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    /// Serialize this bundle to JSON — alias for [`Self::to_json`].
219    ///
220    /// Provided so the task-specified API `export_json()` is available.
221    #[must_use]
222    pub fn export_json(&self) -> String {
223        self.to_json()
224    }
225}
226
227// ===========================================================================
228// Hand-written JSON helpers
229// ===========================================================================
230
231// ---------------------------------------------------------------------------
232// Serialization helpers
233// ---------------------------------------------------------------------------
234
235/// Escape a string for embedding in a JSON value.
236fn 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
251/// Serialize a `Vec<(String, String)>` to a JSON array of `[k, v]` pairs.
252fn 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
261/// Serialize a `Vec<String>` to a JSON array of strings.
262fn 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
271/// Serialize a `HashMap<String, String>` to a JSON object.
272fn kv_map_to_json(map: &HashMap<String, String>) -> String {
273    let mut pairs: Vec<(&String, &String)> = map.iter().collect();
274    // Sort for deterministic output.
275    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
284/// Serialize a [`BundleStep`] to JSON.
285fn 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
295// ---------------------------------------------------------------------------
296// Deserialization helpers
297// ---------------------------------------------------------------------------
298
299/// Extract a JSON string field from a JSON object string.
300fn 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
326/// Extract the content between the first occurrence of `"key":[` and the
327/// matching `]`, allowing one level of nesting.
328fn 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    // Find the matching `]`, tracking bracket depth.
333    let mut depth = 1i32;
334    // Skip inside strings (simple handling: track inside_string flag).
335    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
369/// Parse the `steps` array from the bundle JSON.
370fn 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    // Each step is a `{...}` object. We split by finding top-level `{...}` blocks.
376    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
395/// Extract the first `{...}` object from `s` (handling nesting and strings).
396/// Returns `(object_slice, remainder_after_object)`.
397fn 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
436/// Parse a single [`BundleStep`] from its JSON object string.
437fn 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
451/// Parse `"params":[[k,v],...]` from a step JSON object.
452fn 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        // Find the inner `[k, v]` — two strings separated by a comma.
469        let end = remaining.find(']')?;
470        let inner = &remaining[1..end];
471        // Parse two quoted strings.
472        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
481/// Extract the nth quoted string (0-indexed) from a comma-separated list of
482/// JSON strings like `"foo","bar"`.
483fn extract_nth_string(s: &str, n: usize) -> Option<String> {
484    // Collect all quoted strings in order, then return the nth one.
485    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        // Skip whitespace and commas
491        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            // Skip non-string characters
499            i += 1;
500            continue;
501        }
502        // Parse a quoted string starting at i
503        i += 1; // skip opening "
504        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
559/// Extract a JSON array of strings from a JSON object.
560fn 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..]; // skip opening "
577        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
627/// Extract the `metadata` object from the bundle JSON.
628fn 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    // Parse "key":"value" pairs.
641    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
668/// Extract and consume the next quoted JSON string from `s`, advancing `s`
669/// past the closing quote.
670fn 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; // skip opening "
678    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// ===========================================================================
723// Tests
724// ===========================================================================
725
726#[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    // ------------------------------------------------------------------
766    // JSON round-trip
767    // ------------------------------------------------------------------
768
769    #[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    // ------------------------------------------------------------------
830    // Validation
831    // ------------------------------------------------------------------
832
833    #[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(), // duplicate
844            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        // At minimum one duplicate error and one missing-dep error
889        assert!(errors.len() >= 2);
890    }
891}