collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Project management and directory browsing REST API routes.

use std::path::PathBuf;
use std::sync::Arc;

use axum::Router;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use axum::routing::{get, post};
use serde::{Deserialize, Serialize};

use crate::config::{collet_home, project_data_dir};

use super::state::AppState;

pub fn router() -> Router<Arc<AppState>> {
    Router::new()
        .route("/api/projects", get(list_projects))
        .route("/api/projects/switch", post(switch_project))
        .route("/api/browse", get(browse_directory))
}

// ── List projects ───────────────────────────────────────────────────────

#[derive(Serialize)]
struct ProjectInfo {
    id: String,
    working_dir: Option<String>,
    session_count: usize,
    last_active: Option<String>,
}

async fn list_projects(
    State(_state): State<Arc<AppState>>,
) -> Result<Json<Vec<ProjectInfo>>, (StatusCode, String)> {
    let projects_dir = collet_home(None).join("projects");

    if !projects_dir.exists() {
        return Ok(Json(vec![]));
    }

    let mut entries = tokio::fs::read_dir(&projects_dir).await.map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("Cannot read projects dir: {e}"),
        )
    })?;

    let mut projects = Vec::new();

    while let Some(entry) = entries.next_entry().await.map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("Read error: {e}"),
        )
    })? {
        let metadata = match entry.metadata().await {
            Ok(m) => m,
            Err(_) => continue,
        };
        if !metadata.is_dir() {
            continue;
        }

        let id = entry.file_name().to_string_lossy().to_string();
        let project_path = entry.path();
        let sessions_dir = project_path.join("sessions");

        let (working_dir, session_count, last_active) = scan_project_sessions(&sessions_dir).await;

        // Skip projects with no working_dir or tmp paths.
        let wd = match &working_dir {
            Some(p) if p.starts_with("/tmp/") || p.starts_with("/private/tmp/") => continue,
            Some(p) => p.clone(),
            None => continue,
        };

        projects.push(ProjectInfo {
            id,
            working_dir: Some(wd),
            session_count,
            last_active,
        });
    }

    // Sort by last_active descending (most recent first).
    projects.sort_by(|a, b| b.last_active.cmp(&a.last_active));

    Ok(Json(projects))
}

/// Scan a project's sessions directory to extract working_dir, count, and last_active.
async fn scan_project_sessions(sessions_dir: &PathBuf) -> (Option<String>, usize, Option<String>) {
    let mut working_dir: Option<String> = None;
    let mut session_count: usize = 0;
    let mut last_active: Option<String> = None;

    // Try latest.json first for last_active.
    let latest_path = sessions_dir.join("latest.json");
    if let Ok(content) = tokio::fs::read_to_string(&latest_path).await
        && let Ok(snap) = serde_json::from_str::<serde_json::Value>(&content)
    {
        if let Some(ts) = snap.get("timestamp").and_then(|v| v.as_str()) {
            last_active = Some(ts.to_string());
        }
        if let Some(wd) = snap.get("working_dir").and_then(|v| v.as_str()) {
            working_dir = Some(wd.to_string());
        }
    }

    // Count session files and fallback for working_dir.
    if let Ok(mut reader) = tokio::fs::read_dir(sessions_dir).await {
        while let Ok(Some(entry)) = reader.next_entry().await {
            let name = entry.file_name().to_string_lossy().to_string();
            if !name.ends_with(".json") || name == "latest.json" {
                continue;
            }
            session_count += 1;

            // If we still need working_dir, read the first session file.
            if working_dir.is_none()
                && let Ok(content) = tokio::fs::read_to_string(entry.path()).await
                && let Ok(snap) = serde_json::from_str::<serde_json::Value>(&content)
                && let Some(wd) = snap.get("working_dir").and_then(|v| v.as_str())
            {
                working_dir = Some(wd.to_string());
            }

            // If we still need last_active, check timestamps.
            if last_active.is_none()
                && let Ok(content) = tokio::fs::read_to_string(entry.path()).await
                && let Ok(snap) = serde_json::from_str::<serde_json::Value>(&content)
                && let Some(ts) = snap.get("timestamp").and_then(|v| v.as_str())
            {
                let ts = ts.to_string();
                if last_active.as_ref().is_none_or(|prev| ts > *prev) {
                    last_active = Some(ts);
                }
            }
        }
    }

    (working_dir, session_count, last_active)
}

// ── Switch project ──────────────────────────────────────────────────────

#[derive(Deserialize)]
struct SwitchRequest {
    working_dir: String,
}

#[derive(Serialize)]
struct SwitchResponse {
    id: String,
    working_dir: String,
}

async fn switch_project(
    State(state): State<Arc<AppState>>,
    Json(req): Json<SwitchRequest>,
) -> Result<Json<SwitchResponse>, (StatusCode, String)> {
    let path = PathBuf::from(&req.working_dir);

    if !path.is_dir() {
        return Err((
            StatusCode::BAD_REQUEST,
            format!("Path is not a directory: {}", req.working_dir),
        ));
    }

    // Ensure project data dir exists.
    let project_dir = project_data_dir(&req.working_dir);
    if let Err(e) = tokio::fs::create_dir_all(&project_dir).await {
        return Err((
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("Failed to create project dir: {e}"),
        ));
    }

    // Extract the hash id from the project dir name.
    let id = project_dir
        .file_name()
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_default();

    // Update the active working directory.
    state.set_working_dir(req.working_dir.clone()).await;

    // Clear conversation context so the new project gets a fresh repo map.
    *state.context.lock().await = None;

    Ok(Json(SwitchResponse {
        id,
        working_dir: req.working_dir,
    }))
}

// ── Browse directory ────────────────────────────────────────────────────

#[derive(Deserialize)]
struct BrowseQuery {
    path: Option<String>,
}

#[derive(Serialize)]
struct BrowseResponse {
    current: String,
    parent: Option<String>,
    entries: Vec<BrowseEntry>,
}

#[derive(Serialize)]
struct BrowseEntry {
    name: String,
    path: String,
}

async fn browse_directory(
    Query(query): Query<BrowseQuery>,
) -> Result<Json<BrowseResponse>, (StatusCode, String)> {
    let target = match &query.path {
        Some(p) if !p.is_empty() => PathBuf::from(p),
        _ => dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")),
    };

    let canonical = tokio::fs::canonicalize(&target)
        .await
        .map_err(|e| (StatusCode::NOT_FOUND, format!("Path not found: {e}")))?;

    if !canonical.is_dir() {
        return Err((StatusCode::BAD_REQUEST, "Path is not a directory".into()));
    }

    let parent = canonical.parent().map(|p| p.to_string_lossy().to_string());

    let mut reader = tokio::fs::read_dir(&canonical).await.map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("Cannot read directory: {e}"),
        )
    })?;

    let mut entries = Vec::new();
    while let Some(entry) = reader.next_entry().await.map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("Read error: {e}"),
        )
    })? {
        let name = entry.file_name().to_string_lossy().to_string();

        // Skip hidden directories.
        if name.starts_with('.') {
            continue;
        }

        let metadata = match entry.metadata().await {
            Ok(m) => m,
            Err(_) => continue,
        };

        // Only list directories.
        if !metadata.is_dir() {
            continue;
        }

        let abs_path = entry.path().to_string_lossy().to_string();
        entries.push(BrowseEntry {
            name,
            path: abs_path,
        });
    }

    entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));

    Ok(Json(BrowseResponse {
        current: canonical.to_string_lossy().to_string(),
        parent,
        entries,
    }))
}