Skip to main content

sim_web_shell/
atelier.rs

1//! Atelier shell cache adapter for the web server.
2
3use std::{
4    fs,
5    path::{Path, PathBuf},
6};
7
8use sim_codec_json::json_escape;
9
10/// HTTP response generated by the Atelier cache adapter.
11pub struct AtelierWebResponse {
12    /// HTTP status code.
13    pub status: u16,
14    /// Response content type.
15    pub content_type: &'static str,
16    /// Response body.
17    pub body: String,
18}
19
20/// Atelier state loaded by `sim-web-shell` at startup.
21pub struct AtelierWebState {
22    root: PathBuf,
23    shell_json: String,
24    scenarios_json: String,
25}
26
27impl AtelierWebState {
28    /// Load `.sim/atelier/shell.json` from the supplied cache root.
29    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    /// Return a response for an Atelier API route.
48    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}