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