cortex/models/corpora.rs
1#![allow(clippy::extra_unused_lifetimes)]
2use diesel::result::Error;
3use diesel::*;
4use serde::Serialize;
5use std::collections::HashMap;
6
7use crate::concerns::CortexInsertable;
8use crate::schema::corpora;
9use crate::schema::services;
10use crate::schema::tasks;
11
12use super::services::Service;
13
14// Corpora
15
16#[derive(Identifiable, Queryable, AsChangeset, Clone, Debug, Serialize)]
17#[diesel(table_name = corpora)]
18/// A minimal description of a document collection. Defined by a name, path and simple/complex file
19/// system setup.
20pub struct Corpus {
21 /// auto-incremented postgres id
22 pub id: i32,
23 /// file system path to corpus root
24 /// (a corpus is held in a single top-level directory)
25 pub path: String,
26 /// a human-readable name for this corpus
27 pub name: String,
28 /// are we using multiple files to represent a document entry?
29 /// (if unsure, always use "true")
30 pub complex: bool,
31 /// a human-readable description of the corpus, maybe allow markdown here?
32 pub description: String,
33 /// For a **sandbox** corpus: the parent corpus it was carved from (else `None`). See
34 /// [`crate::backend::sandbox`].
35 pub parent_corpus_id: Option<i32>,
36 /// For a **sandbox** corpus: the filter predicate it was built from — the JSON
37 /// `{service, severity, category, what}` selection over the parent. This IS the sandbox's
38 /// provenance ("why these entries"), so no per-task origin link is kept.
39 pub selection: Option<serde_json::Value>,
40 /// Stable external handle (UUIDv7, DB-generated), independent of the mutable `name` — for
41 /// public/API references that must survive a rename. Immutable once assigned (Arm 3 / D8).
42 pub public_id: uuid::Uuid,
43}
44
45diesel::define_sql_function! {
46 /// SQL `LOWER()`, for case-insensitive corpus-name matching in `find_by_name`.
47 fn lower(x: diesel::sql_types::Text) -> diesel::sql_types::Text;
48}
49
50impl Corpus {
51 /// ORM-like until diesel.rs introduces finders for more fields
52 pub fn find_by_name(name_query: &str, connection: &mut PgConnection) -> Result<Self, Error> {
53 use crate::schema::corpora::name;
54 // Case-insensitive: a corpus name matches regardless of case, so a mixed-case display name like
55 // `arXiv` resolves at any URL case (no functional index, but the corpora set is tiny). The
56 // stored case is preserved — callers render `corpus.name`, not the URL string.
57 corpora::table
58 .filter(lower(name).eq(name_query.to_lowercase()))
59 .first(connection)
60 }
61 /// Find a corpus by its primary key (used to resolve a sandbox's parent).
62 pub fn find_by_id(id_query: i32, connection: &mut PgConnection) -> Result<Self, Error> {
63 corpora::table.find(id_query).first(connection)
64 }
65 /// The id under which this corpus's **result archives** are name-scoped, or `None` for an
66 /// ordinary corpus. A sandbox (`parent_corpus_id.is_some()`) scopes its outputs by its own id
67 /// so a rerun can't clobber the parent's archives — see [`crate::helpers::result_archive_path`]
68 /// (F-6).
69 pub fn sandbox_id(&self) -> Option<i32> { self.parent_corpus_id.map(|_| self.id) }
70 /// Total number of tasks registered under this corpus (across all services) — the blast radius a
71 /// [`Corpus::destroy`] would remove. Used to preview a destructive delete before committing.
72 pub fn task_count(&self, connection: &mut PgConnection) -> Result<i64, Error> {
73 tasks::table
74 .filter(tasks::corpus_id.eq(self.id))
75 .count()
76 .get_result(connection)
77 }
78 /// Finds the **root** corpus registered at `path_query`. Sandboxes share their parent's path, so
79 /// they are excluded; the partial unique index `corpora_root_path_key` guarantees at most one
80 /// root per path, making the lookup deterministic.
81 pub fn find_by_path(path_query: &str, connection: &mut PgConnection) -> Result<Self, Error> {
82 corpora::table
83 .filter(corpora::path.eq(path_query))
84 .filter(corpora::parent_corpus_id.is_null())
85 .first(connection)
86 }
87 /// Returns all registered corpora, ordered by name.
88 pub fn all(connection: &mut PgConnection) -> Result<Vec<Self>, Error> {
89 corpora::table
90 .order(corpora::name.asc())
91 .get_results(connection)
92 }
93
94 /// Document count per corpus id, for **every** corpus in **one** query (no N+1) — the number of
95 /// `import`-service (id 2) tasks, which is one per ingested document. Used to show each corpus's
96 /// scale on the overview/landing without a per-corpus count. A corpus with no import tasks (none
97 /// ingested yet) is simply absent from the map (treat as 0).
98 pub fn document_counts(connection: &mut PgConnection) -> HashMap<i32, i64> {
99 use crate::schema::tasks::dsl::{corpus_id, service_id, tasks};
100 use diesel::dsl::sql;
101 use diesel::sql_types::BigInt;
102 // The magic `import` service id is 2 (1=init, 2=import). Raw `count(*)` mirrors
103 // `progress_report` and sidesteps Diesel's aggregate/group-by type check.
104 tasks
105 .select((corpus_id, sql::<BigInt>("count(*)")))
106 .filter(service_id.eq(2))
107 .group_by(corpus_id)
108 .load::<(i32, i64)>(connection)
109 .unwrap_or_default()
110 .into_iter()
111 .collect()
112 }
113 /// Return a hash representation of the corpus, usually for frontend reports
114 pub fn to_hash(&self) -> HashMap<String, String> {
115 let mut hm = HashMap::new();
116 hm.insert("name".to_string(), self.name.clone());
117 hm.insert("path".to_string(), self.path.clone());
118 hm.insert("description".to_string(), self.description.clone());
119 hm
120 }
121
122 /// Return a vector of services currently activated on this corpus
123 pub fn select_services(&self, connection: &mut PgConnection) -> Result<Vec<Service>, Error> {
124 use crate::schema::tasks::dsl::{corpus_id, service_id};
125 let corpus_service_ids_query = tasks::table
126 .select(service_id)
127 .distinct()
128 .filter(corpus_id.eq(self.id));
129 let services_query = services::table.filter(services::id.eq_any(corpus_service_ids_query));
130 let services: Vec<Service> = services_query.get_results(connection)?;
131 Ok(services)
132 }
133
134 /// Names of every sandbox carved from this corpus, directly or from one of its sandboxes — all
135 /// deleted with it (FK cascade).
136 pub fn sandboxes(&self, connection: &mut PgConnection) -> Result<Vec<String>, Error> {
137 let (mut names, mut frontier) = (Vec::new(), vec![self.id]);
138 // ponytail: one query per carve depth; 32 levels cuts a parent cycle, which only a hand-edited
139 // `parent_corpus_id` could make.
140 for _ in 0..32 {
141 if frontier.is_empty() {
142 break;
143 }
144 let children: Vec<(i32, String)> = corpora::table
145 .filter(corpora::parent_corpus_id.eq_any(&frontier))
146 .select((corpora::id, corpora::name))
147 .load(connection)?;
148 frontier = children.iter().map(|(id, _)| *id).collect();
149 names.extend(children.into_iter().map(|(_, name)| name));
150 }
151 names.sort();
152 names.dedup();
153 Ok(names)
154 }
155
156 /// Deletes a corpus and **all** its dependent rows — the `log_*` messages, the tasks, and the
157 /// corpus registration — consuming the object. Runs in a single transaction so a crash mid-delete
158 /// can't leave a half-deleted corpus (crash-consistency, `docs/DESIGN_PRINCIPLES.md`).
159 ///
160 /// The database cascades `corpora → tasks → log_*` (FKs since migration `…140000`); this deletes
161 /// the logs and tasks explicitly anyway, in one audited transaction, rather than relying on a
162 /// bare `DELETE FROM corpora`.
163 ///
164 /// Deleting a corpus also deletes every sandbox carved from it (and their tasks and logs) via the
165 /// `parent_corpus_id` FK cascade — list them first with [`Corpus::sandboxes`].
166 pub fn destroy(self, connection: &mut PgConnection) -> Result<usize, Error> {
167 use crate::schema::{log_errors, log_fatals, log_infos, log_invalids, log_warnings};
168 let corpus_id = self.id;
169 // A root's init task may sit under a placeholder corpus id (`examples/tex_to_html_import.rs`
170 // queues it before `InitWorker` registers the corpus), so roots also clear it by path — safe
171 // since a path names one root. Never for a sandbox: it shares its parent's path.
172 let root_path = self.parent_corpus_id.is_none().then_some(self.path);
173 connection.transaction(|t_connection| {
174 // The task ids of this corpus, rebuilt per delete (the subquery is consumed by `eq_any`).
175 let task_ids = || {
176 tasks::table
177 .filter(tasks::corpus_id.eq(corpus_id))
178 .select(tasks::id)
179 };
180 delete(log_infos::table.filter(log_infos::task_id.eq_any(task_ids())))
181 .execute(t_connection)?;
182 delete(log_warnings::table.filter(log_warnings::task_id.eq_any(task_ids())))
183 .execute(t_connection)?;
184 delete(log_errors::table.filter(log_errors::task_id.eq_any(task_ids())))
185 .execute(t_connection)?;
186 delete(log_fatals::table.filter(log_fatals::task_id.eq_any(task_ids())))
187 .execute(t_connection)?;
188 delete(log_invalids::table.filter(log_invalids::task_id.eq_any(task_ids())))
189 .execute(t_connection)?;
190 if let Some(path) = &root_path {
191 delete(tasks::table)
192 .filter(tasks::entry.eq(path))
193 .filter(tasks::service_id.eq(1))
194 .execute(t_connection)?;
195 }
196 // all tasks of this corpus, its init task included (cascades to historical_tasks via its FK).
197 delete(tasks::table)
198 .filter(tasks::corpus_id.eq(corpus_id))
199 .execute(t_connection)?;
200 // the corpus registration
201 delete(corpora::table)
202 .filter(corpora::id.eq(corpus_id))
203 .execute(t_connection)
204 })
205 }
206}
207
208/// Insertable `Corpus` struct
209#[derive(Insertable)]
210#[diesel(table_name = corpora)]
211pub struct NewCorpus {
212 /// file system path to corpus root
213 /// (a corpus is held in a single top-level directory)
214 pub path: String,
215 /// a human-readable name for this corpus
216 pub name: String,
217 /// are we using multiple files to represent a document entry?
218 /// (if unsure, always use "true")
219 pub complex: bool,
220 /// frontend-facing description of the corpus, maybe allow markdown here?
221 pub description: String,
222}
223impl Default for NewCorpus {
224 fn default() -> Self {
225 NewCorpus {
226 name: "mock corpus".to_string(),
227 path: ".".to_string(),
228 complex: true,
229 description: String::new(),
230 }
231 }
232}
233impl CortexInsertable for NewCorpus {
234 fn create(&self, connection: &mut PgConnection) -> Result<usize, Error> {
235 insert_into(corpora::table).values(self).execute(connection)
236 }
237}
238
239/// Insertable for a **sandbox** corpus — a `NewCorpus` plus the parent link and the filter
240/// predicate that defines it. Kept separate from [`NewCorpus`] so ordinary corpus creation (the
241/// import path, and dozens of test fixtures) stays a 4-field literal; the two sandbox columns are
242/// nullable, so a plain `NewCorpus` insert simply leaves them `NULL`.
243#[derive(Insertable)]
244#[diesel(table_name = corpora)]
245pub struct NewSandboxCorpus {
246 /// file system path to corpus root — the sandbox references the **parent's** path in place
247 /// (sources are not copied; owner decision 2026-06-15).
248 pub path: String,
249 /// a human-readable name for this sandbox
250 pub name: String,
251 /// inherited from the parent (same on-disk topology)
252 pub complex: bool,
253 /// frontend-facing description (auto-generated from the selection)
254 pub description: String,
255 /// the parent corpus this sandbox was carved from
256 pub parent_corpus_id: Option<i32>,
257 /// the filter predicate (`{service, severity, category, what}`) — the sandbox's provenance
258 pub selection: Option<serde_json::Value>,
259}
260impl CortexInsertable for NewSandboxCorpus {
261 fn create(&self, connection: &mut PgConnection) -> Result<usize, Error> {
262 insert_into(corpora::table).values(self).execute(connection)
263 }
264}