1use std::{
4 fs,
5 path::{Path, PathBuf},
6};
7
8use sim_codec_json::json_escape;
9
10pub struct AtelierWebResponse {
12 pub status: u16,
14 pub content_type: &'static str,
16 pub body: String,
18}
19
20pub struct AtelierWebState {
22 root: PathBuf,
23 shell_json: String,
24 scenarios_json: String,
25}
26
27impl AtelierWebState {
28 pub fn load(root: impl Into<PathBuf>) -> Self {
30 let root = root.into();
31 let shell_file = root.join("shell.json");
32 let shell_json = fs::read_to_string(&shell_file).unwrap_or_else(|err| {
33 fallback_json(
34 &root,
35 "missing-cache",
36 &format!("{}: {err}", shell_file.display()),
37 )
38 });
39 let scenarios_json = scenarios_response_json(&root, &shell_json);
40 Self {
41 root,
42 shell_json,
43 scenarios_json,
44 }
45 }
46
47 pub fn response(&self, method: &str, target: &str) -> Option<AtelierWebResponse> {
49 let path = target.split(['?', '#']).next().unwrap_or(target);
50 if !path.starts_with("/api/atelier") {
51 return None;
52 }
53 if method != "GET" {
54 return Some(AtelierWebResponse {
55 status: 405,
56 content_type: "text/plain; charset=utf-8",
57 body: "method not allowed".to_owned(),
58 });
59 }
60 match path {
61 "/api/atelier" | "/api/atelier/shell" => Some(AtelierWebResponse {
62 status: 200,
63 content_type: "application/json; charset=utf-8",
64 body: self.shell_json.clone(),
65 }),
66 "/api/atelier/scenarios" => Some(AtelierWebResponse {
67 status: 200,
68 content_type: "application/json; charset=utf-8",
69 body: self.scenarios_json.clone(),
70 }),
71 "/api/atelier/status" => Some(AtelierWebResponse {
72 status: 200,
73 content_type: "application/json; charset=utf-8",
74 body: status_json(&self.root, "ready"),
75 }),
76 _ => Some(AtelierWebResponse {
77 status: 404,
78 content_type: "text/plain; charset=utf-8",
79 body: "not found".to_owned(),
80 }),
81 }
82 }
83}
84
85fn status_json(root: &Path, status: &str) -> String {
86 format!(
87 "{{\n \"schema\": \"sim.atelier.web-status.v1\",\n \"status\": \"{}\",\n \"cache_root\": \"{}\"\n}}\n",
88 json_escape(status),
89 json_escape(&root.to_string_lossy()),
90 )
91}
92
93fn scenarios_response_json(root: &Path, shell_json: &str) -> String {
94 let scenarios = extract_json_field(shell_json, "scenarios")
95 .unwrap_or_else(|| "{\"scenarios\":[]}".to_owned());
96 format!(
97 "{{\n \"schema\": \"sim.atelier.web-scenarios.v1\",\n \"cache_root\": \"{}\",\n \"snapshot\": {}\n}}\n",
98 json_escape(&root.to_string_lossy()),
99 scenarios
100 )
101}
102
103fn fallback_json(root: &Path, status: &str, message: &str) -> String {
104 format!(
105 "{{\n \"schema\": \"sim.atelier.shell.v1\",\n \"startup\": {{\n \"cache\": {{\n \"shell\": \"{}\"\n }},\n \"diagnostics\": [\"{}\"]\n }},\n \"cache_root\": \"{}\",\n \"navigation\": [],\n \"panels\": [],\n \"radar\": [],\n \"firewall\": {{\"rules\": [], \"findings\": []}}\n}}\n",
106 json_escape(status),
107 json_escape(message),
108 json_escape(&root.to_string_lossy()),
109 )
110}
111
112fn extract_json_field(input: &str, field: &str) -> Option<String> {
113 let needle = format!("\"{field}\"");
114 let start = input.find(&needle)?;
115 let after_key = &input[start + needle.len()..];
116 let colon = after_key.find(':')?;
117 let value = after_key[colon + 1..].trim_start();
118 let open = value.chars().next()?;
119 let close = match open {
120 '{' => '}',
121 '[' => ']',
122 _ => return None,
123 };
124 let mut depth = 0usize;
125 let mut in_string = false;
126 let mut escaped = false;
127 for (index, ch) in value.char_indices() {
128 if in_string {
129 if escaped {
130 escaped = false;
131 } else if ch == '\\' {
132 escaped = true;
133 } else if ch == '"' {
134 in_string = false;
135 }
136 continue;
137 }
138 if ch == '"' {
139 in_string = true;
140 } else if ch == open {
141 depth += 1;
142 } else if ch == close {
143 depth = depth.saturating_sub(1);
144 if depth == 0 {
145 return Some(value[..=index].to_owned());
146 }
147 }
148 }
149 None
150}
151
152#[cfg(test)]
153mod tests {
154 use std::fs;
155
156 use super::AtelierWebState;
157
158 #[test]
159 fn atelier_api_serves_cached_shell_json() {
160 let root =
161 std::env::temp_dir().join(format!("sim-web-shell-atelier-{}", std::process::id()));
162 let _ = fs::remove_dir_all(&root);
163 fs::create_dir_all(&root).unwrap();
164 fs::write(
165 root.join("shell.json"),
166 "{\n \"schema\": \"sim.atelier.shell.v1\",\n \"navigation\": []\n}\n",
167 )
168 .unwrap();
169
170 let state = AtelierWebState::load(&root);
171 let response = state.response("GET", "/api/atelier").unwrap();
172 assert_eq!(response.status, 200);
173 assert_eq!(response.content_type, "application/json; charset=utf-8");
174 assert!(response.body.contains("sim.atelier.shell.v1"));
175 fs::remove_dir_all(root).unwrap();
176 }
177
178 #[test]
179 fn atelier_api_serves_contract_native_cached_shell_json() {
180 let root = std::env::temp_dir().join(format!(
181 "sim-web-shell-atelier-contract-native-{}",
182 std::process::id()
183 ));
184 let _ = fs::remove_dir_all(&root);
185 fs::create_dir_all(&root).unwrap();
186 fs::write(
187 root.join("shell.json"),
188 "{\n \"schema\": \"sim.atelier.shell.v1\",\n \"contract_native\": {\n \"schema\": \"sim.atelier.contract-native.v1\",\n \"cassette_hash\": \"fnv1a64:bf233c4e5a8bc12d\"\n },\n \"scenarios\": []\n}\n",
189 )
190 .unwrap();
191
192 let state = AtelierWebState::load(&root);
193 let response = state.response("GET", "/api/atelier").unwrap();
194 assert_eq!(response.status, 200);
195 assert!(response.body.contains("sim.atelier.contract-native.v1"));
196 assert!(response.body.contains("fnv1a64:bf233c4e5a8bc12d"));
197 fs::remove_dir_all(root).unwrap();
198 }
199
200 #[test]
201 fn atelier_api_reports_missing_cache_without_reading_source() {
202 let root = std::env::temp_dir().join(format!(
203 "sim-web-shell-atelier-missing-{}",
204 std::process::id()
205 ));
206 let _ = fs::remove_dir_all(&root);
207
208 let state = AtelierWebState::load(&root);
209 let response = state.response("GET", "/api/atelier/shell").unwrap();
210 assert_eq!(response.status, 200);
211 assert!(response.body.contains("missing-cache"));
212 }
213
214 #[test]
215 fn atelier_api_fails_closed_for_unknown_paths_and_methods() {
216 let state = AtelierWebState::load(".sim/atelier");
217 assert_eq!(state.response("POST", "/api/atelier").unwrap().status, 405);
218 assert_eq!(
219 state.response("GET", "/api/atelier/source").unwrap().status,
220 404
221 );
222 assert!(state.response("GET", "/api/cookbook").is_none());
223 }
224
225 #[test]
226 fn atelier_api_serves_scenario_snapshot_fixture() {
227 let root = std::env::temp_dir().join(format!(
228 "sim-web-shell-atelier-scenarios-{}",
229 std::process::id()
230 ));
231 let _ = fs::remove_dir_all(&root);
232 fs::create_dir_all(&root).unwrap();
233 fs::write(
234 root.join("shell.json"),
235 "{\n \"schema\": \"sim.atelier.shell.v1\",\n \"scenarios\": {\n \"schema\": \"sim.atelier.self-hosting-scenarios.v1\",\n \"scenarios\": [{\"id\":\"atelier-change-capsule\",\"cassette_hash\":\"fnv1a64:5ec7c4222478f8f1\"}]\n }\n}\n",
236 )
237 .unwrap();
238
239 let state = AtelierWebState::load(&root);
240 let response = state.response("GET", "/api/atelier/scenarios").unwrap();
241 assert_eq!(response.status, 200);
242 assert!(response.body.contains("sim.atelier.web-scenarios.v1"));
243 assert!(response.body.contains("atelier-change-capsule"));
244 assert!(response.body.contains("fnv1a64:5ec7c4222478f8f1"));
245 fs::remove_dir_all(root).unwrap();
246 }
247}