Skip to main content

wyvern/workflow/
chain.rs

1//! `next_wizard` resolution and wizard config deep-merge.
2
3use std::path::PathBuf;
4
5use serde_json::{Map, Value};
6
7use super::{Allowlist, WorkflowError};
8use crate::error::LoadError;
9use crate::extensions::infer_wizard_root;
10use crate::input::read_file_capped;
11use wyvern_schema::NextWizard;
12
13/// Next hop loaded from `next_wizard.path` (CLI resolves; host does not).
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct NextInvocation {
16    /// Loaded wizard command JSON.
17    pub command: Value,
18    /// UI root for the next host session.
19    pub ui_root: PathBuf,
20    /// Directory of the next `wizard.json`.
21    pub wizard_dir: PathBuf,
22    /// `next_wizard.input` deep-merged into the next wizard `config`.
23    pub input: Value,
24}
25
26/// Resolve an optional `next_wizard` object on finish JSON.
27///
28/// Deserializes the hop to [`NextWizard`] so path / input / `ui_root` are typed
29/// once; the pipeline must not walk the finish JSON again for `input`.
30///
31/// # Errors
32///
33/// Returns [`WorkflowError::Resolve`] or [`WorkflowError::PathDenied`] when the
34/// path or `ui_root` cannot be allowed.
35pub fn resolve_next_wizard(
36    finish: &Value,
37    allowlist: &Allowlist,
38) -> Result<Option<NextInvocation>, WorkflowError> {
39    let Some(next) = finish.get("next_wizard") else {
40        return Ok(None);
41    };
42    if next.is_null() {
43        return Ok(None);
44    }
45    let path_hint = next
46        .get("path")
47        .and_then(Value::as_str)
48        .filter(|path| !path.is_empty())
49        .unwrap_or("next_wizard.path")
50        .to_string();
51    let next: NextWizard =
52        serde_json::from_value(next.clone()).map_err(|err| WorkflowError::Resolve {
53            path: path_hint,
54            cause: format!("next_wizard is invalid: {err}"),
55        })?;
56    let path = next.path.as_str();
57    let wizard_path = allowlist.resolve_allowed(path)?;
58    let text = read_file_capped(&wizard_path).map_err(|err| match err {
59        LoadError::Io { message, .. } => WorkflowError::Resolve {
60            path: path.to_string(),
61            cause: message,
62        },
63        LoadError::Parse { message } | LoadError::Usage { message, .. } => WorkflowError::Resolve {
64            path: path.to_string(),
65            cause: message,
66        },
67    })?;
68    let command: Value = serde_json::from_str(&text).map_err(|err| WorkflowError::Resolve {
69        path: path.to_string(),
70        cause: format!("wizard.json is not JSON: {err}"),
71    })?;
72    let wizard_dir = infer_wizard_root(&wizard_path);
73    let ui_root = match next.ui_root.as_ref() {
74        Some(raw) => allowlist.resolve_allowed(raw.as_str())?,
75        None => wizard_dir.clone(),
76    };
77    Ok(Some(NextInvocation {
78        command,
79        ui_root,
80        wizard_dir,
81        input: next.input,
82    }))
83}
84
85/// Deep-merge `base ← input ← config_patch`.
86///
87/// Object keys deep-merge; arrays and scalars replace. Non-object `input` or
88/// `config_patch` is [`WorkflowError::Merge`].
89///
90/// # Errors
91///
92/// Returns [`WorkflowError::Merge`] when `input` or `config_patch` is not an object.
93pub fn merge_wizard_config(
94    base: Value,
95    input: Value,
96    config_patch: Option<Value>,
97) -> Result<Value, WorkflowError> {
98    if !input.is_object() {
99        return Err(WorkflowError::Merge {
100            cause: "next_wizard.input must be a JSON object".into(),
101        });
102    }
103    let mut out = deep_merge(base, input);
104    if let Some(patch) = config_patch {
105        if !patch.is_object() {
106            return Err(WorkflowError::Merge {
107                cause: "config_patch must be a JSON object".into(),
108            });
109        }
110        out = deep_merge(out, patch);
111    }
112    Ok(out)
113}
114
115fn deep_merge(base: Value, overlay: Value) -> Value {
116    match (base, overlay) {
117        (Value::Object(mut base_map), Value::Object(overlay_map)) => {
118            merge_objects(&mut base_map, overlay_map);
119            Value::Object(base_map)
120        }
121        (_, overlay) => overlay,
122    }
123}
124
125fn merge_objects(base: &mut Map<String, Value>, overlay: Map<String, Value>) {
126    for (key, value) in overlay {
127        match base.remove(&key) {
128            Some(existing) => base.insert(key, deep_merge(existing, value)),
129            None => base.insert(key, value),
130        };
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use serde_json::json;
138
139    #[test]
140    fn merge_deep_objects_replace_arrays() {
141        let merged = merge_wizard_config(
142            json!({"a": {"x": 1, "y": 2}, "keep": true, "list": [1]}),
143            json!({"a": {"y": 9, "z": 3}, "list": [2, 3]}),
144            Some(json!({"extra": false})),
145        )
146        .expect("merge");
147        assert_eq!(
148            merged,
149            json!({"a": {"x": 1, "y": 9, "z": 3}, "keep": true, "list": [2, 3], "extra": false})
150        );
151    }
152
153    #[test]
154    fn merge_rejects_non_object_input() {
155        let err = merge_wizard_config(json!({}), json!([]), None).expect_err("input");
156        assert!(matches!(err, WorkflowError::Merge { .. }));
157    }
158
159    #[test]
160    fn resolve_absent_next_wizard_is_none() {
161        let tmp = tempfile::tempdir().expect("tmp");
162        let allow = Allowlist {
163            share_root: tmp.path().to_path_buf(),
164            cwd: tmp.path().to_path_buf(),
165            wizard_dir: tmp.path().to_path_buf(),
166        };
167        assert!(resolve_next_wizard(&json!({"button": "finish"}), &allow)
168            .expect("ok")
169            .is_none());
170    }
171
172    #[test]
173    fn resolve_next_wizard_carries_typed_input() {
174        let tmp = tempfile::tempdir().expect("tmp");
175        let wizard = tmp.path().join("wizard.json");
176        std::fs::write(
177            &wizard,
178            r#"{"type":"wizard","page":{"id":"a","title":"T","html":"a.html"}}"#,
179        )
180        .unwrap();
181        let allow = Allowlist {
182            share_root: tmp.path().to_path_buf(),
183            cwd: tmp.path().to_path_buf(),
184            wizard_dir: tmp.path().to_path_buf(),
185        };
186        let finish = json!({
187            "button": "finish",
188            "next_wizard": {
189                "path": wizard.to_string_lossy(),
190                "input": {"from": "a"}
191            }
192        });
193        let next = resolve_next_wizard(&finish, &allow)
194            .expect("ok")
195            .expect("some");
196        assert_eq!(next.input, json!({"from": "a"}));
197        assert_eq!(next.command["type"], "wizard");
198    }
199
200    #[test]
201    fn resolve_empty_next_wizard_path_includes_path_context() {
202        let tmp = tempfile::tempdir().expect("tmp");
203        let allow = Allowlist {
204            share_root: tmp.path().to_path_buf(),
205            cwd: tmp.path().to_path_buf(),
206            wizard_dir: tmp.path().to_path_buf(),
207        };
208        let err = resolve_next_wizard(
209            &json!({"button": "finish", "next_wizard": {"path": ""}}),
210            &allow,
211        )
212        .expect_err("empty path");
213        match err {
214            WorkflowError::Resolve { path, cause } => {
215                assert_eq!(path, "next_wizard.path");
216                assert!(
217                    cause.contains("next_wizard is invalid"),
218                    "expected deserialize context: {cause}"
219                );
220            }
221            other => panic!("expected Resolve, got {other:?}"),
222        }
223    }
224}