sim-web-shell 0.1.0

sim-web-shell: the binary that serves the SIM WebUI shell.
Documentation
//! Atelier shell cache adapter for the web server.

use std::{
    fs,
    path::{Path, PathBuf},
};

/// HTTP response generated by the Atelier cache adapter.
pub struct AtelierWebResponse {
    /// HTTP status code.
    pub status: u16,
    /// Response content type.
    pub content_type: &'static str,
    /// Response body.
    pub body: String,
}

/// Atelier state loaded by `sim-web-shell` at startup.
pub struct AtelierWebState {
    root: PathBuf,
    shell_json: String,
    scenarios_json: String,
}

impl AtelierWebState {
    /// Load `.sim/atelier/shell.json` from the supplied cache root.
    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,
        }
    }

    /// Return a response for an Atelier API route.
    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();
    }
}