use std::{
fs,
path::{Path, PathBuf},
};
pub struct AtelierWebResponse {
pub status: u16,
pub content_type: &'static str,
pub body: String,
}
pub struct AtelierWebState {
root: PathBuf,
shell_json: String,
scenarios_json: String,
}
impl AtelierWebState {
pub fn load(root: impl Into<PathBuf>) -> Self {
let root = root.into();
let shell_file = root.join("shell.json");
let shell_json = fs::read_to_string(&shell_file).unwrap_or_else(|err| {
fallback_json(
&root,
"missing-cache",
&format!("{}: {err}", shell_file.display()),
)
});
let scenarios_json = scenarios_response_json(&root, &shell_json);
Self {
root,
shell_json,
scenarios_json,
}
}
pub fn response(&self, method: &str, target: &str) -> Option<AtelierWebResponse> {
let path = target.split(['?', '#']).next().unwrap_or(target);
if !path.starts_with("/api/atelier") {
return None;
}
if method != "GET" {
return Some(AtelierWebResponse {
status: 405,
content_type: "text/plain; charset=utf-8",
body: "method not allowed".to_owned(),
});
}
match path {
"/api/atelier" | "/api/atelier/shell" => Some(AtelierWebResponse {
status: 200,
content_type: "application/json; charset=utf-8",
body: self.shell_json.clone(),
}),
"/api/atelier/scenarios" => Some(AtelierWebResponse {
status: 200,
content_type: "application/json; charset=utf-8",
body: self.scenarios_json.clone(),
}),
"/api/atelier/status" => Some(AtelierWebResponse {
status: 200,
content_type: "application/json; charset=utf-8",
body: status_json(&self.root, "ready"),
}),
_ => Some(AtelierWebResponse {
status: 404,
content_type: "text/plain; charset=utf-8",
body: "not found".to_owned(),
}),
}
}
}
fn status_json(root: &Path, status: &str) -> String {
format!(
"{{\n \"schema\": \"sim.atelier.web-status.v1\",\n \"status\": \"{}\",\n \"cache_root\": \"{}\"\n}}\n",
json_escape(status),
json_escape(&root.to_string_lossy()),
)
}
fn scenarios_response_json(root: &Path, shell_json: &str) -> String {
let scenarios = extract_json_field(shell_json, "scenarios")
.unwrap_or_else(|| "{\"scenarios\":[]}".to_owned());
format!(
"{{\n \"schema\": \"sim.atelier.web-scenarios.v1\",\n \"cache_root\": \"{}\",\n \"snapshot\": {}\n}}\n",
json_escape(&root.to_string_lossy()),
scenarios
)
}
fn fallback_json(root: &Path, status: &str, message: &str) -> String {
format!(
"{{\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",
json_escape(status),
json_escape(message),
json_escape(&root.to_string_lossy()),
)
}
fn extract_json_field(input: &str, field: &str) -> Option<String> {
let needle = format!("\"{field}\"");
let start = input.find(&needle)?;
let after_key = &input[start + needle.len()..];
let colon = after_key.find(':')?;
let value = after_key[colon + 1..].trim_start();
let open = value.chars().next()?;
let close = match open {
'{' => '}',
'[' => ']',
_ => return None,
};
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
for (index, ch) in value.char_indices() {
if in_string {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
if ch == '"' {
in_string = true;
} else if ch == open {
depth += 1;
} else if ch == close {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some(value[..=index].to_owned());
}
}
}
None
}
fn json_escape(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
#[cfg(test)]
mod tests {
use std::fs;
use super::AtelierWebState;
#[test]
fn atelier_api_serves_cached_shell_json() {
let root =
std::env::temp_dir().join(format!("sim-web-shell-atelier-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
fs::write(
root.join("shell.json"),
"{\n \"schema\": \"sim.atelier.shell.v1\",\n \"navigation\": []\n}\n",
)
.unwrap();
let state = AtelierWebState::load(&root);
let response = state.response("GET", "/api/atelier").unwrap();
assert_eq!(response.status, 200);
assert_eq!(response.content_type, "application/json; charset=utf-8");
assert!(response.body.contains("sim.atelier.shell.v1"));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn atelier_api_reports_missing_cache_without_reading_source() {
let root = std::env::temp_dir().join(format!(
"sim-web-shell-atelier-missing-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
let state = AtelierWebState::load(&root);
let response = state.response("GET", "/api/atelier/shell").unwrap();
assert_eq!(response.status, 200);
assert!(response.body.contains("missing-cache"));
}
#[test]
fn atelier_api_fails_closed_for_unknown_paths_and_methods() {
let state = AtelierWebState::load(".sim/atelier");
assert_eq!(state.response("POST", "/api/atelier").unwrap().status, 405);
assert_eq!(
state.response("GET", "/api/atelier/source").unwrap().status,
404
);
assert!(state.response("GET", "/api/cookbook").is_none());
}
#[test]
fn atelier_api_serves_scenario_snapshot_fixture() {
let root = std::env::temp_dir().join(format!(
"sim-web-shell-atelier-scenarios-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
fs::write(
root.join("shell.json"),
"{\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",
)
.unwrap();
let state = AtelierWebState::load(&root);
let response = state.response("GET", "/api/atelier/scenarios").unwrap();
assert_eq!(response.status, 200);
assert!(response.body.contains("sim.atelier.web-scenarios.v1"));
assert!(response.body.contains("atelier-change-capsule"));
assert!(response.body.contains("fnv1a64:5ec7c4222478f8f1"));
fs::remove_dir_all(root).unwrap();
}
}