round-5 follow-up r2: migrate all thiserror Display attributes to catalog

Completes the i18n sweep started in the previous commit. All
remaining hand-rolled user-facing English strings inside
thiserror #[error(...)] attributes have been moved into the
catalog. Drops the thiserror dependency entirely.

Twelve error types migrated:

- dsl::action::UnknownAction         → parse.custom.unknown_action
- dsl::parser::ParseError            → parse.error_wrapper + parse.empty
- dsl::value::ValueError             → value.{type_mismatch,format}
- persistence::csv_io::CsvError      → persistence.csv.*
- persistence::mod::PersistenceError → persistence.{io,encode}
- persistence::yaml::YamlError       → persistence.yaml.*
- persistence::migrations::MigrateError → persistence.migrate.*
- project::lock::LockError           → project.lock.*
- project::naming::NamingError       → project.naming.*
- project::naming::UserNameError     → project.user_name.*
- project::mod::ProjectError         → project.{path_not_found,...}
- project::mod::SafeDeleteError      → project.safe_delete.*
- archive::ArchiveError              → archive.*
- cli::ArgsError                     → cli.*
- db::DbError                        → db.error.*

Pattern per type: drop thiserror::Error derive, write manual
Display calling crate::t!(), keep #[from] semantics via
explicit From impls, override Error::source() where applicable
so #[source]-style chaining is preserved.

Why this matters (user rationale): "fine to have fallbacks for
errors that are purely technical, but lift the output to a
place where it can be localized later and where an adjustment
with friendly text is easily possible if any of them become
part of the happy path." All surface strings now live in
en-US.yaml and can be reworded or localized without touching
Rust source.

