Remove handlers.rs (replaced by handlers/ submodules)
https://claude.ai/code/session_01URZjpNNXYVjq99E1msKw1J
change
commit d77bd0057983d6b20c2fb6ca2a10162d4bb4894d
author Claude <noreply@anthropic.com>
date
parent cfd62bec
diff --git a/quire-server/src/quire/web/handlers.rs b/quire-server/src/quire/web/handlers.rs
deleted file mode 100644
index 75cb3d7..0000000
--- a/quire-server/src/quire/web/handlers.rs
+++ /dev/null
@@ -1,882 +0,0 @@
-//! Route handlers for the web view.
-
-use askama::Template;
-use axum::extract::{Path as AxumPath, State};
-use axum::http::{StatusCode, header};
-use axum::response::{Html, IntoResponse, Response};
-
-use super::db;
-use super::templates::{
-    BookmarkRow, ChangeRow, Crumb, ConfigTemplate, DetailJob, DetailRun, DetailShEvent,
-    ErrorTemplate, HeadInfo, PathCommit, RepoHomeTemplate, RunDetailTemplate, RunListRow,
-    RunListTemplate, TagRow, TreeEntry, TreeEntryKind, TreeTemplate,
-};
-use crate::Quire;
-use crate::quire::Repo;
-
-pub async fn repo_home(State(quire): State<Quire>, AxumPath(repo): AxumPath<String>) -> Response {
-    let repo_display = repo.trim_end_matches(".git").to_string();
-    let repo_name = db::resolve_repo_name(&repo);
-    let git_repo = match quire.repo(&repo_name) {
-        Ok(r) if r.exists() => r,
-        _ => return StatusCode::NOT_FOUND.into_response(),
-    };
-
-    // Load recent CI runs from DB.
-    let q = quire.clone();
-    let rn = repo_name.clone();
-    let recent_runs: Vec<RunListRow> = match tokio::task::spawn_blocking(move || {
-        db::load_runs(&q, &rn)
-    })
-    .await
-    {
-        Ok(Ok(runs)) => runs
-            .into_iter()
-            .take(5)
-            .map(|r| RunListRow {
-                id: r.id,
-                outcome: r.outcome,
-                sha: r.sha,
-                ref_name: r.ref_name,
-                created_at: r.created_at,
-                dispatched_at: r.dispatched_at,
-                resolved_at: r.resolved_at,
-            })
-            .collect(),
-        Ok(Err(e)) => {
-            tracing::warn!(repo = %repo, error = &e as &(dyn std::error::Error + 'static), "failed to load runs for home");
-            vec![]
-        }
-        Err(_) => vec![],
-    };
-
-    // Read git data (blocking).
-    let (head, readme_html, bookmarks, tags, recent_changes) =
-        tokio::task::spawn_blocking(move || read_git_data(&git_repo))
-            .await
-            .unwrap_or_default();
-
-    let tmpl = RepoHomeTemplate {
-        repo: repo_display,
-        crumbs: vec![],
-        head,
-        readme_html,
-        bookmarks,
-        tags,
-        recent_runs,
-        recent_changes,
-        active_section: "overview".to_string(),
-    };
-    render(&tmpl)
-}
-
-type GitData = (
-    Option<HeadInfo>,
-    Option<String>,
-    Vec<BookmarkRow>,
-    Vec<TagRow>,
-    Vec<ChangeRow>,
-);
-
-/// Read summary data from a bare git repository for the repo home page.
-fn read_git_data(repo: &Repo) -> GitData {
-    let head = read_head_info(repo);
-    let readme_html = read_readme(repo);
-    let bookmarks = read_bookmarks(repo);
-    let tags = read_tags(repo);
-    let recent_changes = read_recent_changes(repo);
-    (head, readme_html, bookmarks, tags, recent_changes)
-}
-
-fn run_git(repo: &Repo, args: &[&str]) -> Option<String> {
-    let output = repo.git(args).output().ok()?;
-    if !output.status.success() {
-        return None;
-    }
-    let s = String::from_utf8(output.stdout).ok()?;
-    let s = s.trim().to_string();
-    if s.is_empty() { None } else { Some(s) }
-}
-
-fn read_head_info(repo: &Repo) -> Option<HeadInfo> {
-    let bookmark =
-        run_git(repo, &["symbolic-ref", "--short", "HEAD"]).unwrap_or_else(|| "main".to_string());
-
-    // %H = full sha, %s = subject, %ar = relative age
-    let log = run_git(repo, &["log", "-1", "--format=%H%n%s%n%ar"])?;
-    let mut lines = log.lines();
-    let sha = lines.next()?.to_string();
-    let description = lines.next().unwrap_or("").to_string();
-    let age = lines.next().unwrap_or("").to_string();
-
-    Some(HeadInfo {
-        sha,
-        description,
-        age,
-        bookmark,
-    })
-}
-
-fn read_readme(repo: &Repo) -> Option<String> {
-    // Try common README filenames.
-    let candidates = ["HEAD:README.md", "HEAD:readme.md", "HEAD:README"];
-    for candidate in &candidates {
-        if let Some(raw) = run_git(repo, &["show", candidate]) {
-            return Some(render_markdown(&raw));
-        }
-    }
-    None
-}
-
-fn render_markdown(markdown: &str) -> String {
-    use pulldown_cmark::{Options, Parser, html};
-    let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
-    let parser = Parser::new_ext(markdown, opts);
-    let mut output = String::new();
-    html::push_html(&mut output, parser);
-    output
-}
-
-fn read_bookmarks(repo: &Repo) -> Vec<BookmarkRow> {
-    let out = run_git(
-        repo,
-        &[
-            "for-each-ref",
-            "--format=%(refname:short)|%(objectname:short)|%(committerdate:relative)",
-            "--sort=-committerdate",
-            "refs/heads/",
-        ],
-    )
-    .unwrap_or_default();
-
-    out.lines()
-        .filter_map(|line| {
-            let mut parts = line.splitn(3, '|');
-            Some(BookmarkRow {
-                name: parts.next()?.to_string(),
-                sha_short: parts.next()?.to_string(),
-                age: parts.next().unwrap_or("").to_string(),
-            })
-        })
-        .collect()
-}
-
-fn read_tags(repo: &Repo) -> Vec<TagRow> {
-    let out = run_git(
-        repo,
-        &[
-            "for-each-ref",
-            "--format=%(refname:short)|%(committerdate:relative)",
-            "--sort=-creatordate",
-            "refs/tags/",
-        ],
-    )
-    .unwrap_or_default();
-
-    out.lines()
-        .filter_map(|line| {
-            let mut parts = line.splitn(2, '|');
-            Some(TagRow {
-                name: parts.next()?.to_string(),
-                age: parts.next().unwrap_or("").to_string(),
-            })
-        })
-        .collect()
-}
-
-fn read_recent_changes(repo: &Repo) -> Vec<ChangeRow> {
-    let out = run_git(repo, &["log", "-12", "--format=%H|%s|%ar"]).unwrap_or_default();
-
-    out.lines()
-        .filter_map(|line| {
-            let mut parts = line.splitn(3, '|');
-            Some(ChangeRow {
-                sha: parts.next()?.to_string(),
-                description: parts.next().unwrap_or("").to_string(),
-                age: parts.next().unwrap_or("").to_string(),
-            })
-        })
-        .collect()
-}
-
-/// Render a template into an HTML response, returning 500 on render failure.
-fn render<T: Template>(tmpl: &T) -> Response {
-    match tmpl.render() {
-        Ok(body) => Html(body).into_response(),
-        Err(e) => {
-            tracing::error!(
-                error = &e as &(dyn std::error::Error + 'static),
-                "template render failed"
-            );
-            (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
-        }
-    }
-}
-
-/// Serve the compiled-in stylesheet.
-pub async fn stylesheet() -> Response {
-    (
-        [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
-        include_str!("../../../static/style.css"),
-    )
-        .into_response()
-}
-
-/// True when the error is "query returned no rows" (i.e. resource not found).
-fn is_no_rows(err: &crate::error::Error) -> bool {
-    matches!(
-        err,
-        crate::error::Error::Sql(rusqlite::Error::QueryReturnedNoRows)
-    )
-}
-
-/// Read a CRI log file, returning empty on NotFound and on any other
-/// error after logging it.
-async fn read_log(path: &std::path::Path) -> String {
-    match fs_err::tokio::read_to_string(path).await {
-        Ok(content) => content,
-        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
-        Err(e) => {
-            tracing::warn!(path = %path.display(), error = &e as &(dyn std::error::Error + 'static), "failed to read CRI log");
-            String::new()
-        }
-    }
-}
-
-/// Render the error template with the given status, falling back to plain
-/// text if the error template itself fails to render.
-fn render_error(repo: String, status: StatusCode, title: &str, detail: String) -> Response {
-    let tmpl = ErrorTemplate {
-        repo,
-        crumbs: vec![Crumb::new("error")],
-        title: title.to_string(),
-        detail: detail.clone(),
-    };
-    match tmpl.render() {
-        Ok(body) => (status, Html(body)).into_response(),
-        Err(e) => {
-            tracing::error!(
-                error = &e as &(dyn std::error::Error + 'static),
-                "error template render failed"
-            );
-            (status, format!("{title}\n\n{detail}\n")).into_response()
-        }
-    }
-}
-
-pub async fn run_list(State(quire): State<Quire>, AxumPath(repo): AxumPath<String>) -> Response {
-    let repo_display = repo.trim_end_matches(".git").to_string();
-    let repo_name = db::resolve_repo_name(&repo);
-    let git_repo = match quire.repo(&repo_name) {
-        Ok(r) if r.exists() => r,
-        _ => return StatusCode::NOT_FOUND.into_response(),
-    };
-
-    let q = quire.clone();
-    let rn = repo_name.clone();
-    let runs_handle = tokio::task::spawn_blocking(move || db::load_runs(&q, &rn));
-    let refs_handle =
-        tokio::task::spawn_blocking(move || (read_bookmarks(&git_repo), read_tags(&git_repo)));
-
-    let runs = match runs_handle.await {
-        Ok(Ok(r)) => r,
-        Ok(Err(e)) => {
-            tracing::error!(repo = %repo, error = &e as &(dyn std::error::Error + 'static), "failed to load runs");
-            return render_error(
-                repo_display,
-                StatusCode::INTERNAL_SERVER_ERROR,
-                "Failed to load runs",
-                e.to_string(),
-            );
-        }
-        Err(_) => {
-            tracing::error!("spawn_blocking task panicked");
-            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
-        }
-    };
-    let (bookmarks, tags) = refs_handle.await.unwrap_or_default();
-
-    let template_runs: Vec<RunListRow> = runs
-        .into_iter()
-        .map(|r| RunListRow {
-            id: r.id,
-            outcome: r.outcome,
-            sha: r.sha,
-            ref_name: r.ref_name,
-            created_at: r.created_at,
-            dispatched_at: r.dispatched_at,
-            resolved_at: r.resolved_at,
-        })
-        .collect();
-
-    let tmpl = RunListTemplate {
-        repo: repo_display,
-        crumbs: vec![],
-        runs: template_runs,
-        bookmarks,
-        tags,
-        active_section: "ci".to_string(),
-    };
-    render(&tmpl)
-}
-
-pub async fn run_detail(
-    State(quire): State<Quire>,
-    AxumPath((repo, run_id)): AxumPath<(String, String)>,
-) -> Response {
-    let repo_display = repo.trim_end_matches(".git").to_string();
-    let repo_name = db::resolve_repo_name(&repo);
-    let git_repo = match quire.repo(&repo_name) {
-        Ok(r) if r.exists() => r,
-        _ => return StatusCode::NOT_FOUND.into_response(),
-    };
-    if !db::is_valid_run_id(&run_id) {
-        return StatusCode::NOT_FOUND.into_response();
-    }
-
-    let refs_handle =
-        tokio::task::spawn_blocking(move || (read_bookmarks(&git_repo), read_tags(&git_repo)));
-
-    let q = quire.clone();
-    let rn = repo_name.clone();
-    let ri = run_id.clone();
-    let detail = match tokio::task::spawn_blocking(move || db::load_run_detail(&q, &rn, &ri)).await
-    {
-        Ok(Ok(d)) => d,
-        Ok(Err(ref e)) if is_no_rows(e) => return StatusCode::NOT_FOUND.into_response(),
-        Ok(Err(e)) => {
-            tracing::error!(repo = %repo, run_id = %run_id, error = &e as &(dyn std::error::Error + 'static), "failed to load run detail");
-            return render_error(
-                repo_display,
-                StatusCode::INTERNAL_SERVER_ERROR,
-                "Failed to load run",
-                e.to_string(),
-            );
-        }
-        Err(_) => {
-            tracing::error!("spawn_blocking task panicked");
-            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
-        }
-    };
-
-    let detail_run = DetailRun {
-        outcome: detail.run.outcome,
-        sha: detail.run.sha,
-        ref_name: detail.run.ref_name,
-        created_at: detail.run.created_at,
-        dispatched_at: detail.run.dispatched_at,
-        resolved_at: detail.run.resolved_at,
-    };
-
-    // Group sh events by job_id, preserving DB order so positional index
-    // matches launch order.
-    let mut events_by_job: std::collections::HashMap<&str, Vec<&db::ShEvent>> =
-        std::collections::HashMap::new();
-    for ev in &detail.sh_events {
-        events_by_job.entry(&ev.job_id).or_default().push(ev);
-    }
-
-    let runs_base = quire.base_dir().join("runs").join(&repo_name);
-    let run_dir = runs_base.join(&run_id);
-    let job_dir_base = run_dir.join("jobs");
-
-    // Spawn quire-ci.log read concurrently with the per-sh log reads below.
-    let quire_ci_log_path = run_dir.join("quire-ci.log");
-    let quire_ci_log_handle: tokio::task::JoinHandle<String> =
-        tokio::spawn(async move { read_log(&quire_ci_log_path).await });
-
-    // Build a flat list of log paths keyed by (job index, event index)
-    // so we can issue all reads concurrently and reassemble in order.
-    //
-    // tokio::spawn returns JoinHandle; awaiting handles in order preserves
-    // spawn order while all tasks run concurrently.
-    let mut log_handles: Vec<tokio::task::JoinHandle<String>> = Vec::new();
-
-    for job in &detail.jobs {
-        let job_events = events_by_job
-            .get(job.job_id.as_str())
-            .map(Vec::as_slice)
-            .unwrap_or(&[]);
-        let job_dir = db::is_safe_path_segment(&job.job_id).then(|| job_dir_base.join(&job.job_id));
-        if job_dir.is_none() && !job_events.is_empty() {
-            tracing::warn!(job_id = %job.job_id, "skipping CRI log reads for unsafe job_id");
-        }
-
-        for (i, _ev) in job_events.iter().enumerate() {
-            let sh_n = i + 1;
-            match &job_dir {
-                Some(dir) => {
-                    let path = dir.join(format!("sh-{sh_n}.log"));
-                    log_handles.push(tokio::spawn(async move { read_log(&path).await }));
-                }
-                None => {
-                    log_handles.push(tokio::spawn(async { String::new() }));
-                }
-            }
-        }
-    }
-
-    // Await all spawned reads — tasks run concurrently; awaiting handles
-    // in spawn order preserves the index mapping.
-    let mut log_results: Vec<String> = Vec::with_capacity(log_handles.len());
-    for handle in log_handles {
-        log_results.push(handle.await.unwrap_or_default());
-    }
-
-    // Reassemble: walk jobs/events in the same order, pulling from log_results.
-    let mut log_idx = 0;
-    let mut detail_jobs: Vec<DetailJob> = Vec::with_capacity(detail.jobs.len());
-    for job in &detail.jobs {
-        let job_events = events_by_job
-            .get(job.job_id.as_str())
-            .map(Vec::as_slice)
-            .unwrap_or(&[]);
-
-        let mut detail_sh_events: Vec<DetailShEvent> = Vec::with_capacity(job_events.len());
-        for ev in job_events {
-            detail_sh_events.push(DetailShEvent {
-                started_at_ms: ev.started_at_ms,
-                finished_at_ms: ev.finished_at_ms,
-                exit_code: ev.exit_code,
-                cmd: ev.cmd.clone(),
-                log_content: log_results[log_idx].clone(),
-            });
-            log_idx += 1;
-        }
-
-        detail_jobs.push(DetailJob {
-            job_id: job.job_id.clone(),
-            state: job.state.clone(),
-            exit_code: job.exit_code,
-            started_at_ms: job.started_at_ms,
-            finished_at_ms: job.finished_at_ms,
-            sh_events: detail_sh_events,
-        });
-    }
-
-    let quire_ci_log = quire_ci_log_handle.await.unwrap_or_default();
-    let (bookmarks, tags) = refs_handle.await.unwrap_or_default();
-
-    let crumbs = vec![
-        Crumb::with_href("ci", format!("/{}/ci", repo_display)),
-        Crumb::new(detail_run.sha_short()),
-    ];
-    let tmpl = RunDetailTemplate {
-        repo: repo_display,
-        crumbs,
-        run: detail_run,
-        jobs: detail_jobs,
-        quire_ci_log,
-        bookmarks,
-        tags,
-        active_section: "ci".to_string(),
-    };
-    render(&tmpl)
-}
-
-pub async fn tree_view(State(quire): State<Quire>, AxumPath(repo): AxumPath<String>) -> Response {
-    tree_at_path(quire, repo, String::new()).await
-}
-
-pub async fn tree_view_path(
-    State(quire): State<Quire>,
-    AxumPath((repo, path)): AxumPath<(String, String)>,
-) -> Response {
-    tree_at_path(quire, repo, path).await
-}
-
-async fn tree_at_path(quire: Quire, repo: String, path: String) -> Response {
-    let repo_display = repo.trim_end_matches(".git").to_string();
-    let repo_name = db::resolve_repo_name(&repo);
-    let git_repo = match quire.repo(&repo_name) {
-        Ok(r) if r.exists() => r,
-        _ => return StatusCode::NOT_FOUND.into_response(),
-    };
-
-    let path_clone = path.clone();
-    let result = tokio::task::spawn_blocking(move || {
-        let tree_data = read_tree_data(&git_repo, &path_clone)?;
-        let bookmarks = read_bookmarks(&git_repo);
-        let tags = read_tags(&git_repo);
-        Some((tree_data, bookmarks, tags))
-    })
-    .await
-    .unwrap_or(None);
-
-    let (tree_data, bookmarks, tags) = match result {
-        Some(v) => v,
-        None => return StatusCode::NOT_FOUND.into_response(),
-    };
-
-    let crumbs = {
-        let mut c = vec![Crumb::with_href("tree", format!("/{}/tree", repo_display))];
-        if !path.is_empty() {
-            c.push(Crumb::new(
-                path.split('/').last().unwrap_or(&path).to_string(),
-            ));
-        }
-        c
-    };
-
-    let tmpl = TreeTemplate {
-        repo: repo_display,
-        crumbs,
-        bookmarks,
-        tags,
-        active_section: "tree".to_string(),
-        path,
-        bookmark: tree_data.bookmark,
-        sha_short: tree_data.sha_short,
-        entries: tree_data.entries,
-        total_entries: tree_data.total_entries,
-        head_commit: tree_data.head_commit,
-        readme_preview: tree_data.readme_preview,
-    };
-    render(&tmpl)
-}
-
-struct TreeData {
-    bookmark: String,
-    sha_short: String,
-    entries: Vec<TreeEntry>,
-    total_entries: usize,
-    head_commit: Option<PathCommit>,
-    readme_preview: Option<String>,
-}
-
-fn read_tree_data(repo: &Repo, path: &str) -> Option<TreeData> {
-    let bookmark = run_git(repo, &["symbolic-ref", "--short", "HEAD"])
-        .unwrap_or_else(|| "main".to_string());
-
-    let sha_short =
-        run_git(repo, &["rev-parse", "--short", "HEAD"]).unwrap_or_else(|| "unknown".to_string());
-
-    let ls_target = if path.is_empty() {
-        "HEAD".to_string()
-    } else {
-        format!("HEAD:{}", path)
-    };
-
-    let ls_out = run_git(repo, &["ls-tree", &ls_target])?;
-
-    // Parse ls-tree output: "<mode> <type> <sha>\t<name>"
-    let mut raw: Vec<(TreeEntryKind, String)> = Vec::new();
-    for line in ls_out.lines() {
-        let Some((meta, name)) = line.split_once('\t') else {
-            continue;
-        };
-        let mut parts = meta.split_whitespace();
-        let mode = parts.next().unwrap_or("");
-        let obj_type = parts.next().unwrap_or("");
-        let kind = if mode == "160000" || obj_type == "commit" {
-            TreeEntryKind::Submodule
-        } else if obj_type == "tree" {
-            TreeEntryKind::Dir
-        } else {
-            TreeEntryKind::File
-        };
-        raw.push((kind, name.to_string()));
-    }
-
-    // Dirs/submodules first, then files, each group alphabetical.
-    raw.sort_by(|(ak, an), (bk, bn)| {
-        let ao = matches!(ak, TreeEntryKind::Dir | TreeEntryKind::Submodule) as u8;
-        let bo = matches!(bk, TreeEntryKind::Dir | TreeEntryKind::Submodule) as u8;
-        bo.cmp(&ao).then(an.cmp(bn))
-    });
-
-    let total_entries = raw.len();
-
-    let mut entries: Vec<TreeEntry> = Vec::new();
-
-    // ".." entry for non-root paths.
-    if !path.is_empty() {
-        entries.push(TreeEntry {
-            kind: TreeEntryKind::Up,
-            name: "..".to_string(),
-            last_msg: String::new(),
-            age: String::new(),
-        });
-    }
-
-    for (kind, name) in raw {
-        let entry_path = if path.is_empty() {
-            name.clone()
-        } else {
-            format!("{}/{}", path, name)
-        };
-        let commit_info =
-            run_git(repo, &["log", "-1", "--format=%s|%ar", "HEAD", "--", &entry_path]);
-        let (last_msg, age) = commit_info
-            .and_then(|s| {
-                let mut it = s.splitn(2, '|');
-                Some((it.next()?.to_string(), it.next()?.to_string()))
-            })
-            .unwrap_or_default();
-        entries.push(TreeEntry { kind, name, last_msg, age });
-    }
-
-    let head_commit = {
-        let fmt = "--format=%h|%s|%ar|%an";
-        let info = if path.is_empty() {
-            run_git(repo, &["log", "-1", fmt, "HEAD"])
-        } else {
-            run_git(repo, &["log", "-1", fmt, "HEAD", "--", path])
-        };
-        info.and_then(|s| {
-            let mut it = s.splitn(4, '|');
-            Some(PathCommit {
-                sha_short: it.next()?.to_string(),
-                description: it.next().unwrap_or("").to_string(),
-                age: it.next().unwrap_or("").to_string(),
-                author: it.next().unwrap_or("").to_string(),
-            })
-        })
-    };
-
-    let readme_preview = {
-        let readme_git_path = if path.is_empty() {
-            "HEAD:README.md".to_string()
-        } else {
-            format!("HEAD:{}/README.md", path)
-        };
-        run_git(repo, &["show", &readme_git_path]).map(|content| {
-            let trimmed = content.trim().to_string();
-            if trimmed.len() > 400 {
-                format!("{}…", &trimmed[..400])
-            } else {
-                trimmed
-            }
-        })
-    };
-
-    Some(TreeData { bookmark, sha_short, entries, total_entries, head_commit, readme_preview })
-}
-
-pub async fn config(State(quire): State<Quire>) -> Response {
-    let tmpl = ConfigTemplate {
-        crumbs: vec![Crumb::new("config")],
-        config: quire.config.clone(),
-    };
-    render(&tmpl)
-}
-
-#[cfg(test)]
-mod tests {
-    use axum::body::Body;
-    use axum::http::{Request, StatusCode};
-    use tower::ServiceExt;
-
-    use crate::Quire;
-
-    /// Build a test axum Router with the CI routes, backed by a tempdir.
-    struct TestEnv {
-        _dir: tempfile::TempDir,
-        quire: Quire,
-    }
-
-    impl TestEnv {
-        fn new() -> Self {
-            let dir = tempfile::tempdir().expect("tempdir");
-            let quire = Quire::load(dir.path().to_path_buf()).expect("load");
-
-            // Create repos dir + a bare repo so `quire.repo("example.git")` resolves.
-            let repos_dir = quire.repos_dir();
-            let bare = repos_dir.join("example.git");
-            fs_err::create_dir_all(&bare).expect("mkdir bare repo");
-
-            // Initialise DB with migrations.
-            let mut db = crate::db::open(&quire.db_path()).expect("db open");
-            crate::db::migrate(&mut db).expect("migrate");
-            drop(db);
-
-            Self { _dir: dir, quire }
-        }
-
-        fn insert_run(
-            &self,
-            id: &str,
-            outcome: Option<&str>,
-            sha: &str,
-            ref_name: &str,
-            created: i64,
-            dispatched: Option<i64>,
-            resolved: Option<i64>,
-        ) {
-            let db = self.quire.db_pool();
-            db.execute(
-                "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms,
-                                  created_at, dispatched_at, resolved_at, outcome)
-                 VALUES (?1, 'example.git', ?2, ?3, ?4, ?4, ?5, ?6, ?7)",
-                rusqlite::params![id, ref_name, sha, created, dispatched, resolved, outcome],
-            )
-            .expect("insert run");
-        }
-
-        fn insert_job(
-            &self,
-            run_id: &str,
-            job_id: &str,
-            state: &str,
-            exit_code: Option<i32>,
-            started: Option<i64>,
-            finished: Option<i64>,
-        ) {
-            let db = self.quire.db_pool();
-            db.execute(
-                "INSERT INTO jobs (run_id, job_id, state, exit_code, started_at_ms, finished_at_ms)
-                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
-                rusqlite::params![run_id, job_id, state, exit_code, started, finished],
-            )
-            .expect("insert job");
-        }
-
-        fn app(&self) -> axum::Router {
-            super::super::router(self.quire.clone())
-        }
-    }
-
-    const UUID1: &str = "aaaaaaaa-0000-0000-0000-000000000001";
-    const SHA1: &str = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
-
-    #[tokio::test]
-    async fn repo_home_returns_ok_for_known_repo() {
-        let env = TestEnv::new();
-        let app = env.app();
-        let req = Request::builder()
-            .uri("/example")
-            .body(Body::empty())
-            .unwrap();
-        let resp = app.oneshot(req).await.unwrap();
-        assert_eq!(resp.status(), StatusCode::OK);
-    }
-
-    #[tokio::test]
-    async fn repo_home_accepts_git_suffix() {
-        let env = TestEnv::new();
-        let app = env.app();
-        let req = Request::builder()
-            .uri("/example.git")
-            .body(Body::empty())
-            .unwrap();
-        let resp = app.oneshot(req).await.unwrap();
-        assert_eq!(resp.status(), StatusCode::OK);
-    }
-
-    #[tokio::test]
-    async fn run_list_returns_ok_for_known_repo() {
-        let env = TestEnv::new();
-        env.insert_run(
-            UUID1,
-            Some("succeeded"),
-            SHA1,
-            "refs/heads/main",
-            1000,
-            Some(2000),
-            Some(3000),
-        );
-        let app = env.app();
-        let req = Request::builder()
-            .uri("/example/ci")
-            .body(Body::empty())
-            .unwrap();
-        let resp = app.oneshot(req).await.unwrap();
-        assert_eq!(resp.status(), StatusCode::OK);
-    }
-
-    #[tokio::test]
-    async fn run_list_returns_404_for_unknown_repo() {
-        let env = TestEnv::new();
-        let app = env.app();
-        let req = Request::builder()
-            .uri("/nonexistent/ci")
-            .body(Body::empty())
-            .unwrap();
-        let resp = app.oneshot(req).await.unwrap();
-        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
-    }
-
-    #[tokio::test]
-    async fn run_detail_returns_ok_for_existing_run() {
-        let env = TestEnv::new();
-        env.insert_run(
-            UUID1,
-            Some("succeeded"),
-            SHA1,
-            "refs/heads/main",
-            1000,
-            Some(2000),
-            Some(3000),
-        );
-        env.insert_job(UUID1, "build", "succeeded", Some(0), Some(2000), Some(3000));
-        let app = env.app();
-        let req = Request::builder()
-            .uri(&format!("/example/ci/{UUID1}"))
-            .body(Body::empty())
-            .unwrap();
-        let resp = app.oneshot(req).await.unwrap();
-        assert_eq!(resp.status(), StatusCode::OK);
-    }
-
-    #[tokio::test]
-    async fn run_detail_returns_404_for_invalid_id() {
-        let env = TestEnv::new();
-        let app = env.app();
-        let req = Request::builder()
-            .uri("/example/ci/not-a-uuid")
-            .body(Body::empty())
-            .unwrap();
-        let resp = app.oneshot(req).await.unwrap();
-        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
-    }
-
-    #[tokio::test]
-    async fn tree_view_returns_ok_for_known_repo() {
-        let env = TestEnv::new();
-        // Init the bare repo so ls-tree has something to read.
-        let repo_path = env.quire.repos_dir().join("example.git");
-        std::process::Command::new("git")
-            .args(["init", "--bare"])
-            .current_dir(&repo_path)
-            .output()
-            .ok();
-        let app = env.app();
-        let req = Request::builder()
-            .uri("/example/tree")
-            .body(Body::empty())
-            .unwrap();
-        let resp = app.oneshot(req).await.unwrap();
-        // Empty repo has no HEAD, so ls-tree returns nothing — handler returns 404.
-        // A populated repo would return 200; testing the route is wired is enough here.
-        assert!(
-            resp.status() == StatusCode::OK || resp.status() == StatusCode::NOT_FOUND,
-            "unexpected status: {}",
-            resp.status()
-        );
-    }
-
-    #[tokio::test]
-    async fn tree_view_returns_404_for_unknown_repo() {
-        let env = TestEnv::new();
-        let app = env.app();
-        let req = Request::builder()
-            .uri("/nonexistent/tree")
-            .body(Body::empty())
-            .unwrap();
-        let resp = app.oneshot(req).await.unwrap();
-        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
-    }
-
-    #[tokio::test]
-    async fn run_detail_returns_404_for_missing_run() {
-        let env = TestEnv::new();
-        let app = env.app();
-        // Valid UUID but no run exists — should return 404, not 500.
-        let req = Request::builder()
-            .uri(&format!("/example/ci/{UUID1}"))
-            .body(Body::empty())
-            .unwrap();
-        let resp = app.oneshot(req).await.unwrap();
-        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
-    }
-}