knot-server 0.5.2

Distributed REST API server for knot codebase indexing. Manages Git repositories across a cluster with shared workspace coordination.
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use std::sync::Arc;

use crate::handlers::models::*;
use crate::models::AppState;
use crate::models::{RegisterRepoRequest, RegisterRepoResponse, RepoEntry, RepoListResponse};

#[utoipa::path(
    get,
    path = "/api/repos",
    tag = "Repositories",
    responses(
        (status = 200, description = "List of all registered repositories", body = RepoListResponse),
    ),
    description = "List all registered Git repositories with their current status and metadata.",
)]
pub async fn list_repos_handler(State(state): State<Arc<AppState>>) -> Response {
    let mut registry = state.registry.lock().unwrap();
    let repos = registry.list().to_vec();
    let response = RepoListResponse {
        repositories: repos,
    };
    (StatusCode::OK, Json(response)).into_response()
}

#[utoipa::path(
    get,
    path = "/api/repos/{id}",
    tag = "Repositories",
    params(
        ("id" = String, Path, description = "Repository ID"),
    ),
    responses(
        (status = 200, description = "Repository details", body = RepoEntry),
        (status = 404, description = "Repository not found", body = ErrorResponse),
    ),
    description = "Get detailed information about a single registered repository.",
)]
pub async fn get_repo_handler(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
) -> Response {
    let mut registry = state.registry.lock().unwrap();
    match registry.get(&id) {
        Some(entry) => (StatusCode::OK, Json(entry.clone())).into_response(),
        None => error_response(
            StatusCode::NOT_FOUND,
            format!("Repository '{}' not found", id),
        ),
    }
}

#[utoipa::path(
    post,
    path = "/api/repos",
    tag = "Repositories",
    request_body = RegisterRepoRequest,
    responses(
        (status = 202, description = "Repository registered (or re-registered) and clone job enqueued", body = RegisterRepoResponse),
        (status = 429, description = "Indexing queue is full", body = ErrorResponse),
    ),
    description = "Register a new Git repository, or re-register an existing one. The endpoint is idempotent: if a repository with the same derived id already exists, it is re-registered and a fresh Clone job is enqueued. The destructive cleanup (wiping the Neo4j/Qdrant data and the local directory) is performed by the indexing worker under its file lock — not by this endpoint — so a re-registration never races with in-flight indexing. The response message indicates whether the call was a fresh registration or a re-registration."
)]
#[tracing::instrument(
    name = "register_repo",
    skip_all,
    fields(repo_id = tracing::field::Empty)
)]
pub async fn register_repo_handler(
    State(state): State<Arc<AppState>>,
    Json(body): Json<RegisterRepoRequest>,
) -> Response {
    if body.url.trim().is_empty() {
        return error_response(StatusCode::BAD_REQUEST, "Repository URL cannot be empty");
    }

    let id = body.generate_id();
    tracing::Span::current().record("repo_id", id.as_str());
    if id.is_empty() {
        return error_response(
            StatusCode::BAD_REQUEST,
            "Generated repository ID is empty (invalid URL format)",
        );
    }

    let local_path = crate::models::repo_local_path(&state.workspace_dir, &id);

    let entry = RepoEntry {
        id: id.clone(),
        url: body.url.clone(),
        auth_type: body.auth_type.clone(),
        local_path,
        branch: body.branch.clone(),
        webhook_secret: body.webhook_secret.clone(),
        last_indexed: None,
        status: crate::models::RepoStatus::Queued,
    };

    let mut registry = state.registry.lock().unwrap();
    match registry.add_or_replace(entry) {
        Ok(was_replaced) => {
            // Enqueue a Clone job. The worker is the sole owner of destructive
            // cleanup: when it picks up this job it wipes the databases and the
            // local directory *under the file lock*, then clones from scratch
            // (see `worker::decide_job_plan`). The handler deliberately does NOT
            // touch `local_path` here — doing so in a background task raced with
            // the worker and left previously-healthy repos corrupted (Bug A).
            let job = crate::models::IndexJob::Clone {
                repo_id: id.clone(),
            };
            match state.job_tx.try_send(job) {
                Ok(()) => {}
                Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
                    let _ = registry.update_status(&id, crate::models::RepoStatus::Pending);
                    return error_response(
                        StatusCode::TOO_MANY_REQUESTS,
                        "Server is at maximum capacity: indexing queue is full",
                    );
                }
                Err(e) => {
                    tracing::error!("Failed to enqueue Clone job for {}: {e}", id);
                    let _ = registry.update_status(&id, crate::models::RepoStatus::Pending);
                }
            }

            if was_replaced {
                tracing::info!(
                    "Re-registered repository '{}' (url: {}, auth: {:?})",
                    id,
                    body.url,
                    body.auth_type
                );
            } else {
                tracing::info!(
                    "Registered repository '{}' (url: {}, auth: {:?})",
                    id,
                    body.url,
                    body.auth_type
                );
            }

            let response = RegisterRepoResponse {
                id,
                message: if was_replaced {
                    "Repository re-registered successfully".into()
                } else {
                    "Repository registered successfully".into()
                },
            };
            (StatusCode::ACCEPTED, Json(response)).into_response()
        }
        Err(e) => {
            let msg = e.to_string();
            error_response(StatusCode::INTERNAL_SERVER_ERROR, msg)
        }
    }
}

