Skip to main content

keel/
lib.rs

1//! Keel as a Rust front end.
2//!
3//! Rust has no import hook to hang a zero-code-change promise off of (that's
4//! a dynamic-language trick `python/keel`/`node/keel` use), so the Rust
5//! front end keeps the "one optional attribute" ceiling (dx-spec.md
6//! invariant 1) instead: call [`init`] once from your own `main`, then mark
7//! the calls you want policy-driven resilience on.
8//!
9//! Two seams:
10//!
11//! * [`wrap`] (re-exported from `keel-macros`) — `#[keel::wrap(target =
12//!   "...")]` on a free `async fn` routes its body through the engine's
13//!   cache → rate → breaker → timeout → retry chain. See the macro's own
14//!   docs (`crates/keel-macros/src/lib.rs`) for the exact v1 scope
15//!   (explicit target only, `Clone` parameters, no generics/methods).
16//! * [`KeelMiddleware`] — a `reqwest_middleware::Middleware` for HTTP
17//!   clients built on `reqwest`. See its module docs for the exact v1
18//!   scope (exact-host targets, no glob/`llm:` mapping, no body-hash
19//!   caching).
20//!
21//! Both share one process-wide [`keel_core::Engine`], configured from
22//! `<cwd>/keel.toml` (or Level 0 defaults if absent) — mirrors
23//! `python/keel`'s `install_keel`/`node/keel`'s `installKeel`, minus the
24//! import-hook and adapter-detection machinery neither seam needs here.
25//!
26//! **Deferred** (see the session gap brief's explicit descoping): no
27//! `cargo-keel` subcommand, no `syn`-based static scanner
28//! (`crates/keel-cli/src/scan/rust.rs`), no `keel init --rust` wiring. A
29//! Rust project adds this crate itself and calls [`init`]; `keel
30//! doctor`/`keel init` do not yet know Rust projects exist. This is real,
31//! tracked architectural debt, not an oversight — flagged for
32//! prioritization rather than assumed to be next.
33//!
34//! **Published name vs. import name.** This crate is published to
35//! crates.io as `keelrun` (plain `keel` is taken — see
36//! `docs/naming-decision.md`), but every `#[keel::wrap]`/`::keel::…` path
37//! generated by the macro is hardcoded to the identifier `keel`, matching
38//! the product's one-attribute promise (dx-spec.md invariant 1). Add the
39//! dependency with an explicit rename so the two agree:
40//!
41//! ```toml
42//! [dependencies]
43//! keel = { package = "keelrun", version = "0.1" }
44//! ```
45//!
46//! (equivalently, `cargo add keelrun --rename keel`). This mirrors how this
47//! workspace's own crates already depend on `keelrun-core` as `keel-core` —
48//! not a workaround, the intended way to add this crate.
49
50mod error;
51mod middleware;
52mod policy;
53
54pub use error::Error;
55pub use keel_macros::wrap;
56pub use middleware::KeelMiddleware;
57
58use keel_core::Engine;
59use keel_core_api::KeelError;
60use std::path::Path;
61use std::sync::OnceLock;
62
63static ENGINE: OnceLock<Engine> = OnceLock::new();
64
65/// Initialize Keel from `<current_dir>/keel.toml` (or Level 0 defaults if
66/// absent). Idempotent — a second call is a no-op (mirrors
67/// `python/keel`'s/`node/keel`'s idempotent `install_keel`/`installKeel`).
68///
69/// Call this once from your own `main` before the first `#[keel::wrap]`'d
70/// call or [`KeelMiddleware`] use, so a malformed `keel.toml` fails your
71/// startup loudly. If you never call it, the first wrapped call/middleware
72/// invocation initializes lazily from the same file — but a load/parse
73/// failure at that point silently degrades to Level 0 defaults instead of
74/// surfacing a [`KeelError`], since there is no caller left to hand the
75/// error to by then.
76///
77/// # Errors
78/// `KEEL-E001` if `keel.toml` is present but unreadable or not valid TOML,
79/// or if the policy it describes fails schema validation.
80pub fn init() -> Result<(), KeelError> {
81    init_from(std::env::current_dir().unwrap_or_default())
82}
83
84/// As [`init`], loading `keel.toml` from `dir` instead of the current
85/// directory (multi-root setups; tests).
86///
87/// # Errors
88/// See [`init`].
89pub fn init_from(dir: impl AsRef<Path>) -> Result<(), KeelError> {
90    if ENGINE.get().is_some() {
91        return Ok(());
92    }
93    let policy = policy::load(dir.as_ref())?;
94    let engine = Engine::new();
95    engine.configure(&policy)?;
96    // If another thread won the race to `set`, that engine is configured
97    // from the exact same file — either outcome is the "already installed"
98    // no-op this function promises.
99    let _ = ENGINE.set(engine);
100    Ok(())
101}
102
103/// True once an [`Engine`] has been installed, whether by an explicit
104/// [`init`]/[`init_from`] call or lazily by the first wrapped call/
105/// middleware invocation.
106#[must_use]
107pub fn is_initialized() -> bool {
108    ENGINE.get().is_some()
109}
110
111fn engine() -> &'static Engine {
112    ENGINE.get_or_init(|| {
113        let dir = std::env::current_dir().unwrap_or_default();
114        let policy = policy::load(&dir).unwrap_or_else(|_| serde_json::json!({}));
115        let engine = Engine::new();
116        // See `init`'s docs: a bad policy file discovered only here (no
117        // explicit `init()` call) degrades to Level 0 defaults rather than
118        // panicking the caller's first request.
119        let _ = engine.configure(&policy);
120        engine
121    })
122}
123
124/// Deterministic per-target metrics/discovery report (JSON), forwarded from
125/// the underlying [`keel_core::Engine::report`].
126pub fn report() -> serde_json::Value {
127    engine().report()
128}
129
130/// Implementation detail of the `#[keel::wrap]` macro expansion. Not part of
131/// the public API — its signature changes in lockstep with `keel-macros`.
132#[doc(hidden)]
133pub mod __private {
134    pub use serde;
135    pub use serde_json;
136
137    use crate::Error;
138    use keel_core_api::{AttemptResult, ENVELOPE_VERSION, ErrorClass, Request};
139    use serde::{Serialize, de::DeserializeOwned};
140    use std::future::Future;
141
142    pub async fn wrap_call<T, E, F, Fut>(
143        target: &str,
144        op: &str,
145        idempotent: bool,
146        mut make_attempt: F,
147    ) -> Result<T, Error<E>>
148    where
149        T: Serialize + DeserializeOwned + Send + 'static,
150        E: std::error::Error + Send + Sync + 'static,
151        F: FnMut() -> Fut,
152        Fut: Future<Output = Result<T, E>>,
153    {
154        let request = Request {
155            v: ENVELOPE_VERSION,
156            target: target.to_owned(),
157            op: op.to_owned(),
158            idempotent,
159            args_hash: None,
160        };
161        let mut live_ok: Option<T> = None;
162        let mut live_err: Option<E> = None;
163        let outcome = crate::engine()
164            .execute(&request, async |_attempt: u32| match make_attempt().await {
165                Ok(value) => match serde_json::to_value(&value) {
166                    Ok(payload) => {
167                        live_ok = Some(value);
168                        AttemptResult::Ok { payload }
169                    }
170                    Err(err) => AttemptResult::Error {
171                        class: ErrorClass::Other,
172                        http_status: None,
173                        retry_after_ms: None,
174                        message: format!(
175                            "keel: #[keel::wrap]'d function's result failed to serialize to \
176                             JSON (needed for the cache path): {err}"
177                        ),
178                        original: None,
179                    },
180                },
181                Err(err) => {
182                    let message = err.to_string();
183                    live_err = Some(err);
184                    AttemptResult::Error {
185                        class: ErrorClass::Other,
186                        http_status: None,
187                        retry_after_ms: None,
188                        message,
189                        original: None,
190                    }
191                }
192            })
193            .await;
194
195        if outcome.result == "ok" {
196            if outcome.from_cache {
197                let payload = outcome.payload.unwrap_or(serde_json::Value::Null);
198                return serde_json::from_value(payload).map_err(|err| {
199                    Error::Keel(keel_core_api::OutcomeError {
200                        code: keel_core_api::ErrorCode::Internal,
201                        class: ErrorClass::Other,
202                        http_status: None,
203                        message: format!(
204                            "keel: cached payload for target {target:?} failed to deserialize: \
205                             {err}"
206                        ),
207                        original: None,
208                    })
209                });
210            }
211            return Ok(
212                live_ok.expect("engine reported a non-cached success without running the effect")
213            );
214        }
215        match live_err {
216            Some(err) => Err(Error::Original(err)),
217            None => {
218                Err(Error::Keel(outcome.error.expect(
219                    "engine reported an error outcome without an OutcomeError",
220                )))
221            }
222        }
223    }
224}