lific 1.2.0

Local-first, lightweight issue tracker. Single binary, SQLite-backed, MCP-native.
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, HeaderValue, header};
use axum::response::IntoResponse;

use crate::db::DbPool;
use crate::error::LificError;

use super::with_read;

#[derive(serde::Deserialize)]
pub(super) struct ProjectExportQuery {
    pub format: Option<String>,
}

fn content_disposition(filename: &str) -> Result<HeaderValue, LificError> {
    HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))
        .map_err(|e| LificError::Internal(format!("invalid content-disposition header: {e}")))
}

pub(super) async fn export_issue(
    State(db): State<DbPool>,
    Path(identifier): Path<String>,
) -> Result<impl IntoResponse, LificError> {
    let bundle = with_read(&db, |conn| crate::export::export_issue(conn, &identifier))?;
    let file = bundle
        .files
        .into_iter()
        .next()
        .ok_or_else(|| LificError::Internal("issue export produced no files".into()))?;
    let filename = file.path.rsplit('/').next().unwrap_or("issue.md").to_string();

    let mut headers = HeaderMap::new();
    headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/markdown; charset=utf-8"));
    headers.insert(header::CONTENT_DISPOSITION, content_disposition(&filename)?);
    Ok((headers, file.content))
}

pub(super) async fn export_page(
    State(db): State<DbPool>,
    Path(identifier): Path<String>,
) -> Result<impl IntoResponse, LificError> {
    let bundle = with_read(&db, |conn| crate::export::export_page(conn, &identifier))?;
    let file = bundle
        .files
        .into_iter()
        .next()
        .ok_or_else(|| LificError::Internal("page export produced no files".into()))?;
    let filename = file.path.rsplit('/').next().unwrap_or("page.md").to_string();

    let mut headers = HeaderMap::new();
    headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/markdown; charset=utf-8"));
    headers.insert(header::CONTENT_DISPOSITION, content_disposition(&filename)?);
    Ok((headers, file.content))
}

pub(super) async fn export_project(
    State(db): State<DbPool>,
    Path(identifier): Path<String>,
    Query(q): Query<ProjectExportQuery>,
) -> Result<impl IntoResponse, LificError> {
    let format = q.format.as_deref().unwrap_or("zip");
    let bundle = with_read(&db, |conn| crate::export::export_project(conn, &identifier))?;

    match format {
        "json" => Ok(axum::Json(bundle).into_response()),
        "zip" => {
            let filename = format!("{}-export.zip", bundle.root.to_ascii_lowercase());
            let bytes = crate::export::bundle_to_zip(&bundle)?;
            let mut headers = HeaderMap::new();
            headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/zip"));
            headers.insert(header::CONTENT_DISPOSITION, content_disposition(&filename)?);
            Ok((headers, bytes).into_response())
        }
        _ => Err(LificError::BadRequest(
            "invalid export format. Expected 'zip' or 'json'".into(),
        )),
    }
}

#[cfg(test)]
mod tests {
    use axum::http::{Request, StatusCode};
    use http_body_util::BodyExt;
    use tower::ServiceExt;

    use crate::api::test_helpers::{json_post, parse_json, seed_project, test_app};

    #[tokio::test]
    async fn export_issue_returns_markdown_attachment() {
        let app = test_app();
        let (project_id, _) = seed_project(&app).await;
        let created = parse_json(
            json_post(
                &app,
                "/api/issues",
                serde_json::json!({
                    "project_id": project_id,
                    "title": "Export me",
                    "description": "Body"
                }),
            )
            .await,
        )
        .await;

        let resp = app
            .clone()
            .oneshot(
                Request::builder()
                    .uri(format!("/api/export/issues/{}", created["identifier"].as_str().unwrap()))
                    .body(axum::body::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
        assert_eq!(
            resp.headers()[axum::http::header::CONTENT_TYPE],
            "text/markdown; charset=utf-8"
        );
        let body = resp.into_body().collect().await.unwrap().to_bytes();
        let body = String::from_utf8(body.to_vec()).unwrap();
        assert!(body.contains("identifier: TST-1"));
        assert!(body.contains("# Export me"));
    }

    #[tokio::test]
    async fn export_project_returns_zip_attachment() {
        let app = test_app();
        let (project_id, project) = seed_project(&app).await;
        json_post(
            &app,
            "/api/issues",
            serde_json::json!({
                "project_id": project_id,
                "title": "Export project"
            }),
        )
        .await;

        let resp = app
            .clone()
            .oneshot(
                Request::builder()
                    .uri(format!("/api/export/projects/{}", project["identifier"].as_str().unwrap()))
                    .body(axum::body::Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(resp.status(), StatusCode::OK);
        assert_eq!(resp.headers()[axum::http::header::CONTENT_TYPE], "application/zip");
    }
}