1use 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#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct NextInvocation {
16 pub command: Value,
18 pub ui_root: PathBuf,
20 pub wizard_dir: PathBuf,
22 pub input: Value,
24}
25
26pub 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
85pub 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}