be013d2b42
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.
256 lines
8.3 KiB
Rust
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
|
|
}
|