Skip to main content

cordis/
service.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Arc;
4
5use thiserror::Error;
6
7use crate::context::Context;
8use crate::effect::Disposable;
9use crate::error::ValidationError;
10
11#[derive(Debug, Error)]
12pub enum CordisError {
13    #[error("configuration error: {0}")]
14    Configuration(String),
15    #[error("fiber error: {0}")]
16    Fiber(String),
17    #[error("service not found: {0}")]
18    ServiceNotFound(String),
19    #[error("duplicate provider for '{name}' registered by '{owner}'")]
20    DuplicateProvider { name: String, owner: String },
21    #[error("invalid config: {0}")]
22    InvalidConfig(String),
23    #[error("invalid config: {0}")]
24    Validation(ValidationError),
25    #[error("fiber {fiber} stuck in transition for {waited_ms} ms")]
26    TransitionStuck { fiber: u64, waited_ms: u64 },
27    #[error("internal kernel error: {0}")]
28    Internal(String),
29    #[error("property '{name}' type mismatch: expected '{expected}'")]
30    PropertyTypeMismatch { name: String, expected: String },
31    #[error("property '{0}' is read-only")]
32    ReadOnlyProperty(String),
33}
34
35impl CordisError {
36    /// Lift structured [`ValidationError`] issues into the InvalidConfig
37    /// error class.
38    ///
39    /// Display keeps the established `"invalid config: …"` prefix; the
40    /// aggregate renders its issues joined by `"; "` (each issue as
41    /// `"- msg (at a.b.c)"`). Structure stays recoverable through
42    /// [`Self::validation_error`].
43    pub fn validation(issues: Vec<crate::error::ValidationIssue>) -> Self {
44        Self::Validation(ValidationError::new(issues))
45    }
46
47    /// The structured validation issues carried by this error, if any.
48    ///
49    /// Returns `Some` only for [`CordisError::Validation`]; every other
50    /// variant (including stringly [`CordisError::InvalidConfig`]) has no
51    /// machine-readable issue list.
52    pub fn validation_error(&self) -> Option<&ValidationError> {
53        match self {
54            Self::Validation(validation) => Some(validation),
55            _ => None,
56        }
57    }
58
59    /// The Display text of this error as an owned `String`.
60    ///
61    /// Convenience for callers that only want the rendered message (log
62    /// fields, aggregates, string assertions) without going through
63    /// `format!("{e}")` at every site.
64    pub fn message(&self) -> String {
65        self.to_string()
66    }
67}
68
69pub type ServiceInitFuture<'a> =
70    Pin<Box<dyn Future<Output = Result<Option<Box<dyn Disposable>>, CordisError>> + Send + 'a>>;
71
72pub trait Service: Send + Sync + 'static {
73    fn name(&self) -> &'static str {
74        std::any::type_name::<Self>()
75    }
76
77    fn init(&self, _ctx: &Arc<Context>) -> ServiceInitFuture<'_> {
78        Box::pin(async move { Ok(None) })
79    }
80
81    /// Availability predicate for this service instance.
82    ///
83    /// The kernel consumes `check()` at every point where a freshly built
84    /// instance meets the graph:
85    ///
86    /// - [`crate::RegistryService::register`] — after the plugin factory
87    ///   produces the service and BEFORE it is provided; a `false` verdict
88    ///   rests the fiber as inspectable `Failed { error: "availability
89    ///   predicate rejected service" }` instead of `Active`. Registration is
90    ///   non-throwing: register-before-ready is a supported transient that
91    ///   later refreshes converge.
92    /// - [`crate::Context::plugin`] — after `init`; a `false` verdict leaves
93    ///   the provided value in place but rests the fiber as `Inactive` (the
94    ///   historical behavior).
95    ///
96    /// It is NOT consulted on untyped store reads (`Context::get`): the store
97    /// holds type-erased values, so per-read checks would need downcasting
98    /// machinery that no consumer has asked for. Services whose availability
99    /// can change AFTER registration (circuit breakers, feature gates) must
100    /// drive their dependents by re-providing / notifying instead of relying
101    /// on spontaneous re-checks.
102    fn check(&self) -> bool {
103        true
104    }
105}
106
107#[cfg(test)]
108mod error_tests {
109    use super::CordisError;
110
111    #[test]
112    fn structured_variants_render_through_display_and_message() {
113        let cases: Vec<(CordisError, &str)> = vec![
114            (
115                CordisError::ServiceNotFound("ares_tools::Tools".into()),
116                "service not found: ares_tools::Tools",
117            ),
118            (
119                CordisError::DuplicateProvider {
120                    name: "cordis::EventsService".into(),
121                    owner: "context".into(),
122                },
123                "duplicate provider for 'cordis::EventsService' registered by 'context'",
124            ),
125            (
126                CordisError::InvalidConfig("missing url".into()),
127                "invalid config: missing url",
128            ),
129            (
130                CordisError::TransitionStuck {
131                    fiber: 7,
132                    waited_ms: 250,
133                },
134                "fiber 7 stuck in transition for 250 ms",
135            ),
136            (
137                CordisError::Internal("invariant violated".into()),
138                "internal kernel error: invariant violated",
139            ),
140        ];
141        for (err, expected) in cases {
142            assert_eq!(err.message(), expected);
143            // Round-trip: matching the constructed variant recovers its fields.
144            match err {
145                CordisError::ServiceNotFound(name) => {
146                    assert_eq!(name, "ares_tools::Tools");
147                }
148                CordisError::DuplicateProvider { name, owner } => {
149                    assert_eq!(
150                        (name.as_str(), owner.as_str()),
151                        ("cordis::EventsService", "context")
152                    );
153                }
154                CordisError::InvalidConfig(msg) => assert_eq!(msg, "missing url"),
155                CordisError::TransitionStuck { fiber, waited_ms } => {
156                    assert_eq!((fiber, waited_ms), (7, 250));
157                }
158                CordisError::Internal(msg) => assert_eq!(msg, "invariant violated"),
159                other => panic!("unexpected variant: {other:?}"),
160            }
161        }
162    }
163
164    #[test]
165    fn legacy_catch_all_variants_are_unchanged() {
166        let config = CordisError::Configuration("still supported".into());
167        let fiber = CordisError::Fiber("still supported".into());
168        assert_eq!(config.message(), "configuration error: still supported");
169        assert_eq!(fiber.message(), "fiber error: still supported");
170    }
171
172    /// The single-source discipline refusal keeps the `duplicate provider`
173    /// phrase that tests and docs assert on via `contains`.
174    #[test]
175    fn duplicate_provider_display_keeps_asserted_phrase() {
176        let err = CordisError::DuplicateProvider {
177            name: "FooService".into(),
178            owner: "root".into(),
179        };
180        assert!(
181            err.to_string().contains("duplicate provider"),
182            "Display must keep the asserted phrase, got: {err}"
183        );
184    }
185}