wsb 0.186.4098

Workspace - A tool suite for file operations, version management, and development workflow automation
Documentation
use crate::prelude::*;
use axum::{
    extract::Query,
    http::StatusCode,
    Json,
};
use std::{cmp::Reverse, fs, path::Path};

const PLANS_DIR: &str = "/Users/bahram/.claude/plans";

#[derive(Debug, Serialize)]
pub struct PlanEntry {
    pub name: String,
    /// Unix timestamp (seconds) of file creation / birth time.
    /// Falls back to last-modified if birth time is unavailable (Linux).
    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 mut plans = tokio::task::spawn_blocking(|| {
        let read = fs::read_dir(Path::new(PLANS_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>)> {
    // Reject any name that contains path separators or traversal sequences.
    if query.name.contains('/') || query.name.contains("..") {
        return Err((
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({ "error": "invalid plan name" })),
        ));
    }

    let name = query.name;
    let content = tokio::task::spawn_blocking(move || {
        let path = Path::new(PLANS_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,
    }))
}