recall-server 0.2.0

Recall's sync API: SQLite persistence, LLM-assisted merge, and the HTTP surface
Documentation
//! One function per route, plus the two constants the admin page needs.
//!
//! Status codes and error wording are part of the frozen API surface —
//! `docs/api.md` describes them and `scripts/api-doc-check.sh` asserts them
//! against a running server.

use std::collections::HashMap;
use std::sync::Arc;

use axum::body::Bytes;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use recall_wire::{
    AdminStats, ClaudeCliStatus, Health, MergeError, MergeStatus, PushRequest, PushResponse,
    SyncResponse,
};

use super::respond::{error, internal, json};
use super::AppState;
use crate::now;

/// The admin page is embedded so the binary stays self-contained — there is
/// no asset directory to forget to ship.
const ADMIN_HTML: &str = include_str!("../../assets/admin.html");

/// The token the page holds lives in sessionStorage on this origin;
/// `default-src 'none'` with `connect-src 'self'` means even a future
/// injection bug there would have nowhere to send it.
const ADMIN_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'";

const REQUIRED_FIELDS_MSG: &str =
    "project_key, file_path, and content (string) are required, unless deleted is true";

pub(super) async fn handle_push(State(state): State<Arc<AppState>>, body: Bytes) -> Response {
    let req: PushRequest = match serde_json::from_slice(&body) {
        Ok(req) => req,
        Err(_) => {
            // Go's json.Unmarshal tolerates absent fields and reports them
            // through the "required" message below; serde treats them as a
            // parse failure, so the two cases are separated here to keep
            // the frozen wording for each.
            let missing_field = serde_json::from_slice::<serde_json::Value>(&body)
                .ok()
                .and_then(|v| {
                    v.as_object()
                        .map(|o| !o.contains_key("project_key") || !o.contains_key("file_path"))
                })
                .unwrap_or(false);
            return error(
                StatusCode::BAD_REQUEST,
                if missing_field {
                    REQUIRED_FIELDS_MSG
                } else {
                    "invalid json body"
                },
            );
        }
    };

    if req.project_key.is_empty() || req.file_path.is_empty() {
        return error(StatusCode::BAD_REQUEST, REQUIRED_FIELDS_MSG);
    }
    if recall_wire::validate_file_path(&req.file_path).is_err() {
        return error(
            StatusCode::BAD_REQUEST,
            "file_path must be relative, no traversal",
        );
    }

    let updated_at = now();

    if req.deleted {
        if let Err(e) = state.store.tombstone(
            &req.project_key,
            &req.file_path,
            &req.source_env,
            &updated_at,
        ) {
            return internal(e);
        }
        return json(
            StatusCode::OK,
            &PushResponse {
                ok: true,
                project_key: req.project_key,
                file_path: req.file_path,
                deleted: true,
                merged: false,
                updated_at,
            },
        );
    }

    let existing = match state.store.get(&req.project_key, &req.file_path) {
        Ok(e) => e,
        Err(e) => return internal(e),
    };

    // A non-delete push with no content field at all is malformed — the
    // Node server answers 400 for it, and the wording below matches so a
    // client sees the same message from either implementation.
    let Some(incoming) = req.content.clone() else {
        return error(StatusCode::BAD_REQUEST, REQUIRED_FIELDS_MSG);
    };

    let mut content = incoming.clone();
    let mut merged = false;

    // Merge only when there is genuinely something to reconcile. A
    // brand-new file, a revived tombstone (the delete already expressed
    // intent to discard the old content), or an unchanged re-push all skip
    // straight to a write — cheaper, and it keeps a merge from ever
    // second-guessing content that didn't actually conflict.
    if let Some(stored) = should_merge(&state, existing.as_ref(), &incoming) {
        match state.merger.merge(stored, &incoming).await {
            Ok(out) => {
                content = out;
                merged = true;
                let mut rt = state.write();
                rt.last_merge_at = now();
                rt.last_merge_error = None;
            }
            Err(e) => {
                // Every merge failure degrades to last-write-wins and still
                // returns 200: a not-yet-configured merge must never be
                // able to take basic syncing down with it.
                eprintln!(
                    "merge failed for {}/{}, falling back to last-write-wins: {e}",
                    req.project_key, req.file_path
                );
                state.write().last_merge_error = Some(MergeError {
                    message: e.to_string(),
                    at: now(),
                });
            }
        }
    }

    if let Err(e) = state.store.upsert(
        &req.project_key,
        &req.file_path,
        &content,
        &req.source_env,
        &updated_at,
    ) {
        return internal(e);
    }
    json(
        StatusCode::OK,
        &PushResponse {
            ok: true,
            project_key: req.project_key,
            file_path: req.file_path,
            deleted: false,
            merged,
            updated_at,
        },
    )
}

