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