aRustyDev / aRustyDev/mdbook-htmx
docs(adr): ADR-0017: Error Handling and Build Failures
- Dominant language
- Rust
- Stars
- 0
- Forks
- 1
- PR merge metrics
- No merged PRs in 30d
Description
# ADR-0017: Error Handling and Build Failures
## Status
Accepted
## Context
mdbook-htmx is a build tool. When errors occur, users need:
1. **Clear messages** - What went wrong
2. **Context** - Where in which file
3. **Actionable guidance** - How to fix it
4. **Appropriate exit codes** - For CI integration
Poor error handling leads to frustration and support burden.
## Decision Drivers
1. **User Experience** - Errors should help, not confuse
2. **CI Integration** - Exit codes must be meaningful
3. **Debugging** - Verbose mode for troubleshooting
4. **Fail-Fast vs Collect** - Some builds should continue despite warnings
## Decision
**Use structured error types with context chains, actionable messages, and appropriate severity levels.**
### Error Severity Levels
| Level | Behavior | Exit Code | Use Case |
|-------|----------|-----------|----------|
| Error | Stop build | 1 | Invalid config, missing files |
| Warning | Continue, log | 0 | Deprecated config, unused fields |
| Note | Continue, log | 0 | Performance hints, best practices |
### Error Structure
```rust
use thiserror::Error;
#[derive(Error, Debug)]
pub enum BuildError {
#[error("Configuration error in book.toml")]
Config(#[source] ConfigError),
#[error("Template error in {template}")]
Template {
template: String,
#[source]
source: tera::Error,
},
#[error("Frontmatter error in {path}")]
Frontmatter {
path: PathBuf,
line: Option,
#[source]
source: FrontmatterError,
},
#[error("I/O error")]
Io(#[from] std::io::Error),
}
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("Unknown field '{field}'")]
UnknownField {
field: String,
did_you_mean: Option,
},
#[error("Invalid value for '{field}': expected {expected}, got {actual}")]
InvalidValue {
field: String,
expected: String,
actual: String,
},
#[error("Missing required field '{field}'")]
MissingField { field: String },
#[error("Deprecated field '{field}'. {suggestion}")]
Deprecated {
field: String,
suggestion: String,
},
}
#[derive(Error, Debug)]
pub enum FrontmatterError {
#[error("Unclosed frontmatter (missing closing ---)")]
Unclosed,
#[error("Invalid YAML: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("auth.access is 'roles' but no roles specified")]
MissingRoles,
#[error("Unknown scope '{scope}'. Available: {available:?}")]
UnknownScope {
scope: String,
available: Vec,
},
}
```
### Error Formatting
```rust
impl BuildError {
pub fn display_pretty(&self, ctx: &ErrorContext) -> String {
match self {
BuildError::Frontmatter { path, line, source } => {
let mut output = String::new();
// Error header
output.push_str(&format!(
"{}: {}\n",
"error".red().bold(),
source
));
// Location
output.push_str(&format!(
" {} {}",
"-->".blue().bold(),
path.display()
));
if let Some(line) = line {
output.push_str(&format!(":{}", line));
}
output.push('\n');
// Code snippet if available
if let Some(snippet) = ctx.get_snippet(path, *line) {
output.push_str(&format_snippet(&snippet));
}
// Suggestion
output.push_str(&format!(
"\n{}: {}\n",
"help".green().bold(),
self.suggestion()
));
output
}
// ... other variants
}
}
fn suggestion(&self) -> &str {
match self {
BuildError::Frontmatter { source: FrontmatterError::MissingRoles, .. } => {
"Add 'roles: [role1, role2]' to the auth section"
}
BuildError::Frontmatter { source: FrontmatterError::Unclosed, .. } => {
"Add '---' at the end of the frontmatter block"
}
// ... other suggestions
}
}
}
```
### Example Error Output
```
error: auth.access is 'roles' but no roles specified
--> src/admin/config.md:3
|
1 | ---
2 | auth:
3 | access: roles
| ^^^^^ missing 'roles' field
4 | ---
|
help: Add 'roles: [role1, role2]' to the auth section
Example:
auth:
access: roles
roles: [admin, editor]
```
### Error Collection Mode
For CI, collect all errors before failing:
```rust
pub struct BuildResult {
pub errors: Vec,
pub warnings: Vec,
pub notes: Vec,
}
impl BuildResult {
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
pub fn exit_code(&self) -> i32 {
if self.has_errors() { 1 } else { 0 }
}
pub fn print_summary(&self) {
if !self.errors.is_empty() {
eprintln!(
"{}: build failed with {} error(s)",
"error".red().bold(),
self.errors.len()
);
}
if !self.warnings.is_empty() {
eprintln!(
"{}: {} warning(s) emitted",
"warning".yellow().bold(),
self.warnings.len()
);
}
}
}
```
### CLI Flags
```bash
# Default: stop on first error
$ mdbook-htmx build
# Collect all errors
$ mdbook-htmx build --keep-going
error: 3 errors found
# Warnings as errors
$ mdbook-htmx build --deny warnings
# Verbose mode
$ mdbook-htmx build -v
trace: Loading configuration from book.toml
trace: Found 42 chapters
trace: Rendering chapter: Getting Started
...
```
### Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | Build errors |
| 2 | Configuration errors |
| 3 | I/O errors |
| 4 | Internal errors (bugs) |
### Logging
```rust
use tracing::{error, warn, info, debug, trace};
pub fn render_chapter(chapter: &Chapter) -> Result<()> {
debug!(path = %chapter.path.display(), "Rendering chapter");
let (frontmatter, content) = extract_frontmatter(&chapter.content)
.map_err(|e| {
error!(
path = %chapter.path.display(),
error = %e,
"Failed to parse frontmatter"
);
e
})?;
if frontmatter.auth.access == AccessLevel::Roles
&& frontmatter.auth.roles.is_empty()
{
warn!(
path = %chapter.path.display(),
"auth.access is 'roles' but no roles specified - treating as public"
);
}
trace!(
path = %chapter.path.display(),
auth = ?frontmatter.auth,
scopes = ?frontmatter.scopes,
"Frontmatter extracted"
);
// ... render
}
```
### Error Context Chain
```rust
use anyhow::{Context, Result};
fn render(ctx: &RenderContext) -> Result<()> {
let config = load_config(ctx)
.context("Failed to load [output.htmx] configuration")?;
let tera = init_tera(&config)
.context("Failed to initialize template engine")?;
for chapter in ctx.book.iter() {
render_chapter(&tera, chapter)
.with_context(|| format!(
"Failed to render chapter '{}'",
chapter.name
))?;
}
Ok(())
}
```
### Recovery Strategies
| Error Type | Recovery |
|------------|----------|
| Missing optional field | Use default |
| Unknown frontmatter field | Warn and ignore |
| Template syntax error | Fail (can't recover) |
| Missing chapter file | Skip with warning |
| I/O error | Retry once, then fail |
```rust
fn render_chapter(chapter: &Chapter) -> Result {
// Missing path means draft chapter - skip
let Some(path) = &chapter.path else {
debug!(name = %chapter.name, "Skipping draft chapter");
return Ok(RenderResult::Skipped);
};
// Try to parse frontmatter
let (frontmatter, content) = match extract_frontmatter(&chapter.content) {
Ok(result) => result,
Err(FrontmatterError::Unclosed) => {
// Recoverable: treat as no frontmatter
warn!(path = %path.display(), "Unclosed frontmatter, treating as none");
(Frontmatter::default(), chapter.content.clone())
}
Err(e) => return Err(e.into()),
};
// ... continue rendering
}
```
## Consequences
### Positive
- Clear, actionable error messages
- CI integration via exit codes
- Verbose mode for debugging
- Graceful degradation where possible
### Negative
- More code for error handling
- Error messages need maintenance
- Suggestions may become stale
### Mitigation
- Centralize error messages for easy updates
- Test error output in integration tests
- Document common errors and fixes
## Alternatives Considered
### Panic on Errors
Just panic with a message.
**Rejected** because:
- No context or suggestions
- Hard to debug
- Poor CI integration
### Result<(), String>
Return string error messages.
**Rejected** because:
- No structured error types
- Hard to match on error kinds
- No context chaining
## References
- [The Rust Programming Language - Error Handling](https://doc.rust-lang.org/book/ch09-00-error-handling.html)
- [thiserror crate](https://docs.rs/thiserror)
- [anyhow crate](https://docs.rs/anyhow)
- [Rustc Error Codes](https://doc.rust-lang.org/error_codes/)
- [Elm's Compiler Messages](https://elm-lang.org/news/compiler-errors-for-humans)
Contributor guide
Assessment
This issue has not been assessed yet.