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 /// ORM-like until diesel.rs introduces finders for more fields
79 pub fn find_by_path(path_query: &str, connection: &mut PgConnection) -> Result<Self, Error> {
80 use crate::schema::corpora::path;
81 corpora::table.filter(path.eq(path_query)).first(connection)
82 }
83 /// Returns all registered corpora, ordered by name.
84 pub fn all(connection: &mut PgConnection) -> Result<Vec<Self>, Error> {
85 corpora::table
86 .order(corpora::name.asc())
87 .get_results(connection)
88 }
89
90 /// Document count per corpus id, for **every** corpus in **one** query (no N+1) — the number of
91 /// `import`-service (id 2) tasks, which is one per ingested document. Used to show each corpus's
92 /// scale on the overview/landing without a per-corpus count. A corpus with no import tasks (none
93 /// ingested yet) is simply absent from the map (treat as 0).
94 pub fn document_counts(connection: &mut PgConnection) -> HashMap<i32, i64> {
95 use crate::schema::tasks::dsl::{corpus_id, service_id, tasks};
96 use diesel::dsl::sql;
97 use diesel::sql_types::BigInt;
98 // The magic `import` service id is 2 (1=init, 2=import). Raw `count(*)` mirrors
99 // `progress_report` and sidesteps Diesel's aggregate/group-by type check.
100 tasks
101 .select((corpus_id, sql::<BigInt>("count(*)")))
102 .filter(service_id.eq(2))
103 .group_by(corpus_id)
104 .load::<(i32, i64)>(connection)
105 .unwrap_or_default()
106 .into_iter()
107 .collect()
108 }
109 /// Return a hash representation of the corpus, usually for frontend reports
110 pub fn to_hash(&self) -> HashMap<String, String> {
111 let mut hm = HashMap::new();
112 hm.insert("name".to_string(), self.name.clone());
113 hm.insert("path".to_string(), self.path.clone());
114 hm.insert("description".to_string(), self.description.clone());
115 hm
116 }
117
118 /// Return a vector of services currently activated on this corpus
119 pub fn select_services(&self, connection: &mut PgConnection) -> Result<Vec<Service>, Error> {
120 use crate::schema::tasks::dsl::{corpus_id, service_id};
121 let corpus_service_ids_query = tasks::table
122 .select(service_id)
123 .distinct()
124 .filter(corpus_id.eq(self.id));
125 let services_query = services::table.filter(services::id.eq_any(corpus_service_ids_query));
126 let services: Vec<Service> = services_query.get_results(connection)?;
127 Ok(services)
128 }
129
130 /// Deletes a corpus and **all** its dependent rows — the `log_*` messages, the tasks, and the
131 /// corpus registration — consuming the object. Runs in a single transaction so a crash mid-delete
132 /// can't leave a half-deleted corpus (crash-consistency, `docs/DESIGN_PRINCIPLES.md`).
133 ///
134 /// The `log_*` tables have **no** foreign key to `tasks` (the only FK is
135 /// `historical_tasks.task_id → tasks ON DELETE CASCADE`), so their rows must be deleted
136 /// explicitly **before** the tasks or they orphan — this is why deletion lives in one complete
137 /// primitive rather than a bare `DELETE FROM corpora` (the CLAUDE.md "deleting a corpus orphans
138 /// log_* rows" hazard, now closed at the source so every caller is safe).
139 pub fn destroy(self, connection: &mut PgConnection) -> Result<usize, Error> {
140 use crate::schema::{log_errors, log_fatals, log_infos, log_invalids, log_warnings};
141 let corpus_id = self.id;
142 let corpus_path = self.path;
143 connection.transaction(|t_connection| {
144 // The task ids of this corpus, rebuilt per delete (the subquery is consumed by `eq_any`).
145 let task_ids = || {
146 tasks::table
147 .filter(tasks::corpus_id.eq(corpus_id))
148 .select(tasks::id)
149 };
150 delete(log_infos::table.filter(log_infos::task_id.eq_any(task_ids())))
151 .execute(t_connection)?;
152 delete(log_warnings::table.filter(log_warnings::task_id.eq_any(task_ids())))
153 .execute(t_connection)?;
154 delete(log_errors::table.filter(log_errors::task_id.eq_any(task_ids())))
155 .execute(t_connection)?;
156 delete(log_fatals::table.filter(log_fatals::task_id.eq_any(task_ids())))
157 .execute(t_connection)?;
158 delete(log_invalids::table.filter(log_invalids::task_id.eq_any(task_ids())))
159 .execute(t_connection)?;
160 // all tasks for entries of this corpus (cascades to historical_tasks via its FK)
161 delete(tasks::table)
162 .filter(tasks::corpus_id.eq(corpus_id))
163 .execute(t_connection)?;
164 // the init task of this corpus
165 delete(tasks::table)
166 .filter(tasks::entry.eq(corpus_path))
167 .filter(tasks::service_id.eq(1))
168 .execute(t_connection)?;
169 // the corpus registration
170 delete(corpora::table)
171 .filter(corpora::id.eq(corpus_id))
172 .execute(t_connection)
173 })
174 }
175}
176
177/// Insertable `Corpus` struct
178#[derive(Insertable)]
179#[diesel(table_name = corpora)]
180pub struct NewCorpus {
181 /// file system path to corpus root
182 /// (a corpus is held in a single top-level directory)
183 pub path: String,
184 /// a human-readable name for this corpus
185 pub name: String,
186 /// are we using multiple files to represent a document entry?
187 /// (if unsure, always use "true")
188 pub complex: bool,
189 /// frontend-facing description of the corpus, maybe allow markdown here?
190 pub description: String,
191}
192impl Default for NewCorpus {
193 fn default() -> Self {
194 NewCorpus {
195 name: "mock corpus".to_string(),
196 path: ".".to_string(),
197 complex: true,
198 description: String::new(),
199 }
200 }
201}
202impl CortexInsertable for NewCorpus {
203 fn create(&self, connection: &mut PgConnection) -> Result<usize, Error> {
204 insert_into(corpora::table).values(self).execute(connection)
205 }
206}
207
208/// Insertable for a **sandbox** corpus — a `NewCorpus` plus the parent link and the filter
209/// predicate that defines it. Kept separate from [`NewCorpus`] so ordinary corpus creation (the
210/// import path, and dozens of test fixtures) stays a 4-field literal; the two sandbox columns are
211/// nullable, so a plain `NewCorpus` insert simply leaves them `NULL`.
212#[derive(Insertable)]
213#[diesel(table_name = corpora)]
214pub struct NewSandboxCorpus {
215 /// file system path to corpus root — the sandbox references the **parent's** path in place
216 /// (sources are not copied; owner decision 2026-06-15).
217 pub path: String,
218 /// a human-readable name for this sandbox
219 pub name: String,
220 /// inherited from the parent (same on-disk topology)
221 pub complex: bool,
222 /// frontend-facing description (auto-generated from the selection)
223 pub description: String,
224 /// the parent corpus this sandbox was carved from
225 pub parent_corpus_id: Option<i32>,
226 /// the filter predicate (`{service, severity, category, what}`) — the sandbox's provenance
227 pub selection: Option<serde_json::Value>,
228}
229impl CortexInsertable for NewSandboxCorpus {
230 fn create(&self, connection: &mut PgConnection) -> Result<usize, Error> {
231 insert_into(corpora::table).values(self).execute(connection)
232 }
233}