Skip to main content

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}