Files
el/ide/vessels/el-ide-server/src/api/files.rs
T
Will Anderson be013d2b42 rename crates/ → vessels/ — El's word for buildable units
Per the consolidation onto El: 'crates' is the Rust word, 'vessel' is
El's (per spec/language.md §15). The directory rename is the structural
marker that this slot holds an El buildable unit, even if its current
contents are still Rust pending port.

Mechanical: git mv crates vessels, sed workspace members and any path
dependencies, update CI workflow paths, update README references.
Cross-repo path dependencies (`../foo/crates/bar`) updated workspace-
wide so cargo metadata still resolves where the Rust still builds.
2026-04-30 15:34:20 -05:00

256 lines
8.3 KiB
Rust

//! 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<String>,
}
#[derive(Debug, Serialize)]
pub struct FileEntry {
pub name: String,
pub path: String,
pub is_dir: bool,
pub children: Option<Vec<FileEntry>>,
}
#[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<T> = Result<Json<T>, (StatusCode, Json<serde_json::Value>)>;
fn api_err(code: StatusCode, msg: impl Into<String>) -> (StatusCode, Json<serde_json::Value>) {
(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<AppState>,
Query(q): Query<PathQuery>,
) -> ApiResult<Vec<FileEntry>> {
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<Vec<FileEntry>, 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<AppState>,
Query(q): Query<PathQuery>,
) -> ApiResult<FileContent> {
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<AppState>,
Json(req): Json<WriteRequest>,
) -> ApiResult<WriteResponse> {
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<AppState>,
Query(q): Query<PathQuery>,
) -> ApiResult<WriteResponse> {
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<AppState>,
Json(req): Json<MkdirRequest>,
) -> ApiResult<WriteResponse> {
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<AppState>,
Json(req): Json<RenameRequest>,
) -> ApiResult<WriteResponse> {
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<PathBuf, String> {
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
}