#[utoipa::path(
    delete,
    path = "/api/repos/{id}",
    tag = "Repositories",
    params(
        ("id" = String, Path, description = "Repository ID"),
    ),
    responses(
        (status = 200, description = "Repository deleted successfully", body = serde_json::Value),
        (status = 404, description = "Repository not found", body = ErrorResponse),
    ),
    description = "Delete a repository and clean up its databases and local files.",
)]
#[tracing::instrument(name = "delete_repo", skip_all, fields(repo_id = %id))]
pub async fn delete_repo_handler(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
) -> Response {
    // Scope the (non-Send) registry guard so it is fully released before the
    // `.await` below — otherwise the handler future is not `Send`/`Sync` and
    // fails axum's `Handler` bound.
    let removed = {
        let mut registry = state.registry.lock().unwrap();
        registry.remove(&id)
    };

    match removed {
        Ok(()) => {
            // Same cleanup the worker performs on a wipe, but for a full
            // delete: databases + local directory + progress snapshot/tracker.
            crate::cleanup::cleanup_repo_artifacts(
                &state,
                &id,
                crate::cleanup::CleanupScope {
                    databases: true,
                    local_dir: true,
                    progress: true,
                },
            )
            .await;

            tracing::info!("Deleted repository '{}'", id);
            (
                StatusCode::OK,
                Json(serde_json::json!({"message": "Repository deleted"})),
            )
                .into_response()
        }
        Err(e) => {
            let msg = e.to_string();
            if msg.contains("not found") {
                error_response(StatusCode::NOT_FOUND, msg)
            } else {
                error_response(StatusCode::INTERNAL_SERVER_ERROR, msg)
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::handlers::tests_common::create_test_state_with_rx;
    use crate::models::AuthType;
    use knot::pipeline::progress::ProgressTracker;
    use std::sync::Arc;
    use tempfile::TempDir;

    async fn create_test_state(workspace: &std::path::Path) -> Arc<AppState> {
        create_test_state_with_rx(workspace).await.0
    }

    #[tokio::test]
    async fn test_reregister_does_not_delete_local_dir_in_handler() {
        let dir = TempDir::new().unwrap();
        let workspace = dir.path().join("workspace");
        std::fs::create_dir_all(&workspace).unwrap();
        let (state, mut job_rx) = create_test_state_with_rx(&workspace).await;

        let url = "git@github.com:org/reg-test.git";
        let id = RegisterRepoRequest {
            url: url.into(),
            auth_type: AuthType::Ssh,
            branch: "main".into(),
            webhook_secret: None,
        }
        .generate_id();

        // Pre-existing, healthy local checkout: a .git dir and a file that a
        // stray background cleanup would have deleted.
        let local_path = crate::models::repo_local_path(&state.workspace_dir, &id);
        std::fs::create_dir_all(std::path::Path::new(&local_path).join(".git")).unwrap();
        std::fs::write(std::path::Path::new(&local_path).join("keep.txt"), b"keep").unwrap();

        // Register the same repo once so the second call is a re-registration.
        let entry = RepoEntry {
            id: id.clone(),
            url: url.into(),
            auth_type: AuthType::Ssh,
            local_path: local_path.clone(),
            branch: "main".into(),
            webhook_secret: None,
            last_indexed: Some("2026-07-06T00:00:00Z".into()),
            status: crate::models::RepoStatus::Indexed,
        };
        state
            .registry
            .lock()
            .unwrap()
            .add_or_replace(entry)
            .unwrap();

        let body = RegisterRepoRequest {
            url: url.into(),
            auth_type: AuthType::Ssh,
            branch: "main".into(),
            webhook_secret: None,
        };
        let response = register_repo_handler(State(state.clone()), Json(body)).await;
        assert_eq!(response.status(), StatusCode::ACCEPTED);

        // Give any (now-removed) background task a chance to run.
        tokio::time::sleep(std::time::Duration::from_millis(200)).await;

        assert!(
            std::path::Path::new(&local_path).join("keep.txt").exists(),
            "handler must NOT delete the local dir on re-registration (worker does it)"
        );

        // Exactly one Clone job was enqueued.
        match job_rx.try_recv() {
            Ok(crate::models::IndexJob::Clone { repo_id }) => assert_eq!(repo_id, id),
            other => panic!("expected exactly one Clone job, got {other:?}"),
        }
        assert!(job_rx.try_recv().is_err(), "no extra jobs expected");
    }

    #[tokio::test]
    async fn test_delete_removes_tracker_from_state() {
        let dir = TempDir::new().unwrap();
        let workspace = dir.path().join("workspace");
        std::fs::create_dir_all(&workspace).unwrap();

        let state = create_test_state(&workspace).await;

        let entry = RepoEntry {
            id: "delete-test".into(),
            url: "git@github.com:org/delete-test.git".into(),
            local_path: "/tmp/delete-test".into(),
            auth_type: AuthType::Ssh,
            branch: "main".into(),
            webhook_secret: None,
            last_indexed: None,
            status: crate::models::RepoStatus::Indexed,
        };
        state
            .registry
            .lock()
            .unwrap()
            .add_or_replace(entry)
            .unwrap();

        state
            .progress_trackers
            .lock()
            .unwrap()
            .insert("delete-test".into(), Arc::new(ProgressTracker::new()));

        let response = delete_repo_handler(State(state.clone()), Path("delete-test".into())).await;

        assert_eq!(response.status(), StatusCode::OK);

        let map = state.progress_trackers.lock().unwrap();
        assert!(
            !map.contains_key("delete-test"),
            "tracker should be removed after delete"
        );
    }
}