1use crate::content::{ContentResolver, LocalContentResolver};
4use crate::error::ErrorExt;
5use crate::templates::FAVICON_SVG;
6use axum::{
7 Json, Router,
8 extract::{Path, State},
9 http::{StatusCode, header},
10 response::{Html, IntoResponse, Response},
11 routing::get,
12};
13use statespace_tool_runtime::{
14 ActionRequest, ActionResponse, BuiltinTool, ExecutionLimits, ToolExecutor, expand_env_vars,
15 expand_placeholders, parse_frontmatter, validate_command_with_specs,
16};
17use std::path::PathBuf;
18use std::sync::Arc;
19use tokio::fs;
20use tower_http::cors::{Any, CorsLayer};
21use tower_http::trace::TraceLayer;
22use tracing::{info, warn};
23
24#[derive(Debug, Clone)]
25pub struct ServerConfig {
26 pub content_root: PathBuf,
27 pub host: String,
28 pub port: u16,
29 pub limits: ExecutionLimits,
30}
31
32impl ServerConfig {
33 #[must_use]
34 pub fn new(content_root: PathBuf) -> Self {
35 Self {
36 content_root,
37 host: "127.0.0.1".to_string(),
38 port: 8000,
39 limits: ExecutionLimits::default(),
40 }
41 }
42
43 #[must_use]
44 pub fn with_host(mut self, host: impl Into<String>) -> Self {
45 self.host = host.into();
46 self
47 }
48
49 #[must_use]
50 pub const fn with_port(mut self, port: u16) -> Self {
51 self.port = port;
52 self
53 }
54
55 #[must_use]
56 pub fn with_limits(mut self, limits: ExecutionLimits) -> Self {
57 self.limits = limits;
58 self
59 }
60
61 #[must_use]
62 pub fn socket_addr(&self) -> String {
63 format!("{}:{}", self.host, self.port)
64 }
65
66 #[must_use]
67 pub fn base_url(&self) -> String {
68 format!("http://{}:{}", self.host, self.port)
69 }
70}
71
72#[derive(Clone)]
73pub struct ServerState {
74 pub content_resolver: Arc<dyn ContentResolver>,
75 pub limits: ExecutionLimits,
76 pub content_root: PathBuf,
77}
78
79impl std::fmt::Debug for ServerState {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.debug_struct("ServerState")
82 .field("limits", &self.limits)
83 .field("content_root", &self.content_root)
84 .finish_non_exhaustive()
85 }
86}
87
88impl ServerState {
89 pub fn from_config(config: &ServerConfig) -> crate::error::Result<Self> {
93 Ok(Self {
94 content_resolver: Arc::new(LocalContentResolver::new(&config.content_root)?),
95 limits: config.limits.clone(),
96 content_root: config.content_root.clone(),
97 })
98 }
99}
100
101pub fn build_router(config: &ServerConfig) -> crate::error::Result<Router> {
105 let state = ServerState::from_config(config)?;
106
107 let cors = CorsLayer::new()
108 .allow_origin(Any)
109 .allow_methods(Any)
110 .allow_headers(Any);
111
112 Ok(Router::new()
113 .route("/", get(index_handler).post(action_handler_root))
114 .route("/favicon.svg", get(favicon_handler))
115 .route("/favicon.ico", get(favicon_handler))
116 .route("/{*path}", get(file_handler).post(action_handler))
117 .layer(cors)
118 .layer(TraceLayer::new_for_http())
119 .with_state(state))
120}
121
122async fn index_handler(State(state): State<ServerState>) -> Response {
123 let index_path = state.content_root.join("index.html");
124
125 if index_path.is_file() {
126 match fs::read_to_string(&index_path).await {
127 Ok(content) => {
128 return (
129 StatusCode::OK,
130 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
131 content,
132 )
133 .into_response();
134 }
135 Err(e) => {
136 warn!("Failed to read index.html: {}", e);
137 }
138 }
139 }
140
141 serve_markdown("", &state).await
142}
143
144async fn favicon_handler(State(state): State<ServerState>) -> Response {
145 let favicon_path = state.content_root.join("favicon.svg");
146
147 let content = if favicon_path.is_file() {
148 fs::read_to_string(&favicon_path)
149 .await
150 .unwrap_or_else(|_| FAVICON_SVG.to_string())
151 } else {
152 FAVICON_SVG.to_string()
153 };
154
155 (
156 StatusCode::OK,
157 [(header::CONTENT_TYPE, "image/svg+xml")],
158 content,
159 )
160 .into_response()
161}
162
163async fn file_handler(Path(path): Path<String>, State(state): State<ServerState>) -> Response {
164 serve_markdown(&path, &state).await
165}
166
167async fn serve_markdown(path: &str, state: &ServerState) -> Response {
168 match state.content_resolver.resolve(path).await {
169 Ok(content) => Html(content).into_response(),
170 Err(e) => {
171 warn!("File not found: {} ({})", path, e);
172 (e.status_code(), e.user_message()).into_response()
173 }
174 }
175}
176
177async fn action_handler_root(
178 State(state): State<ServerState>,
179 Json(request): Json<ActionRequest>,
180) -> Response {
181 execute_action("", &state, request).await
182}
183
184async fn action_handler(
185 Path(path): Path<String>,
186 State(state): State<ServerState>,
187 Json(request): Json<ActionRequest>,
188) -> Response {
189 execute_action(&path, &state, request).await
190}
191
192fn error_to_action_response(e: &statespace_tool_runtime::Error) -> Response {
193 let status = e.status_code();
194 let response = ActionResponse::error(e.user_message());
195 (status, Json(response)).into_response()
196}
197
198async fn execute_action(path: &str, state: &ServerState, request: ActionRequest) -> Response {
199 if let Err(msg) = request.validate() {
200 return error_response(StatusCode::BAD_REQUEST, &msg);
201 }
202
203 let file_path = match state.content_resolver.resolve_path(path).await {
204 Ok(p) => p,
205 Err(e) => return error_to_action_response(&e),
206 };
207
208 let content = match state.content_resolver.resolve(path).await {
209 Ok(c) => c,
210 Err(e) => return error_to_action_response(&e),
211 };
212
213 let frontmatter = match parse_frontmatter(&content) {
214 Ok(fm) => fm,
215 Err(e) => return error_to_action_response(&e),
216 };
217
218 let expanded_command = expand_placeholders(&request.command, &request.args);
219 let expanded_command = expand_env_vars(&expanded_command, &request.env);
220
221 if let Err(e) = validate_command_with_specs(&frontmatter.specs, &expanded_command) {
222 warn!(
223 "Command not allowed by frontmatter: {:?} (file: {})",
224 expanded_command, path
225 );
226 return error_to_action_response(&e);
227 }
228
229 let tool = match BuiltinTool::from_command(&expanded_command) {
230 Ok(t) => t,
231 Err(e) => {
232 warn!("Unknown tool: {}", e);
233 return error_to_action_response(&e);
234 }
235 };
236
237 let working_dir = file_path.parent().unwrap_or(&file_path);
238 let executor = ToolExecutor::new(working_dir.to_path_buf(), state.limits.clone());
239
240 info!("Executing tool: {:?}", tool);
241
242 match executor.execute(&tool).await {
243 Ok(output) => {
244 let response = ActionResponse::success(output.to_text());
245 (StatusCode::OK, Json(response)).into_response()
246 }
247 Err(e) => {
248 let status = e.status_code();
249 let response = ActionResponse::error(e.user_message());
250 (status, Json(response)).into_response()
251 }
252 }
253}
254
255fn error_response(status: StatusCode, message: &str) -> Response {
256 let response = ActionResponse::error(message.to_string());
257 (status, Json(response)).into_response()
258}