Skip to main content

capsula_server/
lib.rs

1use askama::Template;
2use askama_web::WebTemplate;
3
4#[expect(
5    clippy::unnecessary_wraps,
6    reason = "Askama filter_fn macro generates code that triggers this lint, but Result return type is required by the Askama filter API"
7)]
8#[expect(
9    clippy::inline_always,
10    clippy::unused_self,
11    reason = "Askama's #[filter_fn] macro generates builder pattern code with #[inline(always)] that triggers these lints"
12)]
13mod filters {
14    use askama::filter_fn;
15
16    #[filter_fn]
17    pub fn format_command(s: &str, _env: &dyn askama::Values) -> ::askama::Result<String> {
18        // Try to parse as JSON array
19        Ok(serde_json::from_str::<Vec<String>>(s).map_or_else(
20            |_| s.to_string(),
21            |cmd_array| {
22                shlex::try_join(cmd_array.iter().map(String::as_str))
23                    .unwrap_or_else(|_| cmd_array.join(" "))
24            },
25        ))
26    }
27
28    #[filter_fn]
29    pub fn pretty_json<T: std::fmt::Display>(
30        s: T,
31        _env: &dyn askama::Values,
32    ) -> ::askama::Result<String> {
33        let s_str = s.to_string();
34        // Try to parse as JSON and pretty-print with 2-space indentation
35        Ok(serde_json::from_str::<serde_json::Value>(&s_str)
36            .ok()
37            .and_then(|value| serde_json::to_string_pretty(&value).ok())
38            .unwrap_or(s_str))
39    }
40}
41
42use axum::{
43    Router,
44    body::Body,
45    extract::{DefaultBodyLimit, Multipart, Path, Query, State},
46    http::{StatusCode, header},
47    response::{IntoResponse, Json, Response},
48    routing::{get, post},
49};
50use capsula_api_types::VaultInfo;
51use serde_json::json;
52use sha2::{Digest, Sha256};
53use sqlx::PgPool;
54use std::collections::VecDeque;
55use std::path::PathBuf;
56use tracing::{error, info, warn};
57
58#[derive(Clone)]
59pub struct AppState {
60    pub pool: PgPool,
61    pub storage_path: PathBuf,
62}
63
64mod models;
65mod query;
66
67#[derive(Template, WebTemplate)]
68#[template(path = "index.html")]
69struct IndexTemplate;
70
71#[derive(Template, WebTemplate)]
72#[template(path = "vaults.html")]
73struct VaultsTemplate {
74    vaults: Vec<VaultInfo>,
75}
76
77#[derive(Template, WebTemplate)]
78#[template(path = "runs.html")]
79struct RunsTemplate {
80    runs: Vec<models::Run>,
81    vault: Option<String>,
82    page: i64,
83    total_pages: i64,
84}
85
86#[derive(Template, WebTemplate)]
87#[template(path = "run_detail.html")]
88struct RunDetailTemplate {
89    run: models::Run,
90    pre_run_hooks: Vec<models::HookOutput>,
91    post_run_hooks: Vec<models::HookOutput>,
92    files: Vec<models::CapturedFile>,
93}
94
95#[derive(Template, WebTemplate)]
96#[template(path = "error.html")]
97struct ErrorTemplate {
98    status_code: u16,
99    title: String,
100    message: String,
101}
102
103pub async fn create_pool(database_url: &str, max_connections: u32) -> Result<PgPool, sqlx::Error> {
104    sqlx::postgres::PgPoolOptions::new()
105        .max_connections(max_connections)
106        .acquire_timeout(std::time::Duration::from_secs(3))
107        .connect(database_url)
108        .await
109}
110
111pub fn build_app(pool: PgPool, storage_path: PathBuf, max_body_size: usize) -> Router {
112    let state = AppState { pool, storage_path };
113
114    Router::new()
115        .route("/", get(index))
116        .route("/vaults", get(vaults_page))
117        .route("/runs", get(runs_page))
118        .route("/runs/{id}", get(run_detail_page))
119        .route("/health", get(health_check))
120        .route("/api/v1/vaults", get(list_vaults))
121        .route("/api/v1/vaults/{name}", get(get_vault_info))
122        .route("/api/v1/runs", post(create_run).get(list_runs))
123        .route("/api/v1/runs/search", post(search_runs))
124        .route("/api/v1/runs/{id}", get(get_run))
125        .route("/api/v1/runs/{id}/files/{*path}", get(download_file))
126        .route(
127            "/api/v1/upload",
128            post(upload_files).layer(DefaultBodyLimit::max(max_body_size)),
129        )
130        .route("/static/style.css", get(serve_style_css))
131        .fallback(not_found)
132        .with_state(state)
133}
134
135async fn serve_style_css() -> impl IntoResponse {
136    (
137        [(header::CONTENT_TYPE, mime_guess::mime::TEXT_CSS.as_ref())],
138        include_str!("../static/style.css"),
139    )
140}
141
142async fn index() -> impl IntoResponse {
143    IndexTemplate
144}
145
146async fn not_found() -> impl IntoResponse {
147    (
148        StatusCode::NOT_FOUND,
149        ErrorTemplate {
150            status_code: 404,
151            title: "Page Not Found".to_string(),
152            message: "The page you are looking for does not exist.".to_string(),
153        },
154    )
155}
156
157async fn vaults_page(State(state): State<AppState>) -> impl IntoResponse {
158    info!("Rendering vaults page");
159
160    let result = sqlx::query!(
161        r#"
162        SELECT vault as name, COUNT(*) as "run_count!"
163        FROM runs
164        GROUP BY vault
165        ORDER BY vault
166        "#
167    )
168    .fetch_all(&state.pool)
169    .await;
170
171    match result {
172        Ok(rows) => {
173            let vaults: Vec<VaultInfo> = rows
174                .into_iter()
175                .map(|row| VaultInfo {
176                    name: row.name,
177                    run_count: row.run_count,
178                })
179                .collect();
180            VaultsTemplate { vaults }
181        }
182        Err(e) => {
183            error!("Failed to fetch vaults: {}", e);
184            // Return empty vaults on error
185            VaultsTemplate { vaults: Vec::new() }
186        }
187    }
188}
189
190async fn runs_page(
191    State(state): State<AppState>,
192    Query(params): Query<models::ListRunsQuery>,
193) -> impl IntoResponse {
194    let page = params.offset.unwrap_or(0) / params.limit.unwrap_or(50) + 1;
195    let limit = params.limit.unwrap_or(50);
196    let offset = (page - 1) * limit;
197
198    info!(
199        "Rendering runs page: page={}, vault={:?}",
200        page, params.vault
201    );
202
203    // Get total count for pagination
204    let total_count = if let Some(ref vault) = params.vault {
205        sqlx::query_scalar!(
206            r#"
207            SELECT COUNT(*)::bigint
208            FROM runs
209            WHERE vault = $1
210            "#,
211            vault
212        )
213        .fetch_one(&state.pool)
214        .await
215        .unwrap_or(Some(0))
216        .unwrap_or(0)
217    } else {
218        sqlx::query_scalar!(
219            r#"
220            SELECT COUNT(*)::bigint
221            FROM runs
222            "#
223        )
224        .fetch_one(&state.pool)
225        .await
226        .unwrap_or(Some(0))
227        .unwrap_or(0)
228    };
229
230    let total_pages = (total_count + limit - 1) / limit;
231
232    // Fetch runs
233    let runs_result = if let Some(ref vault) = params.vault {
234        sqlx::query_as!(
235            models::Run,
236            r#"
237            SELECT id, name, timestamp, command, vault, project_root,
238                   exit_code, duration_ms, stdout, stderr,
239                   created_at, updated_at
240            FROM runs
241            WHERE vault = $1
242            ORDER BY timestamp DESC
243            LIMIT $2 OFFSET $3
244            "#,
245            vault,
246            limit,
247            offset
248        )
249        .fetch_all(&state.pool)
250        .await
251    } else {
252        sqlx::query_as!(
253            models::Run,
254            r#"
255            SELECT id, name, timestamp, command, vault, project_root,
256                   exit_code, duration_ms, stdout, stderr,
257                   created_at, updated_at
258            FROM runs
259            ORDER BY timestamp DESC
260            LIMIT $1 OFFSET $2
261            "#,
262            limit,
263            offset
264        )
265        .fetch_all(&state.pool)
266        .await
267    };
268
269    match runs_result {
270        Ok(runs) => RunsTemplate {
271            runs,
272            vault: params.vault,
273            page,
274            total_pages,
275        },
276        Err(e) => {
277            error!("Failed to fetch runs: {}", e);
278            RunsTemplate {
279                runs: Vec::new(),
280                vault: params.vault,
281                page: 1,
282                total_pages: 1,
283            }
284        }
285    }
286}
287
288async fn run_detail_page(
289    State(state): State<AppState>,
290    Path(id): Path<String>,
291) -> Result<RunDetailTemplate, StatusCode> {
292    info!("Rendering run detail page for: {}", id);
293
294    // Fetch run metadata
295    let run = sqlx::query_as!(
296        models::Run,
297        r#"
298        SELECT id, name, timestamp, command, vault, project_root,
299               exit_code, duration_ms, stdout, stderr,
300               created_at, updated_at
301        FROM runs
302        WHERE id = $1
303        "#,
304        id
305    )
306    .fetch_optional(&state.pool)
307    .await
308    .map_err(|e| {
309        error!("Database error while fetching run: {}", e);
310        StatusCode::INTERNAL_SERVER_ERROR
311    })?
312    .ok_or_else(|| {
313        info!("Run not found: {}", id);
314        StatusCode::NOT_FOUND
315    })?;
316
317    // Fetch hook outputs
318    let hook_outputs_result = sqlx::query_as!(
319        models::RunOutputRow,
320        r#"
321        SELECT phase, hook_id, config, output, success, error
322        FROM run_outputs
323        WHERE run_id = $1
324        ORDER BY id
325        "#,
326        id
327    )
328    .fetch_all(&state.pool)
329    .await;
330
331    let (pre_run_hooks, post_run_hooks) = match hook_outputs_result {
332        Ok(rows) => {
333            let mut pre_hooks = Vec::new();
334            let mut post_hooks = Vec::new();
335
336            for row in rows {
337                let hook_output = models::HookOutput {
338                    meta: models::HookMeta {
339                        id: row.hook_id,
340                        config: row.config,
341                        success: row.success,
342                        error: row.error,
343                    },
344                    output: row.output,
345                };
346
347                if row.phase == "pre" {
348                    pre_hooks.push(hook_output);
349                } else if row.phase == "post" {
350                    post_hooks.push(hook_output);
351                } else {
352                    warn!("Unknown hook phase: {}", row.phase);
353                }
354            }
355
356            (pre_hooks, post_hooks)
357        }
358        Err(e) => {
359            error!("Failed to fetch hook outputs: {}", e);
360            (Vec::new(), Vec::new())
361        }
362    };
363
364    // Fetch captured files
365    let files_result = sqlx::query_as!(
366        models::CapturedFile,
367        r#"
368        SELECT path, size, hash, storage_path, content_type
369        FROM captured_files
370        WHERE run_id = $1
371        ORDER BY path
372        "#,
373        id
374    )
375    .fetch_all(&state.pool)
376    .await;
377
378    let files = match files_result {
379        Ok(files) => files,
380        Err(e) => {
381            error!("Failed to fetch captured files: {}", e);
382            Vec::new()
383        }
384    };
385
386    Ok(RunDetailTemplate {
387        run,
388        pre_run_hooks,
389        post_run_hooks,
390        files,
391    })
392}
393
394async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
395    match sqlx::query("SELECT 1").fetch_one(&state.pool).await {
396        Ok(_) => Json(json!({
397            "status": "ok",
398            "database": "connected"
399        })),
400        Err(e) => Json(json!({
401            "status": "error",
402            "database": "disconnected",
403            "error": e.to_string()
404        })),
405    }
406}
407
408async fn list_vaults(State(state): State<AppState>) -> impl IntoResponse {
409    info!("Listing all vaults");
410
411    let result = sqlx::query!(
412        r#"
413        SELECT vault as name, COUNT(*) as "run_count!"
414        FROM runs
415        GROUP BY vault
416        ORDER BY vault
417        "#
418    )
419    .fetch_all(&state.pool)
420    .await;
421
422    match result {
423        Ok(rows) => {
424            let vaults: Vec<VaultInfo> = rows
425                .into_iter()
426                .map(|row| VaultInfo {
427                    name: row.name,
428                    run_count: row.run_count,
429                })
430                .collect();
431            info!("Found {} vaults", vaults.len());
432            let response = capsula_api_types::VaultsResponse {
433                status: "ok".to_string(),
434                vaults,
435            };
436            Json(serde_json::to_value(response).expect("Failed to serialize VaultsResponse"))
437        }
438        Err(e) => {
439            error!("Failed to list vaults: {}", e);
440            let response = capsula_api_types::ErrorResponse {
441                status: "error".to_string(),
442                error: e.to_string(),
443            };
444            Json(serde_json::to_value(response).expect("Failed to serialize ErrorResponse"))
445        }
446    }
447}
448
449async fn get_vault_info(
450    State(state): State<AppState>,
451    Path(name): Path<String>,
452) -> impl IntoResponse {
453    info!("Getting vault info: {}", name);
454
455    let result = sqlx::query!(
456        r#"
457        SELECT vault as name, COUNT(*) as "run_count!"
458        FROM runs
459        WHERE vault = $1
460        GROUP BY vault
461        "#,
462        name
463    )
464    .fetch_optional(&state.pool)
465    .await;
466
467    match result {
468        Ok(Some(row)) => {
469            let vault = VaultInfo {
470                name: row.name,
471                run_count: row.run_count,
472            };
473            info!("Found vault: {} with {} runs", vault.name, vault.run_count);
474            let response = capsula_api_types::VaultExistsResponse {
475                status: "ok".to_string(),
476                exists: true,
477                vault: Some(vault),
478            };
479            Json(serde_json::to_value(response).expect("Failed to serialize VaultExistsResponse"))
480        }
481        Ok(None) => {
482            info!("Vault not found: {}", name);
483            let response = capsula_api_types::VaultExistsResponse {
484                status: "ok".to_string(),
485                exists: false,
486                vault: None,
487            };
488            Json(serde_json::to_value(response).expect("Failed to serialize VaultExistsResponse"))
489        }
490        Err(e) => {
491            error!("Failed to get vault info: {}", e);
492            let response = capsula_api_types::ErrorResponse {
493                status: "error".to_string(),
494                error: e.to_string(),
495            };
496            Json(serde_json::to_value(response).expect("Failed to serialize ErrorResponse"))
497        }
498    }
499}
500
501async fn list_runs(
502    State(state): State<AppState>,
503    Query(params): Query<models::ListRunsQuery>,
504) -> impl IntoResponse {
505    let limit = params.limit.unwrap_or(100);
506    let offset = params.offset.unwrap_or(0);
507
508    if let Some(ref vault) = params.vault {
509        info!(
510            "Listing runs for vault: {} (limit={}, offset={})",
511            vault, limit, offset
512        );
513    } else {
514        info!("Listing all runs (limit={}, offset={})", limit, offset);
515    }
516
517    let result = if let Some(vault) = params.vault {
518        sqlx::query_as!(
519            models::Run,
520            r#"
521            SELECT id, name, timestamp, command, vault, project_root,
522                   exit_code, duration_ms, stdout, stderr,
523                   created_at, updated_at
524            FROM runs
525            WHERE vault = $1
526            ORDER BY timestamp DESC
527            LIMIT $2 OFFSET $3
528            "#,
529            vault,
530            limit,
531            offset
532        )
533        .fetch_all(&state.pool)
534        .await
535    } else {
536        sqlx::query_as!(
537            models::Run,
538            r#"
539            SELECT id, name, timestamp, command, vault, project_root,
540                   exit_code, duration_ms, stdout, stderr,
541                   created_at, updated_at
542            FROM runs
543            ORDER BY timestamp DESC
544            LIMIT $1 OFFSET $2
545            "#,
546            limit,
547            offset
548        )
549        .fetch_all(&state.pool)
550        .await
551    };
552
553    match result {
554        Ok(runs) => {
555            info!("Found {} runs", runs.len());
556            Json(json!({
557                "status": "ok",
558                "runs": runs,
559                "limit": limit,
560                "offset": offset
561            }))
562        }
563        Err(e) => {
564            error!("Failed to list runs: {}", e);
565            Json(json!({
566                "status": "error",
567                "error": e.to_string()
568            }))
569        }
570    }
571}
572
573/// Search runs with `JSONPath` filters on hook outputs
574#[expect(clippy::too_many_lines, reason = "TODO: Refactor later")]
575async fn search_runs(
576    State(state): State<AppState>,
577    Json(request): Json<models::SearchRunsRequest>,
578) -> impl IntoResponse {
579    info!(
580        "Searching runs: vault={:?}, hook_filters={}",
581        request.vault,
582        request.hook_filters.len()
583    );
584
585    // Build the query
586    let builder = match query::RunQueryBuilder::from_request(&request) {
587        Ok(b) => b,
588        Err(e) => {
589            error!("Failed to build query: {}", e);
590            return Json(json!({
591                "status": "error",
592                "error": e.to_string()
593            }));
594        }
595    };
596
597    let query_sql = builder.build_query();
598    let count_sql = builder.build_count_query();
599    let bind_values = builder.bind_values();
600
601    info!("Executing search query: {}", query_sql);
602
603    // Execute count query first
604    let total: i64 = {
605        let mut query = sqlx::query_scalar::<_, i64>(&count_sql);
606        for value in bind_values {
607            query = match value {
608                query::BindValue::String(s) => query.bind(s.clone()),
609                query::BindValue::I32(i) => query.bind(*i),
610                query::BindValue::I64(i) => query.bind(*i),
611                query::BindValue::DateTime(dt) => query.bind(*dt),
612                query::BindValue::Bool(b) => query.bind(*b),
613            };
614        }
615        match query.fetch_one(&state.pool).await {
616            Ok(count) => count,
617            Err(e) => {
618                error!("Failed to execute count query: {}", e);
619                return Json(json!({
620                    "status": "error",
621                    "error": "Query execution failed"
622                }));
623            }
624        }
625    };
626
627    // Execute main query
628    let runs: Vec<models::Run> = {
629        let mut query = sqlx::query_as::<_, models::Run>(&query_sql);
630        for value in bind_values {
631            query = match value {
632                query::BindValue::String(s) => query.bind(s.clone()),
633                query::BindValue::I32(i) => query.bind(*i),
634                query::BindValue::I64(i) => query.bind(*i),
635                query::BindValue::DateTime(dt) => query.bind(*dt),
636                query::BindValue::Bool(b) => query.bind(*b),
637            };
638        }
639        match query.fetch_all(&state.pool).await {
640            Ok(runs) => runs,
641            Err(e) => {
642                error!("Failed to execute search query: {}", e);
643                return Json(json!({
644                    "status": "error",
645                    "error": "Query execution failed"
646                }));
647            }
648        }
649    };
650
651    info!("Found {} runs (total: {})", runs.len(), total);
652
653    // Determine what to include in response
654    let include_files = request.include.contains(&models::IncludeField::Files);
655    let include_stdout = request.include.contains(&models::IncludeField::Stdout);
656    let include_stderr = request.include.contains(&models::IncludeField::Stderr);
657    let include_hooks = request.include.contains(&models::IncludeField::Hooks);
658
659    // Build response
660    let mut results = Vec::with_capacity(runs.len());
661    for run in runs {
662        let files = if include_files {
663            match sqlx::query_as::<_, models::CapturedFile>(
664                "SELECT path, size, hash, storage_path, content_type \
665                 FROM captured_files WHERE run_id = $1 ORDER BY path",
666            )
667            .bind(&run.id)
668            .fetch_all(&state.pool)
669            .await
670            {
671                Ok(files) => Some(
672                    files
673                        .into_iter()
674                        .map(|f| models::FileInfo {
675                            path: f.path.clone(),
676                            size: f.size,
677                            hash: f.hash,
678                            url: format!("/api/v1/runs/{}/files/{}", run.id, f.path),
679                        })
680                        .collect(),
681                ),
682                Err(e) => {
683                    warn!("Failed to fetch files for run {}: {}", run.id, e);
684                    None
685                }
686            }
687        } else {
688            None
689        };
690
691        let (pre_run_hooks, post_run_hooks) = if include_hooks {
692            match sqlx::query_as::<_, models::RunOutputRow>(
693                "SELECT phase, hook_id, config, output, success, error \
694                 FROM run_outputs WHERE run_id = $1 ORDER BY id",
695            )
696            .bind(&run.id)
697            .fetch_all(&state.pool)
698            .await
699            {
700                Ok(rows) => {
701                    let mut pre_hooks = Vec::new();
702                    let mut post_hooks = Vec::new();
703                    for row in rows {
704                        let hook_output = models::HookOutput {
705                            meta: models::HookMeta {
706                                id: row.hook_id,
707                                config: row.config,
708                                success: row.success,
709                                error: row.error,
710                            },
711                            output: row.output,
712                        };
713                        if row.phase == "pre" {
714                            pre_hooks.push(hook_output);
715                        } else if row.phase == "post" {
716                            post_hooks.push(hook_output);
717                        } else {
718                            // Unknown phase, skip
719                        }
720                    }
721                    (Some(pre_hooks), Some(post_hooks))
722                }
723                Err(e) => {
724                    warn!("Failed to fetch hooks for run {}: {}", run.id, e);
725                    (None, None)
726                }
727            }
728        } else {
729            (None, None)
730        };
731
732        results.push(models::SearchRunResult {
733            id: run.id,
734            name: run.name,
735            timestamp: run.timestamp,
736            vault: run.vault,
737            command: run.command,
738            project_root: run.project_root,
739            exit_code: run.exit_code,
740            duration_ms: run.duration_ms,
741            stdout: if include_stdout { run.stdout } else { None },
742            stderr: if include_stderr { run.stderr } else { None },
743            files,
744            pre_run_hooks,
745            post_run_hooks,
746        });
747    }
748
749    let response = models::SearchRunsResponse {
750        status: "ok".to_string(),
751        total,
752        runs: results,
753    };
754
755    Json(serde_json::to_value(response).expect("Failed to serialize SearchRunsResponse"))
756}
757
758async fn create_run(
759    State(state): State<AppState>,
760    Json(request): Json<models::CreateRunRequest>,
761) -> impl IntoResponse {
762    info!("Creating run: {}", request.id);
763
764    let result = sqlx::query!(
765        r#"
766        INSERT INTO runs (
767            id, name, timestamp, command, vault, project_root,
768            exit_code, duration_ms, stdout, stderr
769        ) VALUES (
770            $1, $2, $3, $4, $5, $6,
771            $7, $8, $9, $10
772        )
773        "#,
774        request.id,
775        request.name,
776        request.timestamp,
777        request.command,
778        request.vault,
779        request.project_root,
780        request.exit_code,
781        request.duration_ms,
782        request.stdout,
783        request.stderr
784    )
785    .execute(&state.pool)
786    .await;
787
788    match result {
789        Ok(_) => {
790            info!("Run created successfully");
791            (
792                StatusCode::CREATED,
793                Json(json!({
794                    "status": "created",
795                    "run": request
796                })),
797            )
798        }
799        Err(e) => {
800            error!("Failed to insert run: {}", e);
801            let status = if e.to_string().contains("duplicate key") {
802                StatusCode::CONFLICT
803            } else {
804                StatusCode::INTERNAL_SERVER_ERROR
805            };
806            (
807                status,
808                Json(json!({
809                    "status": "error",
810                    "error": e.to_string()
811                })),
812            )
813        }
814    }
815}
816
817async fn get_run(State(state): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
818    info!("Getting run: {}", id);
819
820    let result = sqlx::query_as!(
821        models::Run,
822        r#"
823        SELECT id, name, timestamp, command, vault, project_root,
824               exit_code, duration_ms, stdout, stderr,
825               created_at, updated_at
826        FROM runs
827        WHERE id = $1
828        "#,
829        id
830    )
831    .fetch_optional(&state.pool)
832    .await;
833
834    match result {
835        Ok(Some(run)) => {
836            info!("Found run: {}", run.id);
837
838            // Fetch hook outputs
839            let hook_outputs_result = sqlx::query_as!(
840                models::RunOutputRow,
841                r#"
842                SELECT phase, hook_id, config, output, success, error
843                FROM run_outputs
844                WHERE run_id = $1
845                ORDER BY id
846                "#,
847                id
848            )
849            .fetch_all(&state.pool)
850            .await;
851
852            let (pre_run_hooks, post_run_hooks) = match hook_outputs_result {
853                Ok(rows) => {
854                    let mut pre_hooks = Vec::new();
855                    let mut post_hooks = Vec::new();
856
857                    for row in rows {
858                        let hook_output = models::HookOutput {
859                            meta: models::HookMeta {
860                                id: row.hook_id,
861                                config: row.config,
862                                success: row.success,
863                                error: row.error,
864                            },
865                            output: row.output,
866                        };
867
868                        if row.phase == "pre" {
869                            pre_hooks.push(hook_output);
870                        } else if row.phase == "post" {
871                            post_hooks.push(hook_output);
872                        } else {
873                            warn!("Unknown hook phase: {}", row.phase);
874                        }
875                    }
876
877                    info!(
878                        "Found {} pre-run hooks and {} post-run hooks",
879                        pre_hooks.len(),
880                        post_hooks.len()
881                    );
882                    (pre_hooks, post_hooks)
883                }
884                Err(e) => {
885                    error!("Failed to fetch hook outputs: {}", e);
886                    (Vec::new(), Vec::new())
887                }
888            };
889
890            Json(json!({
891                "status": "ok",
892                "run": run,
893                "pre_run_hooks": pre_run_hooks,
894                "post_run_hooks": post_run_hooks
895            }))
896        }
897        Ok(None) => {
898            info!("Run not found: {}", id);
899            Json(json!({
900                "status": "not_found",
901                "error": format!("Run with id {} not found", id)
902            }))
903        }
904        Err(e) => {
905            error!("Failed to retrieve run: {}", e);
906            Json(json!({
907                "status": "error",
908                "error": e.to_string()
909            }))
910        }
911    }
912}
913
914#[expect(clippy::too_many_lines, reason = "TODO: Refactor later")]
915#[expect(
916    clippy::else_if_without_else,
917    reason = "There is `continue` or `return` in each branch, so `else` is redundant"
918)]
919async fn upload_files(
920    State(state): State<AppState>,
921    mut multipart: Multipart,
922) -> impl IntoResponse {
923    let storage_path = &state.storage_path;
924    info!("Received file upload request");
925
926    if let Err(e) = tokio::fs::create_dir_all(&storage_path).await {
927        error!(
928            "Failed to create storage directory at {}: {}",
929            storage_path.display(),
930            e
931        );
932        return Json(json!({
933            "status": "error",
934            "error": format!("Failed to create storage directory: {}", e)
935        }));
936    }
937
938    let mut files_processed = 0;
939    let mut total_bytes = 0u64;
940    let mut run_id: Option<String> = None;
941    let mut pending_paths: VecDeque<String> = VecDeque::new();
942    let mut pre_run_hooks: Option<Vec<models::HookOutput>> = None;
943    let mut post_run_hooks: Option<Vec<models::HookOutput>> = None;
944
945    while let Ok(Some(field)) = multipart.next_field().await {
946        let field_name = field.name().unwrap_or("unknown").to_string();
947
948        if field_name == "run_id" {
949            match field.text().await {
950                Ok(text) => {
951                    run_id = Some(text);
952                    continue;
953                }
954                Err(e) => {
955                    error!("Failed to read run_id field: {}", e);
956                    return Json(json!({
957                        "status": "error",
958                        "error": format!("Failed to read run_id: {}", e)
959                    }));
960                }
961            }
962        } else if field_name == "path" {
963            match field.text().await {
964                Ok(text) => {
965                    pending_paths.push_back(text);
966                    continue;
967                }
968                Err(e) => {
969                    error!("Failed to read path field: {}", e);
970                    return Json(json!({
971                        "status": "error",
972                        "error": format!("Failed to read path: {}", e)
973                    }));
974                }
975            }
976        } else if field_name == "pre_run" {
977            match field.text().await {
978                Ok(text) => match serde_json::from_str::<Vec<models::HookOutput>>(&text) {
979                    Ok(hooks) => {
980                        info!("Parsed {} pre-run hooks", hooks.len());
981                        pre_run_hooks = Some(hooks);
982                        continue;
983                    }
984                    Err(e) => {
985                        error!("Failed to parse pre_run JSON: {}", e);
986                        return Json(json!({
987                            "status": "error",
988                            "error": format!("Failed to parse pre_run JSON: {}", e)
989                        }));
990                    }
991                },
992                Err(e) => {
993                    error!("Failed to read pre_run field: {}", e);
994                    return Json(json!({
995                        "status": "error",
996                        "error": format!("Failed to read pre_run: {}", e)
997                    }));
998                }
999            }
1000        } else if field_name == "post_run" {
1001            match field.text().await {
1002                Ok(text) => match serde_json::from_str::<Vec<models::HookOutput>>(&text) {
1003                    Ok(hooks) => {
1004                        info!("Parsed {} post-run hooks", hooks.len());
1005                        post_run_hooks = Some(hooks);
1006                        continue;
1007                    }
1008                    Err(e) => {
1009                        error!("Failed to parse post_run JSON: {}", e);
1010                        return Json(json!({
1011                            "status": "error",
1012                            "error": format!("Failed to parse post_run JSON: {}", e)
1013                        }));
1014                    }
1015                },
1016                Err(e) => {
1017                    error!("Failed to read post_run field: {}", e);
1018                    return Json(json!({
1019                        "status": "error",
1020                        "error": format!("Failed to read post_run: {}", e)
1021                    }));
1022                }
1023            }
1024        }
1025
1026        let file_name = field.file_name().unwrap_or("unknown").to_string();
1027        let content_type = field
1028            .content_type()
1029            .unwrap_or("application/octet-stream")
1030            .to_string();
1031
1032        info!(
1033            "Processing file: field_name={}, file_name={}, content_type={}",
1034            field_name, file_name, content_type
1035        );
1036
1037        match field.bytes().await {
1038            Ok(data) => {
1039                let size = data.len();
1040                total_bytes += size as u64;
1041                let Ok(size_i64) = i64::try_from(size) else {
1042                    error!("File too large to store size in database: {} bytes", size);
1043                    return Json(json!({
1044                        "status": "error",
1045                        "error": "File too large to store size in database"
1046                    }));
1047                };
1048
1049                let mut hasher = Sha256::new();
1050                hasher.update(&data);
1051                let hash = hex_encode(hasher.finalize().as_ref());
1052
1053                info!("File hash: {}, size: {} bytes", hash, size);
1054
1055                let hash_dir = &hash[0..2];
1056                let file_storage_dir = storage_path.join(hash_dir);
1057                if let Err(e) = tokio::fs::create_dir_all(&file_storage_dir).await {
1058                    error!(
1059                        "Failed to create hash directory at {}: {}",
1060                        file_storage_dir.display(),
1061                        e
1062                    );
1063                    return Json(json!({
1064                        "status": "error",
1065                        "error": format!("Failed to create storage directory: {}", e)
1066                    }));
1067                }
1068
1069                let file_storage_path = file_storage_dir.join(&hash);
1070                let storage_path_str = file_storage_path.to_string_lossy().to_string();
1071
1072                if file_storage_path.exists() {
1073                    info!("File already exists (deduplicated): {}", storage_path_str);
1074                } else {
1075                    if let Err(e) = tokio::fs::write(&file_storage_path, &data).await {
1076                        error!("Failed to write file: {}", e);
1077                        return Json(json!({
1078                            "status": "error",
1079                            "error": format!("Failed to write file: {}", e)
1080                        }));
1081                    }
1082                    info!("Saved new file: {}", storage_path_str);
1083                }
1084
1085                if let Some(ref rid) = run_id {
1086                    let relative_path = pending_paths
1087                        .pop_front()
1088                        .or_else(|| {
1089                            if file_name == "unknown" {
1090                                None
1091                            } else {
1092                                Some(file_name.clone())
1093                            }
1094                        })
1095                        .unwrap_or_else(|| {
1096                            if field_name != "unknown" && field_name != "file" {
1097                                field_name.clone()
1098                            } else {
1099                                format!("file-{}", files_processed + 1)
1100                            }
1101                        });
1102
1103                    let result = sqlx::query!(
1104                        r#"
1105                        INSERT INTO captured_files (run_id, path, size, hash, storage_path, content_type)
1106                        VALUES ($1, $2, $3, $4, $5, $6)
1107                        ON CONFLICT (run_id, path) DO UPDATE
1108                        SET size = EXCLUDED.size,
1109                            hash = EXCLUDED.hash,
1110                            storage_path = EXCLUDED.storage_path,
1111                            content_type = EXCLUDED.content_type
1112                        "#,
1113                        rid,
1114                        relative_path,
1115                        size_i64,
1116                        hash,
1117                        storage_path_str,
1118                        content_type
1119                    )
1120                    .execute(&state.pool)
1121                    .await;
1122
1123                    match result {
1124                        Ok(_) => {
1125                            info!("Stored file metadata in database");
1126                        }
1127                        Err(e) => {
1128                            error!("Failed to store file metadata: {}", e);
1129                            return Json(json!({
1130                                "status": "error",
1131                                "error": format!("Failed to store file metadata: {}", e)
1132                            }));
1133                        }
1134                    }
1135                }
1136
1137                files_processed += 1;
1138                info!("Successfully processed file: {} bytes", size);
1139            }
1140            Err(e) => {
1141                error!("Failed to read file field: {}", e);
1142                return Json(json!({
1143                    "status": "error",
1144                    "error": format!("Failed to read file: {}", e)
1145                }));
1146            }
1147        }
1148    }
1149
1150    info!(
1151        "Upload complete: {} files, {} bytes total",
1152        files_processed, total_bytes
1153    );
1154
1155    // Store hook outputs if provided
1156    let mut pre_run_count = 0;
1157    let mut post_run_count = 0;
1158
1159    if let Some(ref rid) = run_id {
1160        if let Some(hooks) = pre_run_hooks {
1161            for hook in hooks {
1162                let result = sqlx::query!(
1163                    r#"
1164                    INSERT INTO run_outputs (run_id, phase, hook_id, config, output, success, error)
1165                    VALUES ($1, 'pre', $2, $3, $4, $5, $6)
1166                    ON CONFLICT (run_id, phase, hook_id) DO UPDATE
1167                    SET config = EXCLUDED.config,
1168                        output = EXCLUDED.output,
1169                        success = EXCLUDED.success,
1170                        error = EXCLUDED.error
1171                    "#,
1172                    rid,
1173                    hook.meta.id,
1174                    hook.meta.config,
1175                    hook.output,
1176                    hook.meta.success,
1177                    hook.meta.error
1178                )
1179                .execute(&state.pool)
1180                .await;
1181
1182                match result {
1183                    Ok(_) => {
1184                        pre_run_count += 1;
1185                        info!("Stored pre-run hook: {}", hook.meta.id);
1186                    }
1187                    Err(e) => {
1188                        error!("Failed to store pre-run hook {}: {}", hook.meta.id, e);
1189                        return Json(json!({
1190                            "status": "error",
1191                            "error": format!("Failed to store pre-run hook {}: {}", hook.meta.id, e)
1192                        }));
1193                    }
1194                }
1195            }
1196        }
1197
1198        if let Some(hooks) = post_run_hooks {
1199            for hook in hooks {
1200                let result = sqlx::query!(
1201                    r#"
1202                    INSERT INTO run_outputs (run_id, phase, hook_id, config, output, success, error)
1203                    VALUES ($1, 'post', $2, $3, $4, $5, $6)
1204                    ON CONFLICT (run_id, phase, hook_id) DO UPDATE
1205                    SET config = EXCLUDED.config,
1206                        output = EXCLUDED.output,
1207                        success = EXCLUDED.success,
1208                        error = EXCLUDED.error
1209                    "#,
1210                    rid,
1211                    hook.meta.id,
1212                    hook.meta.config,
1213                    hook.output,
1214                    hook.meta.success,
1215                    hook.meta.error
1216                )
1217                .execute(&state.pool)
1218                .await;
1219
1220                match result {
1221                    Ok(_) => {
1222                        post_run_count += 1;
1223                        info!("Stored post-run hook: {}", hook.meta.id);
1224                    }
1225                    Err(e) => {
1226                        error!("Failed to store post-run hook {}: {}", hook.meta.id, e);
1227                        return Json(json!({
1228                            "status": "error",
1229                            "error": format!("Failed to store post-run hook {}: {}", hook.meta.id, e)
1230                        }));
1231                    }
1232                }
1233            }
1234        }
1235    }
1236
1237    info!(
1238        "Hook outputs stored: {} pre-run, {} post-run",
1239        pre_run_count, post_run_count
1240    );
1241
1242    let response = capsula_api_types::UploadResponse {
1243        status: "ok".to_string(),
1244        files_processed,
1245        total_bytes,
1246        pre_run_hooks: pre_run_count,
1247        post_run_hooks: post_run_count,
1248    };
1249    Json(serde_json::to_value(response).expect("Failed to serialize UploadResponse"))
1250}
1251
1252async fn download_file(
1253    State(state): State<AppState>,
1254    Path((run_id, file_path)): Path<(String, String)>,
1255) -> Result<Response, StatusCode> {
1256    info!("Downloading file: run_id={}, path={}", run_id, file_path);
1257
1258    // Query the database for file metadata
1259    let file_record = sqlx::query!(
1260        r#"
1261        SELECT storage_path, content_type, path as file_path
1262        FROM captured_files
1263        WHERE run_id = $1 AND path = $2
1264        "#,
1265        run_id,
1266        file_path
1267    )
1268    .fetch_optional(&state.pool)
1269    .await
1270    .map_err(|e| {
1271        error!("Database error while fetching file metadata: {}", e);
1272        StatusCode::INTERNAL_SERVER_ERROR
1273    })?;
1274
1275    let Some(record) = file_record else {
1276        info!("File not found: run_id={}, path={}", run_id, file_path);
1277        return Err(StatusCode::NOT_FOUND);
1278    };
1279
1280    // Read the file from storage
1281    let file_data = tokio::fs::read(&record.storage_path).await.map_err(|e| {
1282        error!(
1283            "Failed to read file from storage {}: {}",
1284            record.storage_path, e
1285        );
1286        StatusCode::INTERNAL_SERVER_ERROR
1287    })?;
1288
1289    info!(
1290        "Successfully read file: {} bytes from {}",
1291        file_data.len(),
1292        record.storage_path
1293    );
1294
1295    // Determine content type (use stored value or guess from filename)
1296    let content_type = record.content_type.unwrap_or_else(|| {
1297        mime_guess::from_path(&record.file_path)
1298            .first_or_octet_stream()
1299            .to_string()
1300    });
1301
1302    // Extract filename from path for Content-Disposition
1303    let filename = std::path::Path::new(&record.file_path)
1304        .file_name()
1305        .and_then(|n| n.to_str())
1306        .unwrap_or("download");
1307
1308    // Build response with appropriate headers
1309    Response::builder()
1310        .status(StatusCode::OK)
1311        .header(header::CONTENT_TYPE, content_type)
1312        .header(
1313            header::CONTENT_DISPOSITION,
1314            format!("inline; filename=\"{filename}\""),
1315        )
1316        .body(Body::from(file_data))
1317        .map_err(|e| {
1318            error!("Failed to build response: {}", e);
1319            StatusCode::INTERNAL_SERVER_ERROR
1320        })
1321}
1322
1323// NOTE: An identical function exists in capsula-capture-file/src/hash.rs.
1324fn hex_encode(bytes: &[u8]) -> String {
1325    bytes
1326        .iter()
1327        .fold(String::with_capacity(bytes.len() * 2), |mut output, b| {
1328            std::fmt::Write::write_fmt(&mut output, format_args!("{b:02x}"))
1329                .expect("writing to a String should never fail");
1330            output
1331        })
1332}