Tests: 769 passing, 0 failed, 1 ignored. Clippy clean with
-D warnings. Cargo.toml: drop thiserror = "2.0.18".
This commit is contained in:
claude@clouddev1
2026-05-13 21:24:51 +00:00
parent 1e06490572
commit 6ca297579e
17 changed files with 680 additions and 117 deletions
+37 -8
View File
@@ -33,30 +33,59 @@ pub struct Lock {
path: PathBuf,
}
#[derive(Debug, thiserror::Error)]
#[derive(Debug)]
pub enum LockError {
#[error(
"project is already open in another rdbms-playground process \
(pid {pid} on host `{hostname}`); close that process or \
remove `{path}` if you're sure it's not running"
)]
AlreadyHeld {
pid: u32,
hostname: String,
path: PathBuf,
},
#[error("could not write lock file `{path}`: {source}")]
Write {
path: PathBuf,
source: std::io::Error,
},
#[error("could not read existing lock file `{path}`: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
}
impl std::fmt::Display for LockError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AlreadyHeld {
pid,
hostname,
path,
} => f.write_str(&crate::t!(
"project.lock.already_held",
pid = pid,
hostname = hostname,
path = path.display(),
)),
Self::Write { path, source } => f.write_str(&crate::t!(
"project.lock.write",
path = path.display(),
source = source,
)),
Self::Read { path, source } => f.write_str(&crate::t!(
"project.lock.read",
path = path.display(),
source = source,
)),
}
}
}
impl std::error::Error for LockError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::AlreadyHeld { .. } => None,
Self::Write { source, .. } | Self::Read { source, .. } => Some(source),
}
}
}
impl Lock {
/// Attempt to acquire the lock for `project_dir`. The
/// directory itself must exist; `Project::open` /
+84 -16
View File
@@ -238,28 +238,72 @@ pub enum ProjectKind {
Named,
}
#[derive(Debug, thiserror::Error)]
#[derive(Debug)]
pub enum ProjectError {
#[error("could not determine the OS-standard data directory; pass --data-dir to override")]
DataRootUnavailable,
#[error("project path `{0}` does not exist")]
PathNotFound(PathBuf),
#[error(
"path `{0}` does not look like a project directory \
(no `project.yaml` and no `playground.db`)"
)]
NotAProject(PathBuf),
#[error("path `{0}` already exists; pick a different name or remove it first")]
AlreadyExists(PathBuf),
#[error("filesystem error at `{path}`: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error(transparent)]
Naming(#[from] NamingError),
#[error(transparent)]
Lock(#[from] LockError),
Naming(NamingError),
Lock(LockError),
}
impl std::fmt::Display for ProjectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DataRootUnavailable => {
f.write_str(&crate::t!("project.data_root_unavailable"))
}
Self::PathNotFound(p) => f.write_str(&crate::t!(
"project.path_not_found",
path = p.display(),
)),
Self::NotAProject(p) => f.write_str(&crate::t!(
"project.not_a_project",
path = p.display(),
)),
Self::AlreadyExists(p) => f.write_str(&crate::t!(
"project.already_exists",
path = p.display(),
)),
Self::Io { path, source } => f.write_str(&crate::t!(
"project.io",
path = path.display(),
source = source,
)),
// Naming and Lock are transparent — their own Display
// impls already route through the catalog.
Self::Naming(inner) => std::fmt::Display::fmt(inner, f),
Self::Lock(inner) => std::fmt::Display::fmt(inner, f),
}
}
}
impl std::error::Error for ProjectError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Naming(inner) => Some(inner),
Self::Lock(inner) => Some(inner),
_ => None,
}
}
}
impl From<NamingError> for ProjectError {
fn from(e: NamingError) -> Self {
Self::Naming(e)
}
}
impl From<LockError> for ProjectError {
fn from(e: LockError) -> Self {
Self::Lock(e)
}
}
impl Project {
@@ -463,20 +507,44 @@ const ALLOWED_PROJECT_ENTRIES: &[&str] = &[
/// Reasons `safely_delete_temp_project` refuses to delete a
/// path. Each variant carries enough detail to surface in a
/// log warning.
#[derive(Debug, thiserror::Error)]
#[derive(Debug)]
pub enum SafeDeleteError {
#[error("refusing to delete `{}`: {reason}", path.display())]
Refused {
path: PathBuf,
reason: &'static str,
},
#[error("io error on `{}`: {source}", path.display())]
Io {
path: PathBuf,
source: std::io::Error,
},
}
impl std::fmt::Display for SafeDeleteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Refused { path, reason } => f.write_str(&crate::t!(
"project.safe_delete.refused",
path = path.display(),
reason = reason,
)),
Self::Io { path, source } => f.write_str(&crate::t!(
"project.safe_delete.io",
path = path.display(),
source = source,
)),
}
}
}
impl std::error::Error for SafeDeleteError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Refused { .. } => None,
}
}
}
/// Conservatively delete a temp project's directory.
///
/// Stacks every guard we can think of so a bug elsewhere (or
+34 -7
View File
@@ -32,14 +32,29 @@ fn words() -> Vec<&'static str> {
.collect()
}
#[derive(Debug, thiserror::Error)]
#[derive(Debug)]
pub enum NamingError {
#[error("wordlist must contain at least 3 entries; found {0}")]
WordlistTooSmall(usize),
#[error("could not generate a non-colliding temp project name after {0} attempts")]
TooManyCollisions(usize),
}
impl std::fmt::Display for NamingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::WordlistTooSmall(n) => f.write_str(&crate::t!(
"project.naming.wordlist_too_small",
count = n,
)),
Self::TooManyCollisions(n) => f.write_str(&crate::t!(
"project.naming.too_many_collisions",
attempts = n,
)),
}
}
}
impl std::error::Error for NamingError {}
/// Generate a fresh temp project directory name.
///
/// Checks for collisions against `parent_dir` (typically
@@ -162,16 +177,28 @@ pub fn validate_user_name(name: &str) -> Result<(), UserNameError> {
Ok(())
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[derive(Debug, PartialEq, Eq)]
pub enum UserNameError {
#[error("project name cannot be empty")]
Empty,
#[error("project name cannot start with `.`")]
LeadingDot,
#[error("project name cannot contain `{0}`; use letters, digits, `-`, `_`, or `.` only")]
InvalidChar(char),
}
impl std::fmt::Display for UserNameError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => f.write_str(&crate::t!("project.user_name.empty")),
Self::LeadingDot => f.write_str(&crate::t!("project.user_name.leading_dot")),
Self::InvalidChar(c) => f.write_str(&crate::t!(
"project.user_name.invalid_char",
ch = c,
)),
}
}
}
impl std::error::Error for UserNameError {}
#[cfg(test)]
mod tests {
use super::*;