//! File system API — list, read, and write files within the project root. use std::path::{Path, PathBuf}; use axum::{ extract::{Query, State}, http::StatusCode, Json, }; use serde::{Deserialize, Serialize}; use crate::AppState; // ── Types ───────────────────────────────────────────────────────────────────── #[derive(Debug, Deserialize)] pub struct PathQuery { pub path: Option, } #[derive(Debug, Serialize)] pub struct FileEntry { pub name: String, pub path: String, pub is_dir: bool, pub children: Option>, } #[derive(Debug, Serialize)] pub struct FileContent { pub path: String, pub content: String, } #[derive(Debug, Deserialize)] pub struct WriteRequest { pub path: String, pub content: String, } #[derive(Debug, Serialize)] pub struct WriteResponse { pub ok: bool, } type ApiResult = Result, (StatusCode, Json)>; fn api_err(code: StatusCode, msg: impl Into) -> (StatusCode, Json) { (code, Json(serde_json::json!({ "error": msg.into() }))) } // ── Handlers ────────────────────────────────────────────────────────────────── /// GET /api/files?path={dir} /// /// Returns a recursive directory listing for the given path /// (relative to EL_IDE_PROJECT_PATH). pub async fn list_files( State(state): State, Query(q): Query, ) -> ApiResult> { let root = PathBuf::from(&state.config.project_path); let rel = q.path.unwrap_or_else(|| ".".into()); let target = root.join(&rel); let target = target .canonicalize() .map_err(|e| api_err(StatusCode::BAD_REQUEST, format!("invalid path: {e}")))?; // Security: ensure the resolved path is within the project root let root_canonical = root .canonicalize() .map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if !target.starts_with(&root_canonical) { return Err(api_err(StatusCode::FORBIDDEN, "path escapes project root")); } let entries = read_dir_recursive(&target, &root_canonical, 0) .map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, e))?; Ok(Json(entries)) } fn read_dir_recursive( dir: &Path, root: &Path, depth: usize, ) -> Result, String> { if depth > 8 { return Ok(vec![]); } let mut entries = Vec::new(); let rd = std::fs::read_dir(dir).map_err(|e| e.to_string())?; let mut items: Vec<_> = rd.filter_map(|e| e.ok()).collect(); items.sort_by_key(|e| e.file_name()); for entry in items { let path = entry.path(); let name = entry.file_name().to_string_lossy().to_string(); // Skip hidden files/dirs and build/vendor noise if name.starts_with('.') { continue; } // Skip common non-source directories that bloat the tree if matches!(name.as_str(), "target" | "node_modules" | ".git" | "dist" | ".cargo") { continue; } // Skip lock files and large generated files if matches!(name.as_str(), "Cargo.lock" | "package-lock.json" | "yarn.lock" | "bun.lockb") { continue; } let rel_path = path.strip_prefix(root).unwrap_or(&path).to_string_lossy().to_string(); let is_dir = path.is_dir(); let children = if is_dir { Some(read_dir_recursive(&path, root, depth + 1).unwrap_or_default()) } else { None }; entries.push(FileEntry { name, path: rel_path, is_dir, children }); } Ok(entries) } /// GET /api/file?path={file} pub async fn read_file( State(state): State, Query(q): Query, ) -> ApiResult { let rel = q.path.ok_or_else(|| api_err(StatusCode::BAD_REQUEST, "missing path parameter"))?; let file_path = safe_path(&state.config.project_path, &rel) .map_err(|e| api_err(StatusCode::FORBIDDEN, e))?; let content = std::fs::read_to_string(&file_path) .map_err(|e| api_err(StatusCode::NOT_FOUND, format!("cannot read file: {e}")))?; Ok(Json(FileContent { path: rel, content })) } /// POST /api/file — body: { path, content } pub async fn write_file( State(state): State, Json(req): Json, ) -> ApiResult { let file_path = safe_path(&state.config.project_path, &req.path) .map_err(|e| api_err(StatusCode::FORBIDDEN, e))?; // Create parent dirs if needed if let Some(parent) = file_path.parent() { std::fs::create_dir_all(parent) .map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; } std::fs::write(&file_path, &req.content) .map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot write: {e}")))?; Ok(Json(WriteResponse { ok: true })) } /// DELETE /api/file?path={file} pub async fn delete_file( State(state): State, Query(q): Query, ) -> ApiResult { let rel = q.path.ok_or_else(|| api_err(StatusCode::BAD_REQUEST, "missing path parameter"))?; let file_path = safe_path(&state.config.project_path, &rel) .map_err(|e| api_err(StatusCode::FORBIDDEN, e))?; if file_path.is_dir() { std::fs::remove_dir_all(&file_path) .map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot delete directory: {e}")))?; } else { std::fs::remove_file(&file_path) .map_err(|e| api_err(StatusCode::NOT_FOUND, format!("cannot delete file: {e}")))?; } Ok(Json(WriteResponse { ok: true })) } #[derive(Debug, Deserialize)] pub struct MkdirRequest { pub path: String, } /// POST /api/files/mkdir — body: { path } pub async fn mkdir( State(state): State, Json(req): Json, ) -> ApiResult { let dir_path = safe_path(&state.config.project_path, &req.path) .map_err(|e| api_err(StatusCode::FORBIDDEN, e))?; std::fs::create_dir_all(&dir_path) .map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot create directory: {e}")))?; Ok(Json(WriteResponse { ok: true })) } #[derive(Debug, Deserialize)] pub struct RenameRequest { pub from: String, pub to: String, } /// POST /api/files/rename — body: { from, to } pub async fn rename_file( State(state): State, Json(req): Json, ) -> ApiResult { let from_path = safe_path(&state.config.project_path, &req.from) .map_err(|e| api_err(StatusCode::FORBIDDEN, e))?; let to_path = safe_path(&state.config.project_path, &req.to) .map_err(|e| api_err(StatusCode::FORBIDDEN, e))?; // Ensure parent exists if let Some(parent) = to_path.parent() { std::fs::create_dir_all(parent) .map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; } std::fs::rename(&from_path, &to_path) .map_err(|e| api_err(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot rename: {e}")))?; Ok(Json(WriteResponse { ok: true })) } // ── Helpers ─────────────────────────────────────────────────────────────────── fn safe_path(project_root: &str, rel: &str) -> Result { let root = PathBuf::from(project_root) .canonicalize() .map_err(|e| format!("invalid project root: {e}"))?; // Prevent path traversal let joined = root.join(rel); // We can't canonicalize non-existent files, so check manually let normalized = normalize_path(&joined); if !normalized.starts_with(&root) { return Err(format!("path '{rel}' escapes project root")); } Ok(normalized) } /// Normalize a path without requiring the file to exist. fn normalize_path(path: &Path) -> PathBuf { let mut out = PathBuf::new(); for component in path.components() { use std::path::Component; match component { Component::ParentDir => { out.pop(); } Component::CurDir => {} c => out.push(c), } } out }