Skip to main content

cortex/frontend/
corpora.rs

1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! Corpus-management capability: list/inspect/import/delete corpora as screens + API.
9//!
10//! Follows the symmetry contract — one shared [`CorpusDto`] renders as JSON for agents and (later)
11//! as HTML for humans. Handlers live here; the app is assembled in [`crate::frontend::server`].
12//! This is the first capability drained out of the binary's legacy routes; more land per increment.
13
14use std::collections::{HashMap, HashSet};
15
16use diesel::pg::PgConnection;
17use rocket::form::Form;
18use rocket::http::Status;
19use rocket::response::Redirect;
20use rocket::serde::json::Json;
21use rocket::{Route, State};
22use rocket_dyn_templates::Template;
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25use uuid::Uuid;
26
27use crate::backend::{
28  DatabaseUrl, DbPool, GroupBy, SandboxSelection, create_sandbox, export_html_dataset,
29  from_address, progress_report,
30};
31use crate::concerns::CortexInsertable;
32use crate::frontend::actor::{Actor, AdminReject, AdminSession, ReturnTo, require_admin_to};
33use crate::frontend::helpers::{decorate_uri_encodings, uri_escape};
34use crate::frontend::jobs::JobDto;
35use crate::frontend::params::TemplateContext;
36use crate::helpers::TaskStatus;
37use crate::importer::Importer;
38use crate::jobs::{self, JobProgress};
39use crate::models::{Corpus, NewCorpus, Service};
40use rocket_okapi::openapi;
41use schemars::JsonSchema;
42use std::path::PathBuf;
43
44/// The magic `import` service id. Service ids `1` (`init`) and `2` (`import`) are infrastructure
45/// (CLAUDE.md: real conversion services have id `> 2`); a service with id `≤` this is never
46/// user-activatable in the picker nor deactivatable from a corpus.
47const IMPORT_SERVICE_ID: i32 = 2;
48
49/// A corpus as exposed over the API/UI. `name` is the stable external handle used by every route.
50#[derive(Debug, Serialize, JsonSchema)]
51pub struct CorpusDto {
52  /// Stable external handle (UUIDv7) — survives a rename, unlike `name`. Use for durable
53  /// references.
54  pub public_id: String,
55  /// Human-readable corpus name (its external handle).
56  pub name: String,
57  /// Filesystem path to the corpus root.
58  pub path: String,
59  /// Human-readable description.
60  pub description: String,
61  /// Whether documents are multi-file (complex) rather than a single TeX file.
62  pub complex: bool,
63  /// Number of ingested documents (import-service tasks) — the corpus's scale at a glance.
64  pub document_count: i64,
65  /// Name of the parent corpus if this is a **sandbox** (a carved subset), else `null`. Lets a
66  /// caller tell sandboxes from ordinary corpora — and find their parent — from the list alone,
67  /// without a per-corpus detail fetch.
68  pub parent: Option<String>,
69}
70
71impl CorpusDto {
72  /// Builds the DTO, attaching the document count looked up from the batched per-corpus map and the
73  /// parent corpus name (for sandboxes; `None` otherwise — resolve it from the same `Corpus::all`
74  /// listing so there is no extra query). Public so the `cortex` CLI can emit the identical shape
75  /// (web ↔ CLI ↔ agent parity).
76  pub fn build(corpus: Corpus, document_count: i64, parent: Option<String>) -> Self {
77    CorpusDto {
78      public_id: corpus.public_id.to_string(),
79      name: corpus.name,
80      path: corpus.path,
81      description: corpus.description,
82      complex: corpus.complex,
83      document_count,
84      parent,
85    }
86  }
87}
88
89/// Lists all registered corpora (the agent twin of the overview screen).
90#[openapi(tag = "Corpora")]
91#[get("/api/corpora")]
92pub fn api_corpora(pool: &State<DbPool>) -> Json<Vec<CorpusDto>> {
93  let Ok(mut connection) = pool.get() else {
94    return Json(Vec::new());
95  };
96  let corpora = Corpus::all(&mut connection).unwrap_or_default();
97  let counts = Corpus::document_counts(&mut connection);
98  // id → name over the loaded listing, so a sandbox's parent name resolves with no extra query.
99  let names_by_id: HashMap<i32, String> = corpora.iter().map(|c| (c.id, c.name.clone())).collect();
100  Json(
101    corpora
102      .into_iter()
103      .map(|corpus| {
104        let count = counts.get(&corpus.id).copied().unwrap_or(0);
105        let parent = corpus
106          .parent_corpus_id
107          .and_then(|pid| names_by_id.get(&pid).cloned());
108        CorpusDto::build(corpus, count, parent)
109      })
110      .collect(),
111  )
112}
113
114/// Per-service status counts within a corpus (mirrors the progress report).
115#[derive(Debug, Serialize, JsonSchema)]
116pub struct ServiceStatusDto {
117  /// Service name.
118  pub name: String,
119  /// Service version.
120  pub version: f32,
121  /// Total valid tasks (excludes invalids).
122  pub total: i64,
123  /// Completed with no notable problems.
124  pub no_problem: i64,
125  /// Completed with warnings.
126  pub warning: i64,
127  /// Completed with errors.
128  pub error: i64,
129  /// Fatal failures.
130  pub fatal: i64,
131  /// Invalid tasks (excluded from totals).
132  pub invalid: i64,
133  /// Queued / not-yet-processed tasks.
134  pub todo: i64,
135}
136
137/// Provenance of a **sandbox** corpus: the parent it was carved from and the carve predicate. Only
138/// present on sandbox corpora (`null` for ordinary corpora).
139#[derive(Debug, Serialize, JsonSchema)]
140pub struct SandboxProvenanceDto {
141  /// Name of the parent corpus this sandbox was carved from.
142  pub parent: String,
143  /// Compact human-readable summary of the carve filter (e.g. `severity=warning, entry~2506.`).
144  pub filter: String,
145  /// The structured selection predicate (`service_id`, `severity`, `category`, `what`, `entry`,
146  /// `max_entries`) — the same JSON stored on the corpus.
147  pub selection: Option<serde_json::Value>,
148}
149
150/// A corpus with its activated services and their status counts.
151#[derive(Debug, Serialize, JsonSchema)]
152pub struct CorpusDetailDto {
153  /// Corpus name (external handle).
154  pub name: String,
155  /// Filesystem path to the corpus root.
156  pub path: String,
157  /// Human-readable description.
158  pub description: String,
159  /// Whether documents are multi-file.
160  pub complex: bool,
161  /// Sandbox provenance (parent + carve filter), or `null` if this is an ordinary corpus.
162  pub sandbox: Option<SandboxProvenanceDto>,
163  /// Services activated on this corpus, with status counts.
164  pub services: Vec<ServiceStatusDto>,
165}
166
167/// Inspects a single corpus: its activated services and per-service status counts.
168#[openapi(tag = "Corpora")]
169#[get("/api/corpora/<name>")]
170pub fn api_corpus(name: &str, pool: &State<DbPool>) -> Result<Json<CorpusDetailDto>, Status> {
171  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
172  let corpus = Corpus::find_by_name(name, &mut connection).map_err(|_| Status::NotFound)?;
173  let services = corpus.select_services(&mut connection).unwrap_or_default();
174  let mut service_status = Vec::new();
175  for service in services {
176    let report = progress_report(&mut connection, corpus.id, service.id);
177    let count = |key: &str| report.get(key).copied().unwrap_or(0.0) as i64;
178    service_status.push(ServiceStatusDto {
179      name: service.name,
180      version: service.version,
181      total: count("total"),
182      no_problem: count("no_problem"),
183      warning: count("warning"),
184      error: count("error"),
185      fatal: count("fatal"),
186      invalid: count("invalid"),
187      todo: count("todo"),
188    });
189  }
190  let sandbox =
191    sandbox_provenance(&corpus, &mut connection).map(|(parent, filter)| SandboxProvenanceDto {
192      parent,
193      filter,
194      selection: corpus.selection.clone(),
195    });
196  Ok(Json(CorpusDetailDto {
197    name: corpus.name,
198    path: corpus.path,
199    description: corpus.description,
200    complex: corpus.complex,
201    sandbox,
202    services: service_status,
203  }))
204}
205
206/// A corpus's sandbox provenance — the parent name + a human-readable carve-filter summary — or
207/// `None` if it is an ordinary (non-sandbox) corpus. Shared by the agent detail API and the human
208/// corpus page so both surfaces show identical provenance.
209fn sandbox_provenance(corpus: &Corpus, connection: &mut PgConnection) -> Option<(String, String)> {
210  let parent_id = corpus.parent_corpus_id?;
211  let parent = Corpus::find_by_id(parent_id, connection).ok()?;
212  let filter = corpus
213    .selection
214    .as_ref()
215    .and_then(|value| serde_json::from_value::<SandboxSelection>(value.clone()).ok())
216    .map(|selection| selection.filter_summary())
217    .unwrap_or_default();
218  Some((parent.name, filter))
219}
220
221/// Request body for registering and importing a corpus.
222#[derive(Debug, Deserialize, schemars::JsonSchema)]
223pub struct ImportRequest {
224  /// Corpus name (external handle).
225  pub name: String,
226  /// Filesystem path to the corpus root.
227  pub path: String,
228  /// Whether documents are multi-file (complex).
229  pub complex: bool,
230  /// Optional human-readable description.
231  pub description: Option<String>,
232}
233
234/// Registers a corpus and starts an in-process import job; returns `202 Accepted` + the job handle.
235/// Agents and humans poll `GET /api/jobs/<uuid>` (or the progress page) for completion.
236/// **Token-gated** via the [`Actor`] guard (creating a corpus + a filesystem import job is a
237/// consequential write); `401` without a valid token, `409` if the corpus name already exists,
238/// `422` if the `path` is not a readable directory on the server.
239#[rocket_okapi::openapi(tag = "Corpora")]
240#[post("/api/corpora", format = "json", data = "<request>")]
241pub fn import_corpus(
242  request: Json<ImportRequest>,
243  actor: Actor,
244  pool: &State<DbPool>,
245  database_url: &State<DatabaseUrl>,
246) -> Result<(Status, Json<JobDto>), Status> {
247  let request = request.into_inner();
248  let job_uuid = start_import(
249    pool,
250    &database_url.0,
251    &actor.owner,
252    request.name,
253    request.path,
254    request.complex,
255    request.description.unwrap_or_default(),
256  )?;
257  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
258  let job = jobs::find_job(&mut connection, job_uuid).ok_or(Status::InternalServerError)?;
259  Ok((Status::Accepted, Json(JobDto::from(job))))
260}
261
262/// Registers a corpus and spawns its import job, returning the job uuid. The shared core of the
263/// agent endpoint and the human form. `409` if the name already exists; `422` if the `path` is not
264/// a readable directory on the server (pre-flighted so a doomed import is never started).
265#[allow(clippy::too_many_arguments)]
266fn start_import(
267  pool: &DbPool,
268  database_url: &str,
269  actor: &str,
270  name: String,
271  path: String,
272  complex: bool,
273  description: String,
274) -> Result<Uuid, Status> {
275  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
276  // Reject a blank name on the agent path too (the HTML form enforces `required`, but a raw
277  // `POST /api/corpora` bypasses that) — an empty handle is unreachable by every name-keyed route.
278  if name.trim().is_empty() {
279    return Err(Status::BadRequest);
280  }
281  if Corpus::find_by_name(&name, &mut connection).is_ok() {
282    return Err(Status::Conflict);
283  }
284  // Pre-flight the source path (the in-process import reads it): reject a path that doesn't exist
285  // or isn't a readable directory, so the admin/agent gets immediate feedback instead of a
286  // registered corpus whose import silently finds nothing. `422` distinguishes it from the `409`
287  // name clash.
288  if !std::fs::metadata(path.trim_end())
289    .map(|meta| meta.is_dir())
290    .unwrap_or(false)
291  {
292    return Err(Status::UnprocessableEntity);
293  }
294  NewCorpus {
295    name: name.clone(),
296    path: path.clone(),
297    complex,
298    description,
299  }
300  .create(&mut connection)
301  .map_err(|_| Status::InternalServerError)?;
302  let corpus =
303    Corpus::find_by_name(&name, &mut connection).map_err(|_| Status::InternalServerError)?;
304  drop(connection);
305
306  let database_url = database_url.to_string();
307  let params = serde_json::json!({ "name": name, "path": path });
308  jobs::spawn_job(
309    pool.clone(),
310    "corpus_import",
311    actor,
312    params,
313    move |progress| run_import(&database_url, corpus, progress),
314  )
315  .map_err(|_| Status::InternalServerError)
316}
317
318/// Fields of the human "Add a corpus" form on the admin dashboard.
319#[derive(FromForm)]
320pub struct ImportForm {
321  /// Corpus name (external handle).
322  pub name: String,
323  /// Filesystem path to the corpus root (server-side).
324  pub path: String,
325  /// Whether documents are multi-file (complex).
326  pub complex: bool,
327  /// Optional description.
328  pub description: Option<String>,
329}
330
331/// The human twin of [`import_corpus`]: the admin dashboard's "Add a corpus" form. **Gated by the
332/// signed-in [`AdminSession`] cookie** (no token typed in the form — an anonymous browser is
333/// redirected to sign-in); registers + imports the corpus off the request path and redirects to
334/// `/jobs`. `409` if the name is taken.
335// The Err variant is a re-rendered form `Template` (the friendly-error path), which is chunky —
336// fine for a one-shot request handler.
337#[allow(clippy::result_large_err)]
338#[post("/corpus/import", data = "<form>")]
339pub fn import_corpus_human(
340  form: Form<ImportForm>,
341  session: Option<AdminSession>,
342  pool: &State<DbPool>,
343  database_url: &State<DatabaseUrl>,
344) -> Result<Redirect, Template> {
345  let Some(session) = session else {
346    return Ok(Redirect::to("/admin/login"));
347  };
348  let form = form.into_inner();
349  let description = form.description.clone().unwrap_or_default();
350  match start_import(
351    pool,
352    &database_url.0,
353    &session.owner,
354    form.name.clone(),
355    form.path.clone(),
356    form.complex,
357    description.clone(),
358  ) {
359    Ok(uuid) => Ok(Redirect::to(format!("/jobs/{uuid}"))),
360    // A failed submit re-renders the form with a friendly message + the values preserved, rather
361    // than a bare error page that loses the admin's input.
362    Err(status) => {
363      let message = match status.code {
364        409 => format!(
365          "A corpus named “{}” already exists — choose a different name.",
366          form.name
367        ),
368        422 => format!(
369          "“{}” is not a readable directory on the server — check the path.",
370          form.path
371        ),
372        503 => "The database is temporarily unavailable — please try again.".to_string(),
373        _ => "Could not register the corpus — check the values and try again.".to_string(),
374      };
375      Err(render_corpora_new(
376        Some(&message),
377        &form.name,
378        &form.path,
379        &description,
380        form.complex,
381      ))
382    },
383  }
384}
385
386/// The body of a `corpus_import` job: run the importer in-process against `corpus`, reporting
387/// progress, and return the number of import-service tasks created.
388fn run_import(database_url: &str, corpus: Corpus, progress: &JobProgress) -> Result<Value, String> {
389  let corpus_id = corpus.id;
390  let mut importer = Importer {
391    corpus,
392    backend: from_address(database_url),
393    cwd: Importer::cwd(),
394    active_prefixes: HashSet::new(),
395  };
396  progress.step(0, None, "importing corpus");
397  importer.process().map_err(|error| error.to_string())?;
398  let imported = count_service_tasks(&mut importer.backend.connection, corpus_id, 2);
399  progress.step(imported, Some(imported), "import complete");
400  Ok(serde_json::json!({ "imported": imported }))
401}
402
403/// Counts the tasks registered for a `(corpus, service)` pair.
404fn count_service_tasks(connection: &mut PgConnection, corpus: i32, service: i32) -> i32 {
405  use crate::schema::tasks::dsl::{corpus_id, service_id, tasks};
406  use diesel::prelude::*;
407  tasks
408    .filter(corpus_id.eq(corpus))
409    .filter(service_id.eq(service))
410    .count()
411    .get_result::<i64>(connection)
412    .unwrap_or(0) as i32
413}
414
415/// Request body for exporting a corpus/service's converted HTML into ZIP archives
416/// ([`export_dataset`]). Mirrors the `cortex export-dataset` CLI flags.
417#[derive(Debug, Deserialize, JsonSchema)]
418pub struct ExportRequest {
419  /// Server-side output directory for the archives + the `<corpus>-manifest.json` sidecar (created
420  /// if missing).
421  pub out: String,
422  /// Bucketing: `month` (one archive per year-month) or `severity` (one per severity). Defaults to
423  /// `month`.
424  #[serde(default)]
425  pub group_by: Option<String>,
426  /// Severity keys to include (canonical: `no_problem` | `warning` | `error` | `fatal` |
427  /// `invalid`). Defaults to `no_problem,warning,error` (matching the CLI).
428  #[serde(default)]
429  pub severities: Option<Vec<String>>,
430  /// Optional per-archive size cap in **MB**: when set, each month/severity bucket is split into
431  /// numbered chunks `<corpus>-<key>-NNN.zip` once it exceeds this many MB of (uncompressed) HTML
432  /// — the published `.zip` is smaller. Omit for one archive per bucket (no size limit).
433  #[serde(default)]
434  pub max_archive_mb: Option<u64>,
435}
436
437/// The default severity set when a caller omits `severities` — kept identical to the
438/// `cortex export-dataset` CLI default so all three surfaces export the same slice by default.
439fn default_export_severities() -> Vec<String> {
440  vec![
441    "no_problem".to_string(),
442    "warning".to_string(),
443    "error".to_string(),
444  ]
445}
446
447/// Exports a corpus/service's already-converted HTML into ZIP archives off the shared filesystem as
448/// an in-process **background job** (no conversion is run); returns `202 Accepted` + the job
449/// handle, which agents and humans poll via `GET /api/jobs/<uuid>`. The agent twin of `cortex
450/// export-dataset` (and the future web form), over the same [`export_html_dataset`] core.
451/// **Token-gated** via the [`Actor`] guard (it reads `/data` and writes archives server-side);
452/// `401` without a valid token, `404` if the corpus or service is unknown, `422` for an invalid
453/// `group_by` or severity key (pre-flighted so a doomed export never starts).
454#[openapi(tag = "Corpora")]
455#[post(
456  "/api/corpora/<corpus>/services/<service>/export-dataset",
457  format = "json",
458  data = "<request>"
459)]
460pub fn export_dataset(
461  corpus: &str,
462  service: &str,
463  request: Json<ExportRequest>,
464  actor: Actor,
465  pool: &State<DbPool>,
466  database_url: &State<DatabaseUrl>,
467) -> Result<(Status, Json<JobDto>), Status> {
468  let job_uuid = start_export(
469    pool,
470    &database_url.0,
471    &actor.owner,
472    corpus,
473    service,
474    request.into_inner(),
475  )?;
476  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
477  let job = jobs::find_job(&mut connection, job_uuid).ok_or(Status::InternalServerError)?;
478  Ok((Status::Accepted, Json(JobDto::from(job))))
479}
480
481/// Validates the export request, resolves the corpus/service, and spawns the `dataset_export`
482/// background job — the shared core of the agent endpoint and the (future) human form. `404` if the
483/// corpus/service is unknown; `422` for a bad `group_by`/severity (so the caller gets immediate
484/// feedback instead of a job that fails late).
485fn start_export(
486  pool: &DbPool,
487  database_url: &str,
488  actor: &str,
489  corpus_name: &str,
490  service_name: &str,
491  request: ExportRequest,
492) -> Result<Uuid, Status> {
493  // Pre-flight the knobs (mirrors the CLI's exit-2 validation) before any DB work or job spawn.
494  let group_by_key = request.group_by.unwrap_or_else(|| "month".to_string());
495  let group_by = GroupBy::from_key(&group_by_key).ok_or(Status::UnprocessableEntity)?;
496  let severity_keys = request.severities.unwrap_or_else(default_export_severities);
497  let severities = severity_keys
498    .iter()
499    .map(|key| TaskStatus::from_key(key))
500    .collect::<Option<Vec<_>>>()
501    .ok_or(Status::UnprocessableEntity)?;
502  if severities.is_empty() {
503    return Err(Status::UnprocessableEntity);
504  }
505
506  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
507  let corpus = Corpus::find_by_name(&corpus_name.to_lowercase(), &mut connection)
508    .map_err(|_| Status::NotFound)?;
509  let service = Service::find_by_name(&service_name.to_lowercase(), &mut connection)
510    .map_err(|_| Status::NotFound)?;
511  drop(connection);
512
513  let out = PathBuf::from(request.out);
514  let max_archive_mb = request.max_archive_mb;
515  let database_url = database_url.to_string();
516  let params = serde_json::json!({
517    "corpus": corpus.name,
518    "service": service.name,
519    "out": out.display().to_string(),
520    "group_by": group_by_key,
521    "severities": severity_keys,
522    "max_archive_mb": max_archive_mb,
523  });
524  jobs::spawn_job(
525    pool.clone(),
526    "dataset_export",
527    actor,
528    params,
529    move |progress| {
530      run_export(
531        &database_url,
532        corpus,
533        service,
534        severities,
535        group_by,
536        max_archive_mb,
537        out,
538        progress,
539      )
540    },
541  )
542  .map_err(|_| Status::InternalServerError)
543}
544
545/// The body of a `dataset_export` job: stream the corpus/service HTML into archives, threading the
546/// exporter's milestone lines through the job's progress feed, and return the
547/// [`DatasetExportOutcome`](crate::backend::DatasetExportOutcome) (archives + tallies) as the job
548/// result.
549#[allow(clippy::too_many_arguments)] // mirrors export_html_dataset's knobs; a struct is ceremony here
550fn run_export(
551  database_url: &str,
552  corpus: Corpus,
553  service: Service,
554  severities: Vec<TaskStatus>,
555  group_by: GroupBy,
556  max_archive_mb: Option<u64>,
557  out: PathBuf,
558  progress: &JobProgress,
559) -> Result<Value, String> {
560  let mut backend = from_address(database_url);
561  let outcome = export_html_dataset(
562    &mut backend.connection,
563    &corpus,
564    &service,
565    &severities,
566    group_by,
567    max_archive_mb,
568    &out,
569    |line| progress.step(0, None, line),
570  )?;
571  let total = outcome.total_entries as i32;
572  progress.step(total, Some(total), "export complete");
573  serde_json::to_value(&outcome).map_err(|error| error.to_string())
574}
575
576/// Fields of the human "Export dataset" form (the web twin of [`export_dataset`]).
577#[derive(FromForm)]
578pub struct ExportForm {
579  /// Server-side output directory for the archives.
580  pub out: String,
581  /// Bucketing: `month` or `severity`.
582  pub group_by: String,
583  /// The checked severity keys (the multi-value checkbox group; empty = none selected).
584  pub severities: Vec<String>,
585  /// Optional per-archive size cap in MB (blank = no limit). A string so a blank field parses to
586  /// "no limit" instead of a form error.
587  pub max_archive_mb: Option<String>,
588}
589
590/// Renders the "Export dataset" form for a `(corpus, service)`. Shared by the GET screen and the
591/// POST error path, so a failed submit re-renders with a friendly `error` and every typed value
592/// preserved (the output path, grouping, and which severities were checked) instead of a bare error
593/// page. Admin-only page (`is_admin`).
594fn render_export_form(
595  corpus: &str,
596  service: &str,
597  error: Option<&str>,
598  out: &str,
599  group_by: &str,
600  selected: &[String],
601  max_archive_mb: &str,
602) -> Template {
603  let mut global = HashMap::new();
604  global.insert("title".to_string(), "Export dataset".to_string());
605  global.insert(
606    "description".to_string(),
607    format!("Bundle {corpus} / {service} converted HTML into ZIP archives"),
608  );
609  global.insert("corpus_name".to_string(), corpus.to_string());
610  global.insert("service_name".to_string(), service.to_string());
611  global.insert("out".to_string(), out.to_string());
612  global.insert("group_by".to_string(), group_by.to_string());
613  global.insert("max_archive_mb".to_string(), max_archive_mb.to_string());
614  if let Some(message) = error {
615    global.insert("error".to_string(), message.to_string());
616  }
617  // Per-severity checked flags (the form preserves the admin's selection across a re-render). The
618  // key set matches the canonical `TaskStatus` severities the exporter accepts.
619  for key in ["no_problem", "warning", "error", "fatal", "invalid"] {
620    let checked = selected.iter().any(|s| s == key);
621    global.insert(format!("sev_{key}_checked"), checked.to_string());
622  }
623  let mut context = TemplateContext {
624    global,
625    is_admin: true,
626    ..TemplateContext::default()
627  };
628  decorate_uri_encodings(&mut context);
629  Template::render("export-dataset", context)
630}
631
632/// The "Export dataset" screen (`GET /export/<c>/<s>`): the human form that drives the same export
633/// as `cortex export-dataset` and the agent [`export_dataset`]. **Signed-in admins only**
634/// (anonymous → sign-in). Pre-fills a default output path + the CLI's default severity set. A
635/// sibling top-level path (like `/runs/<c>/<s>`, `/history/<c>/<s>`) so it never collides with the
636/// report ladder's `/corpus/<c>/<s>/<severity>` rung.
637#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
638#[get("/export/<corpus>/<service>")]
639pub fn export_dataset_page(
640  corpus: &str,
641  service: &str,
642  session: Option<AdminSession>,
643  return_to: ReturnTo,
644) -> Result<Template, AdminReject> {
645  require_admin_to(session, &return_to)?;
646  let default_out = format!("/data/datasets/{corpus}-{service}");
647  Ok(render_export_form(
648    corpus,
649    service,
650    None,
651    &default_out,
652    "month",
653    &default_export_severities(),
654    "",
655  ))
656}
657
658/// The human twin of [`export_dataset`]: the "Export dataset" form post. **Gated by the signed-in
659/// [`AdminSession`] cookie** (anonymous → sign-in); spawns the background export via the shared
660/// [`start_export`] core and redirects to the job's live-progress page. A failed submit re-renders
661/// the form with a friendly message + the values preserved (404 unknown corpus/service, 422 bad
662/// grouping / no severity).
663// The Err variant is a re-rendered form `Template` (the friendly-error path), which is chunky —
664// fine for a one-shot request handler.
665#[allow(clippy::result_large_err)]
666#[post("/export/<corpus>/<service>", data = "<form>")]
667pub fn export_dataset_human(
668  corpus: &str,
669  service: &str,
670  form: Form<ExportForm>,
671  session: Option<AdminSession>,
672  pool: &State<DbPool>,
673  database_url: &State<DatabaseUrl>,
674) -> Result<Redirect, Template> {
675  let Some(session) = session else {
676    return Ok(Redirect::to("/admin/login"));
677  };
678  let form = form.into_inner();
679  let request = ExportRequest {
680    out: form.out.clone(),
681    group_by: Some(form.group_by.clone()),
682    severities: Some(form.severities.clone()),
683    // Blank or non-numeric → no limit (a number input keeps it numeric in practice).
684    max_archive_mb: form
685      .max_archive_mb
686      .as_deref()
687      .map(str::trim)
688      .filter(|s| !s.is_empty())
689      .and_then(|s| s.parse::<u64>().ok()),
690  };
691  match start_export(
692    pool,
693    &database_url.0,
694    &session.owner,
695    corpus,
696    service,
697    request,
698  ) {
699    Ok(uuid) => Ok(Redirect::to(format!("/jobs/{uuid}"))),
700    Err(status) => {
701      let message = match status.code {
702        404 => format!("Corpus “{corpus}” / service “{service}” not found — check the names."),
703        422 => "Pick a grouping and at least one valid severity.".to_string(),
704        503 => "The database is temporarily unavailable — please try again.".to_string(),
705        _ => "Could not start the export — check the values and try again.".to_string(),
706      };
707      Err(render_export_form(
708        corpus,
709        service,
710        Some(&message),
711        &form.out,
712        &form.group_by,
713        &form.severities,
714        form.max_archive_mb.as_deref().unwrap_or(""),
715      ))
716    },
717  }
718}
719
720/// Request body for carving a **sandbox** corpus out of a parent by a filter (Arm 5). Task-status
721/// and message-severity are independent, intersecting dimensions (Model C); `category`/`what`
722/// narrow the message filter, mirroring the report drill-down.
723#[derive(Debug, Deserialize, JsonSchema)]
724pub struct SandboxRequest {
725  /// Name for the new sandbox corpus (its external handle; must be unique).
726  pub name: String,
727  /// The service whose conversion results are filtered.
728  pub service_id: i32,
729  /// Optional **task-status** filter (`todo` | `no_problem` | `warning` | `error` | `fatal` |
730  /// `invalid`).
731  #[serde(default)]
732  pub status: Option<String>,
733  /// Optional **message-severity** filter (`info` | `warning` | `error` | `fatal` | `invalid`) —
734  /// matches tasks that emitted such a message, at any status. `category`/`what` narrow within it.
735  #[serde(default)]
736  pub message_severity: Option<String>,
737  /// Optional message-category narrowing (needs `message_severity`).
738  #[serde(default)]
739  pub category: Option<String>,
740  /// Optional `what` narrowing within the category (needs `category`).
741  #[serde(default)]
742  pub what: Option<String>,
743  /// Optional substring the parent `entry` path must contain (`entry LIKE '%…%'`, e.g. `/2506/`
744  /// for one arXiv month). Empty/absent = no narrowing.
745  #[serde(default)]
746  pub entry: Option<String>,
747  /// Optional hard cap on the number of entries captured (the first `n` by `entry` order). Absent
748  /// or non-positive = no cap.
749  #[serde(default)]
750  pub max_entries: Option<i64>,
751}
752
753impl From<&SandboxRequest> for SandboxSelection {
754  fn from(request: &SandboxRequest) -> Self {
755    SandboxSelection {
756      service_id: request.service_id,
757      status: request.status.clone(),
758      message_severity: request.message_severity.clone(),
759      category: request.category.clone(),
760      what: request.what.clone(),
761      entry: request.entry.clone(),
762      max_entries: request.max_entries,
763      severity: None,
764    }
765  }
766}
767
768/// Carves a **sandbox corpus** from `<parent>` by a message-condition filter and starts the job
769/// that populates it; returns `202 Accepted` + the job handle to poll. **Token-gated** via the
770/// [`Actor`] guard; `401` without a valid token, `404` if the parent is unknown, `409` if the
771/// sandbox name is taken. The sandbox is a first-class corpus an agent can then run/rerun to
772/// iterate a campaign.
773#[rocket_okapi::openapi(tag = "Corpora")]
774#[post("/api/corpora/<parent>/sandbox", format = "json", data = "<request>")]
775pub fn create_sandbox_corpus(
776  parent: &str,
777  request: Json<SandboxRequest>,
778  actor: Actor,
779  pool: &State<DbPool>,
780  database_url: &State<DatabaseUrl>,
781) -> Result<(Status, Json<JobDto>), Status> {
782  let request = request.into_inner();
783  let job_uuid = start_sandbox(pool, &database_url.0, &actor.owner, parent, &request)?;
784  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
785  let job = jobs::find_job(&mut connection, job_uuid).ok_or(Status::InternalServerError)?;
786  Ok((Status::Accepted, Json(JobDto::from(job))))
787}
788
789/// Resolves the parent (`404`), rejects a taken sandbox name (`409`), and spawns the
790/// `corpus_sandbox` job. The shared core of the agent endpoint and the human form.
791fn start_sandbox(
792  pool: &DbPool,
793  database_url: &str,
794  actor: &str,
795  parent: &str,
796  request: &SandboxRequest,
797) -> Result<Uuid, Status> {
798  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
799  let parent_corpus =
800    Corpus::find_by_name(parent, &mut connection).map_err(|_| Status::NotFound)?;
801  // A blank sandbox name is unreachable junk — reject it (the web form enforces `required`).
802  if request.name.trim().is_empty() {
803    return Err(Status::BadRequest);
804  }
805  if Corpus::find_by_name(&request.name, &mut connection).is_ok() {
806    return Err(Status::Conflict);
807  }
808  drop(connection);
809
810  let database_url = database_url.to_string();
811  let name = request.name.clone();
812  let selection = SandboxSelection::from(request);
813  // Pre-flight the selection (mirrors import/export) so a bad filter is an immediate 422, not a
814  // `202` that an agent has to poll only to find the job failed.
815  selection
816    .validate()
817    .map_err(|_| Status::UnprocessableEntity)?;
818  let params = serde_json::json!({
819    "parent": parent, "name": name, "selection": serde_json::to_value(&selection).ok(),
820  });
821  jobs::spawn_job(
822    pool.clone(),
823    "corpus_sandbox",
824    actor,
825    params,
826    move |progress| run_sandbox(&database_url, parent_corpus, name, selection, progress),
827  )
828  .map_err(|_| Status::InternalServerError)
829}
830
831/// Fields of the human "Create a sandbox" form on the corpus page.
832#[derive(FromForm)]
833pub struct SandboxForm {
834  /// New sandbox corpus name.
835  pub name: String,
836  /// Service whose results are filtered.
837  pub service_id: i32,
838  /// Optional task-status filter (empty string = none).
839  pub status: Option<String>,
840  /// Optional message-severity filter (empty string = none).
841  pub message_severity: Option<String>,
842  /// Optional category narrowing (empty string = none).
843  pub category: Option<String>,
844  /// Optional `what` narrowing (empty string = none).
845  pub what: Option<String>,
846  /// Optional `entry` substring filter (empty string = none).
847  pub entry: Option<String>,
848  /// Optional hard cap on captured entries (empty/zero = none).
849  pub max_entries: Option<i64>,
850}
851
852/// The human twin of [`create_sandbox_corpus`]: the corpus page's "Create a sandbox" form. **Gated
853/// by the signed-in [`AdminSession`] cookie**; carves the sandbox off the request path and
854/// redirects to `/jobs`. `404` unknown parent, `409` name taken.
855#[post("/corpus/<parent>/sandbox", data = "<form>")]
856pub fn create_sandbox_human(
857  parent: &str,
858  form: Form<SandboxForm>,
859  session: Option<AdminSession>,
860  pool: &State<DbPool>,
861  database_url: &State<DatabaseUrl>,
862) -> Result<Redirect, Status> {
863  let Some(session) = session else {
864    return Ok(Redirect::to("/admin/login"));
865  };
866  let form = form.into_inner();
867  // Treat empty optional inputs as "no narrowing".
868  let blank_to_none = |value: Option<String>| value.filter(|text| !text.trim().is_empty());
869  let request = SandboxRequest {
870    name: form.name,
871    service_id: form.service_id,
872    status: blank_to_none(form.status),
873    message_severity: blank_to_none(form.message_severity),
874    category: blank_to_none(form.category),
875    what: blank_to_none(form.what),
876    entry: blank_to_none(form.entry),
877    // A non-positive cap is treated as "no cap" (create_sandbox ignores it); keep the raw value.
878    max_entries: form.max_entries.filter(|n| *n > 0),
879  };
880  // Pre-validate the Model C filter for a friendly inline error — a bad combo (a category without a
881  // message severity, no dimension at all, …) would otherwise be a bare 422 from start_sandbox. The
882  // job path re-checks, so this never lets an invalid carve through.
883  if let Err(reason) = SandboxSelection::from(&request).validate() {
884    return Ok(Redirect::to(format!(
885      "/corpus/{}?sandbox_invalid={}",
886      uri_escape(Some(parent.to_string())).unwrap_or_default(),
887      uri_escape(Some(reason)).unwrap_or_default()
888    )));
889  }
890  match start_sandbox(pool, &database_url.0, &session.owner, parent, &request) {
891    Ok(uuid) => Ok(Redirect::to(format!("/jobs/{uuid}"))),
892    // A name collision re-shows the corpus page with a friendly flash instead of a bare 409 page —
893    // the same courtesy the import form gives. The agent twin keeps its 409 status.
894    Err(status) if status == Status::Conflict => Ok(Redirect::to(format!(
895      "/corpus/{}?sandbox_taken={}",
896      uri_escape(Some(parent.to_string())).unwrap_or_default(),
897      uri_escape(Some(request.name)).unwrap_or_default()
898    ))),
899    Err(status) => Err(status),
900  }
901}
902
903/// The body of a `corpus_sandbox` job: carve the sandbox in-process and report the captured-entry
904/// count. Returns `{ sandbox, entries }`.
905fn run_sandbox(
906  database_url: &str,
907  parent: Corpus,
908  name: String,
909  selection: SandboxSelection,
910  progress: &JobProgress,
911) -> Result<Value, String> {
912  progress.step(0, None, &format!("carving sandbox '{name}'"));
913  let mut backend = from_address(database_url);
914  let outcome = create_sandbox(&mut backend.connection, &parent, &name, &selection)
915    .map_err(|error| error.to_string())?;
916  let captured = outcome.entry_count as i32;
917  progress.step(captured, Some(captured), "sandbox created");
918  Ok(serde_json::json!({ "sandbox": outcome.sandbox.name, "entries": outcome.entry_count }))
919}
920
921/// Extends an existing corpus with newly-arrived entries; starts an in-process job and returns
922/// `202 Accepted` + the job handle. **Token-gated** via the [`Actor`] guard; `401` without a valid
923/// token, `404` if the corpus is unknown.
924#[rocket_okapi::openapi(tag = "Corpora")]
925#[post("/api/corpora/<name>/extend")]
926pub fn extend_corpus(
927  name: &str,
928  actor: Actor,
929  pool: &State<DbPool>,
930  database_url: &State<DatabaseUrl>,
931) -> Result<(Status, Json<JobDto>), Status> {
932  let job_uuid = start_extend(pool, &database_url.0, &actor.owner, name)?;
933  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
934  let job = jobs::find_job(&mut connection, job_uuid).ok_or(Status::InternalServerError)?;
935  Ok((Status::Accepted, Json(JobDto::from(job))))
936}
937
938/// Spawns a corpus-extend job for an existing corpus (`404` if unknown), returning the job uuid.
939/// Shared by the agent endpoint and the human form.
940fn start_extend(
941  pool: &DbPool,
942  database_url: &str,
943  actor: &str,
944  name: &str,
945) -> Result<Uuid, Status> {
946  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
947  let corpus = Corpus::find_by_name(name, &mut connection).map_err(|_| Status::NotFound)?;
948  // Pre-flight the corpus source path (extend re-scans it for new entries). If the data mount is
949  // gone/unreadable, fail transparently with `422` instead of spawning a job that silently finds
950  // nothing (`glob` over a missing dir yields an empty set, not an error) and reports "0 new" — the
951  // same courtesy as import, so a vanished mount surfaces as an error rather than a quiet no-op.
952  if !std::fs::metadata(corpus.path.trim_end())
953    .map(|meta| meta.is_dir())
954    .unwrap_or(false)
955  {
956    return Err(Status::UnprocessableEntity);
957  }
958  drop(connection);
959  let database_url = database_url.to_string();
960  let params = serde_json::json!({ "name": name });
961  jobs::spawn_job(
962    pool.clone(),
963    "corpus_extend",
964    actor,
965    params,
966    move |progress| run_extend(&database_url, corpus, progress),
967  )
968  .map_err(|_| Status::InternalServerError)
969}
970
971/// The human twin of [`extend_corpus`]: the corpus screen's "Re-scan for new entries" button.
972/// **Gated by the signed-in [`AdminSession`] cookie** (anonymous → sign-in); spawns the extend job
973/// and redirects to `/jobs`. `404` if the corpus is unknown.
974#[post("/corpus/<name>/extend")]
975pub fn extend_corpus_human(
976  name: &str,
977  session: Option<AdminSession>,
978  pool: &State<DbPool>,
979  database_url: &State<DatabaseUrl>,
980) -> Result<Redirect, Status> {
981  let Some(session) = session else {
982    return Ok(Redirect::to("/admin/login"));
983  };
984  let uuid = start_extend(pool, &database_url.0, &session.owner, name)?;
985  Ok(Redirect::to(format!("/jobs/{uuid}")))
986}
987
988/// The body of a `corpus_extend` job: import newly-arrived entries and propagate them to the real
989/// (non-init/import) services, returning the resulting import-task count.
990fn run_extend(database_url: &str, corpus: Corpus, progress: &JobProgress) -> Result<Value, String> {
991  let corpus_id = corpus.id;
992  let mut importer = Importer {
993    corpus,
994    backend: from_address(database_url),
995    cwd: Importer::cwd(),
996    active_prefixes: HashSet::new(),
997  };
998  progress.step(0, None, "extending corpus");
999  importer
1000    .extend_corpus()
1001    .map_err(|error| error.to_string())?;
1002  let services = importer
1003    .corpus
1004    .select_services(&mut importer.backend.connection)
1005    .unwrap_or_default();
1006  for service in services.iter().filter(|service| service.id > 2) {
1007    importer
1008      .backend
1009      .extend_service(service, &importer.corpus)
1010      .map_err(|error| error.to_string())?;
1011  }
1012  let imported = count_service_tasks(&mut importer.backend.connection, corpus_id, 2);
1013  progress.step(imported, Some(imported), "extend complete");
1014  Ok(serde_json::json!({ "import_tasks": imported }))
1015}
1016
1017/// Activates a registered `service` on a `corpus`: creates a TODO task per imported document so the
1018/// workers begin converting it. **Token-gated** via the [`Actor`] guard (the run is attributed to
1019/// the authenticated actor); the work runs as a background job — poll `GET /api/jobs/<uuid>` for
1020/// the pending/done status. `401` without a valid token, `404` on an unknown corpus/service, `409`
1021/// if the service is **already registered** on the corpus (registration is idempotent-neutral — no
1022/// re-activation wipes existing results; use *extend*/*rerun* instead), `202` with the job handle
1023/// on success.
1024#[rocket_okapi::openapi(tag = "Corpora")]
1025#[post("/api/corpora/<corpus>/services/<service>")]
1026pub fn activate_service(
1027  corpus: &str,
1028  service: &str,
1029  actor: Actor,
1030  pool: &State<DbPool>,
1031  database_url: &State<DatabaseUrl>,
1032) -> Result<(Status, Json<JobDto>), Status> {
1033  let job_uuid = start_activate(pool, &database_url.0, &actor.owner, corpus, service)?;
1034  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1035  let job = jobs::find_job(&mut connection, job_uuid).ok_or(Status::InternalServerError)?;
1036  Ok((Status::Accepted, Json(JobDto::from(job))))
1037}
1038
1039/// Resolves the `(corpus, service)`, spawns the activation job (attributed to `actor`), and returns
1040/// the job uuid. `404` on an unknown corpus/service. Shared by the agent endpoint, the corpus
1041/// screen's human form, and the "Add a service" screen (which activates a freshly-defined service
1042/// on each checked corpus — see [`crate::frontend::services`]).
1043pub(crate) fn start_activate(
1044  pool: &DbPool,
1045  database_url: &str,
1046  actor: &str,
1047  corpus: &str,
1048  service: &str,
1049) -> Result<Uuid, Status> {
1050  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1051  let corpus_record =
1052    Corpus::find_by_name(corpus, &mut connection).map_err(|_| Status::NotFound)?;
1053  let service_record =
1054    Service::find_by_name(service, &mut connection).map_err(|_| Status::NotFound)?;
1055  // Idempotent-neutral: refuse to re-register an already-registered (service, corpus) pair. The
1056  // activation is destructive (`register_service` wipes & re-creates the pair's tasks + their
1057  // `log_*` rows), so a re-register would throw away completed results. Reject with `409` and spawn
1058  // **no** job — no action taken. (To add newly-imported documents use *extend*; to re-process use
1059  // *rerun*.) The backend `register_service` enforces the same invariant as defense-in-depth.
1060  if count_service_tasks(&mut connection, corpus_record.id, service_record.id) > 0 {
1061    return Err(Status::Conflict);
1062  }
1063  drop(connection);
1064  let database_url = database_url.to_string();
1065  let owner = actor.to_string();
1066  let params = serde_json::json!({ "corpus": corpus, "service": service });
1067  jobs::spawn_job(
1068    pool.clone(),
1069    "service_activate",
1070    actor,
1071    params,
1072    move |progress| {
1073      run_activate(
1074        &database_url,
1075        corpus_record,
1076        service_record,
1077        owner,
1078        progress,
1079      )
1080    },
1081  )
1082  .map_err(|_| Status::InternalServerError)
1083}
1084
1085/// Fields of the human "Activate a service" form on the corpus screen.
1086#[derive(FromForm)]
1087pub struct ActivateForm {
1088  /// The registered service to activate on this corpus.
1089  pub service: String,
1090}
1091
1092/// The human twin of [`activate_service`]: the corpus screen's "Activate a service" form. **Gated
1093/// by the signed-in [`AdminSession`] cookie** (anonymous → sign-in); spawns the activation job and
1094/// redirects to `/jobs`. `404` on an unknown corpus/service.
1095#[post("/corpus/<corpus>/activate", data = "<form>")]
1096pub fn activate_service_human(
1097  corpus: &str,
1098  form: Form<ActivateForm>,
1099  session: Option<AdminSession>,
1100  pool: &State<DbPool>,
1101  database_url: &State<DatabaseUrl>,
1102) -> Result<Redirect, Status> {
1103  let Some(session) = session else {
1104    return Ok(Redirect::to("/admin/login"));
1105  };
1106  let uuid = start_activate(pool, &database_url.0, &session.owner, corpus, &form.service)?;
1107  Ok(Redirect::to(format!("/jobs/{uuid}")))
1108}
1109
1110/// The body of a `service_activate` job: register `service` on `corpus` (creating a TODO task per
1111/// imported document), attributing the new run to `owner`, and return the task count created.
1112fn run_activate(
1113  database_url: &str,
1114  corpus: Corpus,
1115  service: Service,
1116  owner: String,
1117  progress: &JobProgress,
1118) -> Result<Value, String> {
1119  let (corpus_id, service_id) = (corpus.id, service.id);
1120  let corpus_name = corpus.name.clone();
1121  let service_name = service.name.clone();
1122  let mut backend = from_address(database_url);
1123  progress.step(
1124    0,
1125    None,
1126    &format!("registering {service_name} on {corpus_name}"),
1127  );
1128  backend
1129    .register_service(
1130      &service,
1131      &corpus,
1132      owner,
1133      format!("Activated service {service_name} on {corpus_name}"),
1134    )
1135    .map_err(|error| error.to_string())?;
1136  let activated = count_service_tasks(&mut backend.connection, corpus_id, service_id);
1137  progress.step(
1138    activated,
1139    Some(activated),
1140    &format!("registered {service_name} on {corpus_name} ({activated} tasks)"),
1141  );
1142  Ok(serde_json::json!({ "tasks": activated, "corpus": corpus_name, "service": service_name }))
1143}
1144
1145/// Deletes a corpus and all of its tasks and log messages. **Token-gated** via the [`Actor`] guard
1146/// (an unauthenticated wipe of a corpus must not be possible — `401` without a valid token) and
1147/// double-guarded: the caller must also echo the corpus name via `?confirm=<name>` to proceed
1148/// (prevents accidental wipes; the UI confirms the same way). Returns 204 on success, 400 if the
1149/// confirmation does not match, 404 if unknown.
1150#[rocket_okapi::openapi(tag = "Corpora")]
1151#[delete("/api/corpora/<name>?<confirm>")]
1152pub fn delete_corpus(
1153  name: &str,
1154  confirm: Option<&str>,
1155  actor: Actor,
1156  pool: &State<DbPool>,
1157) -> Status {
1158  if confirm != Some(name) {
1159    return Status::BadRequest;
1160  }
1161  let mut connection = match pool.get() {
1162    Ok(connection) => connection,
1163    Err(_) => return Status::ServiceUnavailable,
1164  };
1165  let corpus = match Corpus::find_by_name(name, &mut connection) {
1166    Ok(corpus) => corpus,
1167    Err(_) => return Status::NotFound,
1168  };
1169  match delete_corpus_cascade(&mut connection, corpus) {
1170    Ok(()) => {
1171      tracing::info!(actor = %actor.owner, corpus = name, "corpus deleted via API");
1172      Status::NoContent
1173    },
1174    Err(status) => status,
1175  }
1176}
1177
1178/// Fields of the human "Delete corpus" form: the name echoed as confirmation.
1179#[derive(FromForm)]
1180pub struct DeleteForm {
1181  /// Must equal the corpus name to confirm the destructive action.
1182  pub confirm: String,
1183}
1184
1185/// The human twin of [`delete_corpus`]: the corpus screen's "Delete corpus" form. **Gated by the
1186/// signed-in [`AdminSession`] cookie** (anonymous → sign-in) *and* confirmation-gated (the form
1187/// echoes the corpus name), then redirects to the overview. `400` if the confirmation doesn't
1188/// match, `404` if unknown.
1189#[post("/corpus/<name>/delete", data = "<form>")]
1190pub fn delete_corpus_human(
1191  name: &str,
1192  form: Form<DeleteForm>,
1193  session: Option<AdminSession>,
1194  pool: &State<DbPool>,
1195) -> Result<Redirect, Status> {
1196  if session.is_none() {
1197    return Ok(Redirect::to("/admin/login"));
1198  }
1199  if form.confirm != name {
1200    return Err(Status::BadRequest);
1201  }
1202  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1203  let corpus = Corpus::find_by_name(name, &mut connection).map_err(|_| Status::NotFound)?;
1204  delete_corpus_cascade(&mut connection, corpus)?;
1205  // Land on the overview with a confirmation flash (corpus names are URL-safe by construction).
1206  Ok(Redirect::to(format!("/?deleted={name}")))
1207}
1208
1209/// Deactivates (retires) a `service` from a `corpus`: deletes that pair's tasks + log messages (the
1210/// service definition and its work on other corpora are untouched — the symmetric counterpart of
1211/// [`activate_service`]). **Token-gated** via the [`Actor`] guard and confirmation-gated
1212/// (`?confirm=<service>`, echoing the service name). Returns `204` on success, `400` if the
1213/// confirmation doesn't match, `404` if the corpus or service is unknown.
1214#[rocket_okapi::openapi(tag = "Corpora")]
1215#[delete("/api/corpora/<corpus>/services/<service>?<confirm>")]
1216pub fn deactivate_service(
1217  corpus: &str,
1218  service: &str,
1219  confirm: Option<&str>,
1220  actor: Actor,
1221  pool: &State<DbPool>,
1222) -> Status {
1223  if confirm != Some(service) {
1224    return Status::BadRequest;
1225  }
1226  let mut connection = match pool.get() {
1227    Ok(connection) => connection,
1228    Err(_) => return Status::ServiceUnavailable,
1229  };
1230  let corpus_record = match Corpus::find_by_name(corpus, &mut connection) {
1231    Ok(corpus) => corpus,
1232    Err(_) => return Status::NotFound,
1233  };
1234  let service_record = match Service::find_by_name(service, &mut connection) {
1235    Ok(service) => service,
1236    Err(_) => return Status::NotFound,
1237  };
1238  // The magic `init` (1) / `import` (2) services are infrastructure — deactivating `import` would
1239  // wipe the corpus's document registry. Never deactivatable.
1240  if service_record.id <= IMPORT_SERVICE_ID {
1241    return Status::Forbidden;
1242  }
1243  match service_record.deactivate_from_corpus(&corpus_record, &mut connection) {
1244    Ok(_) => {
1245      tracing::info!(actor = %actor.owner, corpus, service, "service deactivated from corpus via API");
1246      Status::NoContent
1247    },
1248    Err(_) => Status::InternalServerError,
1249  }
1250}
1251
1252/// Fields of the human per-service "Deactivate" form: the service echoed as confirmation.
1253#[derive(FromForm)]
1254pub struct DeactivateForm {
1255  /// Must equal the service name to confirm the destructive action.
1256  pub confirm: String,
1257}
1258
1259/// The human twin of [`deactivate_service`]: the corpus screen's per-service "Deactivate" form.
1260/// **Gated by the signed-in [`AdminSession`] cookie** (anonymous → sign-in) *and*
1261/// confirmation-gated (echoes the service name), then redirects back to the corpus page. `400` if
1262/// the confirmation doesn't match, `404` if unknown.
1263#[post("/corpus/<corpus>/services/<service>/deactivate", data = "<form>")]
1264pub fn deactivate_service_human(
1265  corpus: &str,
1266  service: &str,
1267  form: Form<DeactivateForm>,
1268  session: Option<AdminSession>,
1269  pool: &State<DbPool>,
1270) -> Result<Redirect, Status> {
1271  if session.is_none() {
1272    return Ok(Redirect::to("/admin/login"));
1273  }
1274  if form.confirm != service {
1275    return Err(Status::BadRequest);
1276  }
1277  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1278  let corpus_record =
1279    Corpus::find_by_name(corpus, &mut connection).map_err(|_| Status::NotFound)?;
1280  let service_record =
1281    Service::find_by_name(service, &mut connection).map_err(|_| Status::NotFound)?;
1282  // Guard the magic init/import services (see [`deactivate_service`]).
1283  if service_record.id <= IMPORT_SERVICE_ID {
1284    return Err(Status::Forbidden);
1285  }
1286  service_record
1287    .deactivate_from_corpus(&corpus_record, &mut connection)
1288    .map_err(|_| Status::InternalServerError)?;
1289  Ok(Redirect::to(format!(
1290    "/corpus/{corpus}?deactivated={service}"
1291  )))
1292}
1293
1294/// Acknowledgement for a save-snapshot: the `(corpus, service)` frozen and how many per-task status
1295/// rows were appended to `historical_tasks`.
1296#[derive(Serialize, JsonSchema)]
1297pub struct SnapshotAckDto {
1298  /// Corpus the snapshot froze.
1299  pub corpus: String,
1300  /// Service the snapshot froze.
1301  pub service: String,
1302  /// The authenticated initiator the snapshot is attributed to.
1303  pub actor: String,
1304  /// Per-task status rows appended to `historical_tasks` (the size of the frozen snapshot).
1305  pub saved: usize,
1306}
1307
1308/// Freezes the current per-task statuses of a `(corpus, service)` into `historical_tasks` — the
1309/// agent twin of the report screen's "save snapshot" action (`POST /savetasks/...`), so an agent
1310/// can capture a baseline before a rerun campaign and later diff against it (`GET
1311/// /api/runs/.../tasks`). **Token-gated** via the [`Actor`] guard; the snapshot is **append-only**
1312/// (history stays immutable over the API — there is deliberately no snapshot delete/modify
1313/// endpoint; pruning is a human-admin operation, see [`crate::frontend::retention`]). `401` without
1314/// a valid token, `404` on an unknown corpus/service, `202` with the appended-row count on success.
1315/// Uses a fresh connection (not the request pool) since the snapshot is a single bulk `INSERT …
1316/// SELECT` over every task and shouldn't pin a pooled slot.
1317#[rocket_okapi::openapi(tag = "Management")]
1318#[post("/api/corpora/<corpus>/services/<service>/snapshot")]
1319pub fn snapshot_tasks(
1320  corpus: &str,
1321  service: &str,
1322  actor: Actor,
1323  database_url: &State<DatabaseUrl>,
1324) -> Result<(Status, Json<SnapshotAckDto>), Status> {
1325  let mut backend = from_address(&database_url.0);
1326  let corpus_record =
1327    Corpus::find_by_name(corpus, &mut backend.connection).map_err(|_| Status::NotFound)?;
1328  let service_record =
1329    Service::find_by_name(service, &mut backend.connection).map_err(|_| Status::NotFound)?;
1330  // Refuse a mid-run snapshot (in-progress tasks would resolve to a different status moments
1331  // later), matching the human `serve_savetasks` guard so both surfaces agree. `409` while any
1332  // task is TODO or Queued (status >= 0).
1333  let progress = progress_report(&mut backend.connection, corpus_record.id, service_record.id);
1334  let in_progress =
1335    progress.get("todo").copied().unwrap_or(0.0) + progress.get("queued").copied().unwrap_or(0.0);
1336  if in_progress > 0.0 {
1337    return Err(Status::Conflict);
1338  }
1339  let saved = backend
1340    .save_historical_tasks(&corpus_record, &service_record)
1341    .map_err(|_| Status::InternalServerError)?;
1342  tracing::info!(actor = %actor.owner, corpus, service, saved, "snapshot via API");
1343  Ok((
1344    Status::Accepted,
1345    Json(SnapshotAckDto {
1346      corpus: corpus.to_string(),
1347      service: service.to_string(),
1348      actor: actor.owner,
1349      saved,
1350    }),
1351  ))
1352}
1353
1354/// Removes a corpus's log messages (the `log_*` tables have no FK cascade), then its tasks and the
1355/// corpus row itself.
1356fn delete_corpus_cascade(connection: &mut PgConnection, corpus: Corpus) -> Result<(), Status> {
1357  // `Corpus::destroy` is the complete, transactional deletion primitive (log_* + tasks + corpus,
1358  // atomic + orphan-free); the handler only maps its error to an HTTP status.
1359  corpus
1360    .destroy(connection)
1361    .map(|_| ())
1362    .map_err(|_| Status::InternalServerError)
1363}
1364
1365// --- Human screens (HTML twins of the corpora API above) ---------------------------------------
1366//
1367// Relocated from `bin/frontend.rs` onto the library surface so they share the connection **pool**
1368// (no per-request `Backend::default()` fresh libpq connect) and are testable via `rocket::local`.
1369
1370/// The overview screen (HTML twin of [`api_corpora`]): the table of registered corpora — the
1371/// admin landing page. `503` if the pool is exhausted.
1372#[get("/?<deleted>")]
1373pub fn overview_page(deleted: Option<&str>, pool: &State<DbPool>) -> Result<Template, Status> {
1374  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1375  let counts = Corpus::document_counts(&mut connection);
1376  let all = Corpus::all(&mut connection).unwrap_or_default();
1377  // id → name over the loaded listing, so a sandbox's parent name resolves with no extra query.
1378  let names_by_id: HashMap<i32, String> = all.iter().map(|c| (c.id, c.name.clone())).collect();
1379  let corpora = all
1380    .iter()
1381    .map(|corpus| {
1382      let mut hash = corpus.to_hash();
1383      // Document scale at a glance on the landing (0 → omitted client-side); grouped for
1384      // readability.
1385      if let Some(count) = counts.get(&corpus.id) {
1386        hash.insert(
1387          "document_count".to_string(),
1388          crate::frontend::helpers::group_thousands(*count),
1389        );
1390      }
1391      // Mark sandboxes (carved subsets) so the list can badge them + link to their parent — the
1392      // human twin of `CorpusDto.parent`. `decorate_uri_encodings` adds `sandbox_parent_uri`.
1393      if let Some(parent) = corpus
1394        .parent_corpus_id
1395        .and_then(|pid| names_by_id.get(&pid))
1396      {
1397        hash.insert("sandbox_parent".to_string(), parent.clone());
1398      }
1399      hash
1400    })
1401    .collect::<Vec<_>>();
1402  let mut global = HashMap::new();
1403  global.insert(
1404    "title".to_string(),
1405    "Overview of available Corpora".to_string(),
1406  );
1407  global.insert(
1408    "description".to_string(),
1409    "An analysis framework for corpora of TeX/LaTeX documents - overview page".to_string(),
1410  );
1411  // The landing page carries the full hero wordmark, so the shared nav suppresses its brand logo
1412  // here (it shows on every *other* page).
1413  global.insert("is_landing".to_string(), "true".to_string());
1414  // `?deleted=<name>` flashes a confirmation after a corpus delete (the post-redirect-get lands
1415  // here), so a destructive action gets explicit feedback, not just "it vanished from the list".
1416  if let Some(name) = deleted {
1417    global.insert("deleted".to_string(), name.to_string());
1418  }
1419  let mut context = TemplateContext {
1420    global,
1421    corpora: Some(corpora),
1422    ..TemplateContext::default()
1423  };
1424  decorate_uri_encodings(&mut context);
1425  Ok(Template::render("overview", context))
1426}
1427
1428/// The corpus screen (HTML twin of [`api_corpus`]): the services registered on a corpus. `404` if
1429/// the corpus is unknown, `503` if the pool is exhausted.
1430#[get("/corpus/<name>?<deactivated>&<sandbox_taken>&<sandbox_invalid>")]
1431pub fn corpus_page(
1432  name: &str,
1433  deactivated: Option<&str>,
1434  sandbox_taken: Option<&str>,
1435  sandbox_invalid: Option<&str>,
1436  session: Option<AdminSession>,
1437  pool: &State<DbPool>,
1438) -> Result<Template, Status> {
1439  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
1440  let corpus = Corpus::find_by_name(name, &mut connection).map_err(|_| Status::NotFound)?;
1441  let mut global = HashMap::new();
1442  // `?deactivated=<service>` flashes a confirmation after a service deactivation lands back here.
1443  if let Some(service) = deactivated {
1444    global.insert("deactivated".to_string(), service.to_string());
1445  }
1446  // `?sandbox_taken=<name>` flashes a friendly error after a sandbox name collision (the human form
1447  // redirects here instead of dumping a bare 409 page).
1448  if let Some(taken) = sandbox_taken {
1449    global.insert(
1450      "sandbox_error".to_string(),
1451      format!("A corpus named “{taken}” already exists — choose a different sandbox name."),
1452    );
1453  }
1454  // `?sandbox_invalid=<reason>` flashes the Model C filter-validation reason (e.g. a category needs
1455  // a message severity) instead of a bare 422 page.
1456  if let Some(reason) = sandbox_invalid {
1457    global.insert(
1458      "sandbox_error".to_string(),
1459      format!("That sandbox filter isn't valid: {reason}."),
1460    );
1461  }
1462  global.insert(
1463    "title".to_string(),
1464    format!("Registered services for {}", corpus.name),
1465  );
1466  global.insert(
1467    "description".to_string(),
1468    format!(
1469      "An analysis framework for corpora of TeX/LaTeX documents - registered services for {}",
1470      corpus.name
1471    ),
1472  );
1473  global.insert("corpus_name".to_string(), corpus.name.clone());
1474  global.insert("corpus_description".to_string(), corpus.description.clone());
1475  // The filesystem root the "Extend" action re-scans — named in the corpus-actions form so an admin
1476  // sees exactly which directory will be walked.
1477  global.insert("corpus_path".to_string(), corpus.path.clone());
1478  // Sandbox provenance: if this corpus was carved from a parent, surface the parent + carve filter
1479  // (the agent twin `api_corpus` exposes the same via `CorpusDetailDto.sandbox`). `sandbox_parent`
1480  // gets a `_uri` variant from `decorate_uri_encodings` for the parent link.
1481  if let Some((parent, filter)) = sandbox_provenance(&corpus, &mut connection) {
1482    global.insert("sandbox_parent".to_string(), parent);
1483    global.insert("sandbox_filter".to_string(), filter);
1484  }
1485  // Each activated service, enriched with its per-severity task counts (the same numbers the agent
1486  // `api_corpus` reports) so the corpus screen is a progress dashboard, not just a service list.
1487  let service_records = corpus.select_services(&mut connection).unwrap_or_default();
1488  let mut services = Vec::with_capacity(service_records.len());
1489  for service in &service_records {
1490    let mut hash = service.to_hash();
1491    let report = progress_report(&mut connection, corpus.id, service.id);
1492    for key in [
1493      "total",
1494      "no_problem",
1495      "warning",
1496      "error",
1497      "fatal",
1498      "invalid",
1499      "todo",
1500    ] {
1501      hash.insert(
1502        key.to_string(),
1503        (report.get(key).copied().unwrap_or(0.0) as i64).to_string(),
1504      );
1505    }
1506    services.push(hash);
1507  }
1508  // The "register a service on this corpus" picker (the corpus-side mirror of the service screen's
1509  // "register on a corpus" <select>): all real (non-init/import) services **not yet activated on
1510  // this corpus**. Already-activated services are excluded — re-registering is rejected
1511  // (idempotent-neutral, 409) and must not be offered.
1512  let all_services = Service::all(&mut connection)
1513    .unwrap_or_default()
1514    .iter()
1515    .filter(|service| {
1516      service.id > IMPORT_SERVICE_ID
1517        && !service_records
1518          .iter()
1519          .any(|activated| activated.id == service.id)
1520    })
1521    .map(Service::to_hash)
1522    .collect::<Vec<_>>();
1523  let mut context = TemplateContext {
1524    global,
1525    services: Some(services),
1526    all_services: Some(all_services),
1527    is_admin: session.is_some(),
1528    ..TemplateContext::default()
1529  };
1530  decorate_uri_encodings(&mut context);
1531  Ok(Template::render("services", context))
1532}
1533
1534/// The "Add a corpus" screen (the corpus analogue of `/services/new`): the import form on its own
1535/// page, linked from the admin dashboard. **Signed-in admins only** (anonymous → sign-in); the form
1536/// posts to the existing `POST /corpus/import`.
1537#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
1538/// Renders the "Add a corpus" form. Shared by the GET page and the POST error path, so a failed
1539/// submit (e.g. a name collision) re-renders the form with a friendly `error` and the typed values
1540/// preserved instead of a bare error page. The `corpus_*` keys carry the form values (the page meta
1541/// `description` is separate). Admin-only page, so `is_admin` is set.
1542fn render_corpora_new(
1543  error: Option<&str>,
1544  name: &str,
1545  path: &str,
1546  description: &str,
1547  complex: bool,
1548) -> Template {
1549  let mut global = HashMap::new();
1550  global.insert("title".to_string(), "Add a corpus".to_string());
1551  global.insert(
1552    "description".to_string(),
1553    "Register a new corpus and import its documents".to_string(),
1554  );
1555  if let Some(message) = error {
1556    global.insert("error".to_string(), message.to_string());
1557  }
1558  global.insert("name".to_string(), name.to_string());
1559  global.insert("path".to_string(), path.to_string());
1560  global.insert("corpus_description".to_string(), description.to_string());
1561  global.insert("complex".to_string(), complex.to_string());
1562  Template::render(
1563    "corpora-new",
1564    TemplateContext {
1565      global,
1566      is_admin: true,
1567      ..TemplateContext::default()
1568    },
1569  )
1570}
1571
1572/// The "Add a corpus" form (`GET /corpora/new`). Signed-in admins only (anonymous → sign-in).
1573#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
1574#[get("/corpora/new")]
1575pub fn new_corpus_page(
1576  session: Option<AdminSession>,
1577  return_to: ReturnTo,
1578) -> Result<Template, AdminReject> {
1579  require_admin_to(session, &return_to)?;
1580  Ok(render_corpora_new(None, "", "", "", false))
1581}
1582
1583/// The route set for the corpus-management capability (API + human screens).
1584pub fn routes() -> Vec<Route> {
1585  // NB: the agent `/api/corpora*` routes (read + write) are mounted via `frontend::apidoc`
1586  // (rocket_okapi) so they land in the generated OpenAPI spec; only the human screens + form posts
1587  // are in this plain route group.
1588  routes![
1589    import_corpus_human,
1590    extend_corpus_human,
1591    delete_corpus_human,
1592    activate_service_human,
1593    deactivate_service_human,
1594    create_sandbox_human,
1595    export_dataset_page,
1596    export_dataset_human,
1597    overview_page,
1598    new_corpus_page,
1599    corpus_page
1600  ]
1601}