/// Returns the stored content to merge against, or `None` when this push
/// needs no reconciliation.
fn should_merge<'a>(
    state: &AppState,
    existing: Option<&'a crate::store::Existing>,
    incoming: &str,
) -> Option<&'a str> {
    if !state.cfg.merge_enabled {
        return None;
    }
    let stored = existing.filter(|e| !e.deleted && e.content != incoming)?;
    // Don't even attempt it when the CLI isn't logged in: every attempt
    // would burn a subprocess and a timeout before failing to the same
    // place.
    state
        .read()
        .claude_status
        .logged_in
        .then_some(stored.content.as_str())
}

pub(super) async fn handle_pull(
    State(state): State<Arc<AppState>>,
    Query(params): Query<HashMap<String, String>>,
) -> Response {
    let Some(project_key) = params.get("project_key").filter(|k| !k.is_empty()) else {
        return error(
            StatusCode::BAD_REQUEST,
            "project_key query param is required",
        );
    };
    match state.store.list(project_key) {
        Ok(files) => json(
            StatusCode::OK,
            &SyncResponse {
                project_key: project_key.clone(),
                files,
            },
        ),
        Err(e) => internal(e),
    }
}

pub(super) async fn handle_health(State(state): State<Arc<AppState>>) -> Response {
    let last_sync_at = match state.store.last_sync_at() {
        Ok(v) => v,
        Err(e) => return internal(e),
    };

    let rt = state.read();
    let claude_cli = if rt.claude_status.checked_at.is_empty() {
        ClaudeCliStatus::default()
    } else {
        ClaudeCliStatus {
            checked_at: rt.claude_status.checked_at.clone(),
            available: Some(rt.claude_status.available),
            logged_in: Some(rt.claude_status.logged_in),
            error: rt.claude_status.error.clone(),
        }
    };
    let body = Health {
        status: "ok".to_string(),
        git_commit: state.cfg.git_commit.clone(),
        started_at: state.started_at.clone(),
        last_sync_at,
        last_backup_at: rt.last_backup_at.clone(),
        merge: MergeStatus {
            enabled: state.cfg.merge_enabled,
            claude_cli,
            last_merge_at: rt.last_merge_at.clone(),
            last_merge_error: rt.last_merge_error.clone(),
        },
    };
    drop(rt);
    json(StatusCode::OK, &body)
}

/// Static markup only: the page holds no data, it asks the viewer for a
/// token and fetches `/admin/stats` itself.
pub(super) async fn handle_admin_page() -> Response {
    (
        StatusCode::OK,
        [
            ("content-type", "text/html; charset=utf-8"),
            ("x-content-type-options", "nosniff"),
            ("content-security-policy", ADMIN_CSP),
        ],
        ADMIN_HTML,
    )
        .into_response()
}

pub(super) async fn handle_admin_stats(State(state): State<Arc<AppState>>) -> Response {
    let (projects, totals) = match state.store.admin_stats() {
        Ok(v) => v,
        Err(e) => return internal(e),
    };
    let last_backup_at = state.read().last_backup_at.clone();
    json(
        StatusCode::OK,
        &AdminStats {
            projects,
            totals,
            git_commit: state.cfg.git_commit.clone(),
            last_backup_at,
        },
    )
}

pub(super) async fn not_found() -> Response {
    error(StatusCode::NOT_FOUND, "not found")
}