# error-engine
A shared Rust crate to centralize error, warning, and info presentation
across personal projects. It does not replace `thiserror` or `tracing` — it
sits on top of them as a **message catalog + presentation** layer.
Each project defines its errors as an `enum` with `thiserror` (re-exported
by this crate) and implements the `EngineDiagnostic` trait. A TOML catalog
file maps each diagnostic's stable code to a message template and an
optional hint, so wording can be edited without touching Rust code.
## Install
```toml
[dependencies]
error-engine = "0.1"
```
Enable the `cli` feature if you want colored terminal output (`Engine::print`):
```toml
[dependencies]
error-engine = { version = "0.1", features = ["cli"] }
```
## Usage
1. Define your errors with `thiserror`, using `error_engine::thiserror::Error`
so you don't need `thiserror` as a separate dependency:
```rust
use error_engine::thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("config not found")]
ConfigNotFound { path: String },
}
```
2. Implement `EngineDiagnostic` — a stable `code()`, a `severity()`, and
optional `context()` key-value pairs to interpolate into the template:
```rust
use error_engine::{EngineDiagnostic, Severity};
impl EngineDiagnostic for AppError {
fn code(&self) -> &'static str {
match self {
AppError::ConfigNotFound { .. } => "CONFIG_NOT_FOUND",
}
}
fn severity(&self) -> Severity {
Severity::Error
}
fn context(&self) -> Vec<(&'static str, String)> {
match self {
AppError::ConfigNotFound { path } => vec![("path", path.clone())],
}
}
}
```
3. Write a catalog TOML file at your project's root, e.g. `errors.toml`:
```toml
[CONFIG_NOT_FOUND]
template = "Could not find the configuration file at {path}"
hint = "Check that the path is correct, or create one with `myapp init`"
[DB_CONN_FAILED]
template = "Could not connect to the database: {reason}"
```
- Each table key (`[CODE]`) must match exactly the `code()` returned by
the corresponding `EngineDiagnostic`.
- `template` is required, `hint` is optional.
- `{key}` placeholders are substituted with values from `context()`. A
placeholder with no matching value is left literal in the text — it
never panics.
- `UNK-000` is reserved by the crate (see [Failure behavior](#failure-behavior-harmless-by-design))
and should not be reused as a project-defined code.
4. Load the catalog and build an `Engine`:
```rust
use error_engine::{Catalog, Engine};
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
let catalog = Catalog::load_or_fallback("errors.toml");
let engine = Engine::new(catalog);
let err = AppError::ConfigNotFound { path: "/etc/app.toml".into() };
engine.print(&err); engine.log(&err);
Ok(())
}
```
Run the bundled examples for a working end-to-end reference:
```sh
cargo run --example basic
cargo run --example cli --features cli
```
## Output modes
| `message()` | `String` | GUI apps: plain text only, no logging, no color |
| `log()` | nothing, goes to `tracing` | Structured logging (files, services) |
| `print()` | nothing, goes to stdout/stderr | CLIs: colored output with hint (`cli` feature) |
`print()` lives behind the `cli` feature flag so GUI/service projects don't
pull in terminal-only dependencies (`owo-colors`).
## Failure behavior (harmless by design)
Nothing about diagnostic rendering should ever panic or crash the
application — a broken catalog or a typo'd code is a documentation problem,
not a runtime crash. Two failure modes are unified under a single reserved
code, `UNK-000`:
1. **The catalog itself failed to load** (file missing, unreadable, or
invalid TOML).
2. **The catalog loaded fine, but the requested code has no entry in it.**
In both cases, rendering returns a message built around `UNK-000` stating
which of the two happened, instead of failing.
- `Catalog::load(path)` — strict loader, returns `Result<Catalog, EngineError>`.
Use at startup if you want the app to refuse to run without a valid
catalog.
- `Catalog::load_or_fallback(path)` — infallible loader, and the
recommended entry point. A broken or missing catalog never panics; every
diagnostic that would have failed instead renders as `UNK-000` with an
explanatory message.
## Catalog composition (for library authors)
If your project depends on a library that also uses `error-engine`, the
library can ship its own catalog embedded in the compiled crate — no
runtime file path required — and your app's catalog can layer on top of it.
In the library:
```rust
// lib_a's own crate
pub fn catalog() -> error_engine::Catalog {
error_engine::Catalog::from_str(include_str!("../errors.toml"))
.expect("lib_a's own catalog is valid TOML — covered by lib_a's own tests")
}
```
In the consuming app:
```rust
use error_engine::{Catalog, Engine};
let catalog = Catalog::load_or_fallback("errors.toml") // app's own, wins on conflicts
.merged_with(lib_a::catalog());
let engine = Engine::new(catalog);
```
`merged_with` is app-wins on code collisions: entries in `self` (the
catalog you call it on) take priority over entries in `other`. Codes the
app doesn't define fall through to the library's defaults instead of
rendering `UNK-000`. A `load_error` on the app's own catalog (e.g. a
missing/broken `errors.toml`) is preserved as-is after merging — it still
renders `UNK-000` for everything, but that never poisons the library's
catalog, which keeps working independently. There's no automatic discovery
and no collision warnings (see `docs/error-engine-design.md` section 10 for
the full design rationale and open questions).
## Project structure
```
error-engine/
├── Cargo.toml
├── .cargo/
│ └── config.toml # rustflags = ["-D", "warnings"]
├── src/
│ ├── lib.rs # public re-exports (includes `pub use thiserror`)
│ ├── diagnostic.rs # trait EngineDiagnostic + enum Severity
│ ├── catalog.rs # TOML catalog loading/rendering + UNK-000 fallback
│ ├── engine.rs # struct Engine: message() / log() / print()
│ └── error.rs # crate's own internal errors (catalog loading)
├── examples/
│ ├── basic.rs # minimal usage: message() + log()
│ ├── cli.rs # usage with the `cli` feature: print()
│ └── errors.toml # sample catalog used by both examples
└── tests/
└── catalog.rs # catalog loading/rendering tests
```
## Testing
```sh
cargo test --all-features
```
## Non-goals
- No compile-time validation that codes used in the project exist in the catalog.
- No `#[derive(EngineDiagnostic)]` macro.
- No automatic discovery of a dependency's catalog — merging is explicit
(`.merged_with(...)`), not reflected over `Cargo.toml` dependencies.
- No collision detection/warnings when merged catalogs define the same code.
- No i18n (multiple languages per catalog).
See `docs/error-engine-design.md` for the full design rationale.