Skip to main content

oxide_batch_cli/
catalog.rs

1//! The host-supplied job definition catalog.
2//!
3//! A launch or a restart is guarded against the job's canonical
4//! [`DefinitionIdentity`], which is derived from the live component revisions
5//! of the application that owns the job. A standalone process that only reads
6//! metadata cannot reconstruct one, and asserting a manifest digest from
7//! configuration would let an operator claim an identity the application never
8//! produced.
9//!
10//! A host application therefore embeds this crate and registers the same
11//! definitions it launches in process. The shipped binary registers none, so it
12//! serves every command that a repository alone can answer and reports a
13//! deterministic rejection for the two that cannot be answered without the
14//! application.
15//!
16//! This is not a definition registry: it resolves nothing, persists nothing,
17//! and stores no component. It is the narrowest input `launch` and
18//! `execution restart` require.
19
20use std::collections::BTreeMap;
21use std::fmt;
22
23use oxide_batch::{DefinitionIdentity, JobName};
24
25/// A rejected catalog registration.
26#[derive(Clone, Debug, Eq, PartialEq)]
27#[non_exhaustive]
28pub enum CatalogError {
29    /// The identity carries no job name, so it selects no job.
30    AnonymousDefinition,
31    /// The job name is already registered with a different identity.
32    DuplicateJob {
33        /// Conflicting job name.
34        job_name: JobName,
35    },
36}
37
38impl fmt::Display for CatalogError {
39    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            Self::AnonymousDefinition => {
42                formatter.write_str("the definition identity carries no job name")
43            }
44            Self::DuplicateJob { job_name } => {
45                write!(formatter, "job {job_name} is already registered")
46            }
47        }
48    }
49}
50
51impl std::error::Error for CatalogError {}
52
53/// The job definitions one embedding application authorizes for launch.
54#[derive(Clone, Debug, Default)]
55pub struct DefinitionCatalog {
56    entries: BTreeMap<JobName, DefinitionIdentity>,
57}
58
59impl DefinitionCatalog {
60    /// Builds an empty catalog.
61    ///
62    /// A CLI built on an empty catalog serves every command except `launch`
63    /// and `execution restart`.
64    #[must_use]
65    pub fn new() -> Self {
66        Self {
67            entries: BTreeMap::new(),
68        }
69    }
70
71    /// Registers one job definition.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`CatalogError::AnonymousDefinition`] when the identity carries
76    /// no job name and [`CatalogError::DuplicateJob`] when the name is already
77    /// registered.
78    pub fn register(&mut self, identity: DefinitionIdentity) -> Result<(), CatalogError> {
79        let job_name = identity
80            .job_name()
81            .cloned()
82            .ok_or(CatalogError::AnonymousDefinition)?;
83        if self.entries.contains_key(&job_name) {
84            return Err(CatalogError::DuplicateJob { job_name });
85        }
86        self.entries.insert(job_name, identity);
87        Ok(())
88    }
89
90    /// Registers one job definition and returns the catalog.
91    ///
92    /// # Errors
93    ///
94    /// Returns the same rejections as [`DefinitionCatalog::register`].
95    pub fn with(mut self, identity: DefinitionIdentity) -> Result<Self, CatalogError> {
96        self.register(identity)?;
97        Ok(self)
98    }
99
100    /// Returns the registered identity of one job name.
101    #[must_use]
102    pub fn get(&self, job_name: &JobName) -> Option<&DefinitionIdentity> {
103        self.entries.get(job_name)
104    }
105
106    /// Returns whether the catalog registers no definition.
107    #[must_use]
108    pub fn is_empty(&self) -> bool {
109        self.entries.is_empty()
110    }
111
112    /// Returns the number of registered definitions.
113    #[must_use]
114    pub fn len(&self) -> usize {
115        self.entries.len()
116    }
117
118    /// Iterates registered job names in canonical order.
119    #[must_use]
120    pub fn job_names(&self) -> impl ExactSizeIterator<Item = &JobName> {
121        self.entries.keys()
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    #![allow(clippy::expect_used, clippy::panic)]
128
129    use oxide_batch::{
130        ComponentRevision, DefinitionIdentity, DefinitionRevision, JobName, StepName,
131    };
132
133    use super::{CatalogError, DefinitionCatalog};
134
135    fn identity(job: &str) -> DefinitionIdentity {
136        let job_name = JobName::new(job).expect("the job name is valid");
137        let step_name = StepName::new("only").expect("the step name is valid");
138        let revision = DefinitionRevision::new("r1").expect("the revision is valid");
139        let component = ComponentRevision::new("c1").expect("the component revision is valid");
140        DefinitionIdentity::tasklet(&job_name, &step_name, revision, &component)
141            .expect("the manifest encodes")
142    }
143
144    #[test]
145    fn an_empty_catalog_registers_nothing() {
146        let catalog = DefinitionCatalog::new();
147        assert!(catalog.is_empty());
148        assert_eq!(catalog.len(), 0);
149    }
150
151    #[test]
152    fn a_registered_job_resolves_by_name() {
153        let catalog = DefinitionCatalog::new()
154            .with(identity("orders"))
155            .expect("the registration succeeds");
156        let job_name = JobName::new("orders").expect("the job name is valid");
157        assert!(catalog.get(&job_name).is_some());
158    }
159
160    #[test]
161    fn a_duplicate_job_name_is_rejected() {
162        let mut catalog = DefinitionCatalog::new();
163        catalog
164            .register(identity("orders"))
165            .expect("the first registration succeeds");
166        let error = catalog
167            .register(identity("orders"))
168            .expect_err("the second registration is a duplicate");
169        assert!(matches!(error, CatalogError::DuplicateJob { .. }));
170    }
171}