use wsb::prelude::*;
use axum::{extract::Query, http::StatusCode, Json};
use std::{cmp::Reverse, fs, path::PathBuf};
fn plans_dir() -> Result<PathBuf, (StatusCode, Json<serde_json::Value>)> {
crate::web::home_dir()
.map(|home| home.join(".claude").join("plans"))
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("cannot locate the plans directory: {e}") })),
)
})
}
#[derive(Debug, Serialize)]
pub struct PlanEntry {
pub name: String,
pub created_at: u64,
}
#[derive(Debug, Serialize)]
pub struct PlansListResponse {
pub plans: Vec<PlanEntry>,
}
#[derive(Debug, Deserialize)]
pub struct PlanContentQuery {
pub name: String,
}
#[derive(Debug, Serialize)]
pub struct PlanContentResponse {
pub name: String,
pub content: String,
}
pub async fn list_plans() -> Result<Json<PlansListResponse>, (StatusCode, Json<serde_json::Value>)>
{
let dir = plans_dir()?;
let mut plans = tokio::task::spawn_blocking(move || {
let read = fs::read_dir(&dir).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(
serde_json::json!({ "error": format!("failed to read plans directory: {e}") }),
),
)
})?;
let plans: Vec<PlanEntry> = read
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
return None;
}
let name = path.file_name()?.to_str()?.to_owned();
let meta = fs::metadata(&path).ok()?;
let created_at = meta
.created()
.or_else(|_| meta.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
Some(PlanEntry { name, created_at })
})
.collect();
Ok(plans)
})
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("list_plans task panicked: {e}") })),
)
})??;
plans.sort_by_key(|p| Reverse(p.created_at));
Ok(Json(PlansListResponse { plans }))
}
pub async fn get_plan_content(
Query(query): Query<PlanContentQuery>,
) -> Result<Json<PlanContentResponse>, (StatusCode, Json<serde_json::Value>)> {
if query.name.contains('/') || query.name.contains("..") {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "invalid plan name" })),
));
}
let dir = plans_dir()?;
let name = query.name;
let content = tokio::task::spawn_blocking(move || {
let path = dir.join(&name);
fs::read_to_string(&path)
.map_err(|e| {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": format!("plan not found: {e}") })),
)
})
.map(|content| (name, content))
})
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("get_plan_content task panicked: {e}") })),
)
})??;
Ok(Json(PlanContentResponse {
name: content.0,
content: content.1,
}))
}