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