use crate::backlog::ItemId;
use crate::i18n::{Localizer, Message};
use crate::sprint::{SprintId, SprintState};
use crate::template::TemplateName;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum Error {
#[error("invalid item id: {0:?} (expected `<ASCII_LETTERS>-<NUMBER>`)")]
InvalidItemId(String),
#[error("invalid rank: {0:?} (expected non-empty base-36 chars `0-9a-z`)")]
InvalidRank(String),
#[error(
"invalid automation plan (expected {{\"commands\":[[\"command\", \"arg\"], ...]}}; API keys are not accepted)"
)]
InvalidAutomationPlan,
#[error(
"cannot read automation plan source {path}: {message}; provide inline JSON, a readable file path, or `-` for standard input"
)]
AutomationPlanSource { path: PathBuf, message: String },
#[error("unknown status: {0:?} is not a column in the workflow")]
UnknownStatus(String),
#[error("title must not be empty")]
EmptyTitle,
#[error("invalid search pattern: {0}")]
InvalidSearchPattern(String),
#[error("invalid filter option: {0}")]
InvalidFilterOption(String),
#[error("no fields to update")]
NothingToUpdate,
#[error("definition of done must not be empty")]
EmptyDod,
#[error("invalid template name: {0:?} (expected non-empty ASCII alphanumeric, `-`, or `_`)")]
InvalidTemplateName(String),
#[error("template not found: {kind}/{name} (create {path})")]
TemplateNotFound {
kind: &'static str,
name: TemplateName,
path: PathBuf,
},
#[error("template cannot be read: {path} ({message}; fix or replace this plain-text file)")]
TemplateUnreadable { path: PathBuf, message: String },
#[error("backlog item not found: {0}")]
NotFound(ItemId),
#[error(
"cannot remove {item}: referenced by {references}; remove these parent/dependency links first"
)]
ReferencedItem {
item: ItemId,
references: String,
},
#[error("invalid sprint id: {0:?} (expected non-empty ASCII alphanumeric, `-`, or `_`)")]
InvalidSprintId(String),
#[error("invalid sprint state: {0:?} (expected `planned`, `active`, or `closed`)")]
InvalidSprintState(String),
#[error("sprint title must not be empty")]
EmptySprintTitle,
#[error("sprint goal must be set before starting the sprint")]
EmptySprintGoal,
#[error("invalid sprint transition: {from} -> {to} (allowed: planned -> active -> closed)")]
InvalidSprintTransition {
from: SprintState,
to: SprintState,
},
#[error("sprint not found: {0}")]
SprintNotFound(SprintId),
#[error("sprint already exists: {0} (use `sprint edit`/`remove` to manage it)")]
SprintExists(SprintId),
#[error(
"cannot assign a PBI to closed sprint {0} (assign it to a planned or active sprint instead; use `sprint unassign {0} <item-id>` to remove an existing assignment)"
)]
SprintClosed(SprintId),
#[error("invalid sprint period: start {start} is after end {end}")]
InvalidSprintPeriod {
start: chrono::NaiveDate,
end: chrono::NaiveDate,
},
#[error("daily work hours must be a finite number greater than or equal to 0 (got {0})")]
InvalidDailyWorkHours(String),
#[error("deduction factor must be a finite number from 0 to 1 (got {0})")]
InvalidDeductionFactor(String),
#[error(
"holiday days ({holidays}) must not exceed the {calendar_days} calendar day(s) in the sprint period"
)]
InvalidSprintHolidays { holidays: u32, calendar_days: u32 },
#[error(
"sprint {0} has no start/end dates (set them with `sprint edit {0} --start <YYYY-MM-DD> --end <YYYY-MM-DD>` before setting capacity)"
)]
SprintCapacityPeriodUnset(SprintId),
#[error(
"sprint {0} has no capacity settings (set them with `sprint capacity {0} --daily-hours <HOURS> --holidays <DAYS> --deduction-factor <0..1>`)"
)]
SprintCapacityUnset(SprintId),
#[error(
"sprint {0} has no start/end dates (set them with `sprint edit {0} --start <YYYY-MM-DD> --end <YYYY-MM-DD>`)"
)]
SprintPeriodUnset(SprintId),
#[error("sprint {0} has no assigned items (assign one with `sprint add {0} <item-id>`)")]
SprintEmpty(SprintId),
#[error("{item} is not assigned to sprint {sprint}")]
NotInSprint {
item: ItemId,
sprint: SprintId,
},
#[error("setting parent of {child} to {parent} would create a cycle")]
ParentCycle {
child: ItemId,
parent: ItemId,
},
#[error("not a pinto board: {path} not found (run `pinto init` first)")]
NotInitialized {
path: PathBuf,
},
#[error("i/o error at {path}: {message}")]
Io {
path: PathBuf,
message: String,
},
#[error("failed to parse {path}: {message}")]
Parse {
path: PathBuf,
message: String,
},
#[error("missing `+++` frontmatter delimiter in {path}")]
MissingFrontmatter {
path: PathBuf,
},
#[error(
"unsupported SQLite schema at {path}: found version {found:?}, but pinto supports version {supported}; upgrade or downgrade pinto to a compatible version, or recreate the database (automatic migration is not available)"
)]
UnsupportedSqliteSchema {
path: PathBuf,
found: String,
supported: u32,
},
#[error("cannot reorder {0} relative to itself")]
SelfReference(ItemId),
#[error(
"cannot reorder {item} relative to {reference}: they are not siblings (same parent and column); use `edit --parent` to regroup"
)]
NotSibling { item: ItemId, reference: ItemId },
#[error("background task failed: {0}")]
Task(String),
#[error("git backend error: {0}")]
Git(String),
#[error(
"no editor configured: set $VISUAL or $EDITOR, or provide content directly (e.g. `pinto add <title> --body ...` or `pinto edit <id> --title ...`)"
)]
EditorNotSet,
#[error("failed to launch editor `{editor}`: {message}")]
EditorLaunch {
editor: String,
message: String,
},
#[error("edited content is invalid: {message}")]
EditorInvalid {
message: String,
},
#[error(
"board is locked by another process ({path}); retry shortly, or remove the file if no pinto is running"
)]
Locked {
path: PathBuf,
},
}
impl Error {
#[must_use]
pub const fn code(&self) -> &'static str {
match self {
Self::InvalidItemId(_) => "invalid-item-id",
Self::InvalidRank(_) => "invalid-rank",
Self::InvalidAutomationPlan => "invalid-automation-plan",
Self::AutomationPlanSource { .. } => "automation-plan-source",
Self::UnknownStatus(_) => "unknown-status",
Self::EmptyTitle => "empty-title",
Self::InvalidSearchPattern(_) => "invalid-search-pattern",
Self::InvalidFilterOption(_) => "invalid-filter-option",
Self::NothingToUpdate => "nothing-to-update",
Self::EmptyDod => "empty-dod",
Self::InvalidTemplateName(_) => "invalid-template-name",
Self::TemplateNotFound { .. } => "template-not-found",
Self::TemplateUnreadable { .. } => "template-unreadable",
Self::NotFound(_) => "not-found",
Self::ReferencedItem { .. } => "referenced-item",
Self::InvalidSprintId(_) => "invalid-sprint-id",
Self::InvalidSprintState(_) => "invalid-sprint-state",
Self::EmptySprintTitle => "empty-sprint-title",
Self::EmptySprintGoal => "empty-sprint-goal",
Self::InvalidSprintTransition { .. } => "invalid-sprint-transition",
Self::SprintNotFound(_) => "sprint-not-found",
Self::SprintExists(_) => "sprint-exists",
Self::SprintClosed(_) => "sprint-closed",
Self::InvalidSprintPeriod { .. } => "invalid-sprint-period",
Self::InvalidDailyWorkHours(_) => "invalid-daily-work-hours",
Self::InvalidDeductionFactor(_) => "invalid-deduction-factor",
Self::InvalidSprintHolidays { .. } => "invalid-sprint-holidays",
Self::SprintCapacityPeriodUnset(_) => "sprint-capacity-period-unset",
Self::SprintCapacityUnset(_) => "sprint-capacity-unset",
Self::SprintPeriodUnset(_) => "sprint-period-unset",
Self::SprintEmpty(_) => "sprint-empty",
Self::NotInSprint { .. } => "not-in-sprint",
Self::ParentCycle { .. } => "parent-cycle",
Self::NotInitialized { .. } => "not-initialized",
Self::Io { .. } => "io",
Self::Parse { .. } => "parse",
Self::MissingFrontmatter { .. } => "missing-frontmatter",
Self::UnsupportedSqliteSchema { .. } => "unsupported-sqlite-schema",
Self::SelfReference(_) => "self-reference",
Self::NotSibling { .. } => "not-sibling",
Self::Task(_) => "task",
Self::Git(_) => "git",
Self::EditorNotSet => "editor-not-set",
Self::EditorLaunch { .. } => "editor-launch",
Self::EditorInvalid { .. } => "editor-invalid",
Self::Locked { .. } => "locked",
}
}
pub fn localized(&self, localizer: &Localizer) -> String {
macro_rules! message {
($message:expr $(, $name:expr => $value:expr)* $(,)?) => {{
let values = [$(($name, $value.to_string())),*];
localizer.format(
$message,
values.iter().map(|(name, value)| (*name, value.as_str())),
)
}};
}
match self {
Self::InvalidItemId(value) => message!(Message::ErrorInvalidItemId, "value" => value),
Self::InvalidRank(value) => message!(Message::ErrorInvalidRank, "value" => value),
Self::InvalidAutomationPlan => localizer.text(Message::ErrorInvalidAutomationPlan),
Self::AutomationPlanSource {
path,
message: detail,
} => message!(
Message::ErrorAutomationPlanSource,
"path" => path.display(),
"message" => detail,
),
Self::UnknownStatus(status) => {
message!(Message::ErrorUnknownStatus, "status" => status)
}
Self::EmptyTitle => localizer.text(Message::ErrorEmptyTitle),
Self::InvalidSearchPattern(pattern) => message!(
Message::ErrorInvalidSearchPattern,
"pattern" => pattern,
),
Self::InvalidFilterOption(option) => message!(
Message::ErrorInvalidFilterOption,
"option" => option,
),
Self::NothingToUpdate => localizer.text(Message::ErrorNothingToUpdate),
Self::EmptyDod => localizer.text(Message::ErrorEmptyDod),
Self::InvalidTemplateName(name) => message!(
Message::ErrorInvalidTemplateName,
"name" => name,
),
Self::TemplateNotFound { kind, name, path } => message!(
Message::ErrorTemplateNotFound,
"kind" => kind,
"name" => name,
"path" => path.display(),
),
Self::TemplateUnreadable {
path,
message: detail,
} => message!(
Message::ErrorTemplateUnreadable,
"path" => path.display(),
"message" => detail,
),
Self::NotFound(id) => message!(Message::ErrorNotFound, "id" => id),
Self::ReferencedItem { item, references } => message!(
Message::ErrorReferencedItem,
"item" => item,
"references" => references,
),
Self::InvalidSprintId(id) => message!(Message::ErrorInvalidSprintId, "id" => id),
Self::InvalidSprintState(state) => {
message!(Message::ErrorInvalidSprintState, "state" => state)
}
Self::EmptySprintTitle => localizer.text(Message::ErrorEmptySprintTitle),
Self::EmptySprintGoal => localizer.text(Message::ErrorEmptySprintGoal),
Self::InvalidSprintTransition { from, to } => message!(
Message::ErrorInvalidSprintTransition,
"from" => from,
"to" => to,
),
Self::SprintNotFound(id) => message!(Message::ErrorSprintNotFound, "id" => id),
Self::SprintExists(id) => message!(Message::ErrorSprintExists, "id" => id),
Self::SprintClosed(id) => message!(Message::ErrorSprintClosed, "id" => id),
Self::InvalidSprintPeriod { start, end } => message!(
Message::ErrorInvalidSprintPeriod,
"start" => start,
"end" => end,
),
Self::InvalidDailyWorkHours(value) => message!(
Message::ErrorInvalidDailyWorkHours,
"value" => value,
),
Self::InvalidDeductionFactor(value) => message!(
Message::ErrorInvalidDeductionFactor,
"value" => value,
),
Self::InvalidSprintHolidays {
holidays,
calendar_days,
} => message!(
Message::ErrorInvalidSprintHolidays,
"holidays" => holidays,
"calendar_days" => calendar_days,
),
Self::SprintCapacityPeriodUnset(id) => message!(
Message::ErrorSprintCapacityPeriodUnset,
"id" => id,
),
Self::SprintCapacityUnset(id) => {
message!(Message::ErrorSprintCapacityUnset, "id" => id)
}
Self::SprintPeriodUnset(id) => message!(Message::ErrorSprintPeriodUnset, "id" => id),
Self::SprintEmpty(id) => message!(Message::ErrorSprintEmpty, "id" => id),
Self::NotInSprint { item, sprint } => message!(
Message::ErrorNotInSprint,
"item" => item,
"sprint" => sprint,
),
Self::ParentCycle { child, parent } => message!(
Message::ErrorParentCycle,
"child" => child,
"parent" => parent,
),
Self::NotInitialized { path } => message!(
Message::ErrorNotInitialized,
"path" => path.display(),
),
Self::Io {
path,
message: detail,
} => message!(
Message::ErrorIo,
"path" => path.display(),
"message" => detail,
),
Self::Parse {
path,
message: detail,
} => message!(
Message::ErrorParse,
"path" => path.display(),
"message" => detail,
),
Self::MissingFrontmatter { path } => message!(
Message::ErrorMissingFrontmatter,
"path" => path.display(),
),
Self::UnsupportedSqliteSchema {
path,
found,
supported,
} => message!(
Message::ErrorUnsupportedSqliteSchema,
"path" => path.display(),
"found" => found,
"supported" => supported,
),
Self::SelfReference(id) => message!(Message::ErrorSelfReference, "id" => id),
Self::NotSibling { item, reference } => message!(
Message::ErrorNotSibling,
"item" => item,
"reference" => reference,
),
Self::Task(detail) => message!(Message::ErrorTask, "message" => detail),
Self::Git(detail) => message!(Message::ErrorGit, "message" => detail),
Self::EditorNotSet => localizer.text(Message::ErrorEditorNotSet),
Self::EditorLaunch {
editor,
message: detail,
} => message!(
Message::ErrorEditorLaunch,
"editor" => editor,
"message" => detail,
),
Self::EditorInvalid { message: detail } => message!(
Message::ErrorEditorInvalid,
"message" => detail,
),
Self::Locked { path } => message!(Message::ErrorLocked, "path" => path.display()),
}
}
pub(crate) fn io(path: &Path, source: &std::io::Error) -> Self {
Error::Io {
path: path.to_path_buf(),
message: source.to_string(),
}
}
pub(crate) fn parse(path: &Path, message: impl Into<String>) -> Self {
Error::Parse {
path: path.to_path_buf(),
message: message.into(),
}
}
pub(crate) fn task(source: impl std::fmt::Display) -> Self {
Error::Task(source.to_string())
}
#[must_use]
pub fn is_user_error(&self) -> bool {
matches!(
self,
Error::InvalidItemId(_)
| Error::InvalidRank(_)
| Error::InvalidAutomationPlan
| Error::AutomationPlanSource { .. }
| Error::UnknownStatus(_)
| Error::EmptyTitle
| Error::InvalidSearchPattern(_)
| Error::InvalidFilterOption(_)
| Error::NothingToUpdate
| Error::EmptyDod
| Error::InvalidTemplateName(_)
| Error::TemplateNotFound { .. }
| Error::TemplateUnreadable { .. }
| Error::NotFound(_)
| Error::ReferencedItem { .. }
| Error::InvalidSprintId(_)
| Error::InvalidSprintState(_)
| Error::EmptySprintTitle
| Error::EmptySprintGoal
| Error::InvalidSprintTransition { .. }
| Error::SprintNotFound(_)
| Error::SprintExists(_)
| Error::SprintClosed(_)
| Error::InvalidSprintPeriod { .. }
| Error::InvalidDailyWorkHours(_)
| Error::InvalidDeductionFactor(_)
| Error::InvalidSprintHolidays { .. }
| Error::SprintCapacityPeriodUnset(_)
| Error::SprintCapacityUnset(_)
| Error::SprintPeriodUnset(_)
| Error::SprintEmpty(_)
| Error::NotInSprint { .. }
| Error::ParentCycle { .. }
| Error::SelfReference(_)
| Error::NotSibling { .. }
| Error::NotInitialized { .. }
| Error::Parse { .. }
| Error::MissingFrontmatter { .. }
| Error::UnsupportedSqliteSchema { .. }
| Error::EditorNotSet
| Error::EditorLaunch { .. }
| Error::EditorInvalid { .. }
| Error::Locked { .. }
)
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_facing_variants_are_user_errors() {
let user: &[Error] = &[
Error::InvalidItemId("x".into()),
Error::InvalidRank("".into()),
Error::Parse {
path: PathBuf::from("f"),
message: "bad".into(),
},
Error::MissingFrontmatter {
path: PathBuf::from("f"),
},
Error::UnknownStatus("archived".into()),
Error::EmptyTitle,
Error::NothingToUpdate,
Error::EmptyDod,
Error::NotFound(ItemId::new("T", 1)),
Error::ReferencedItem {
item: ItemId::new("T", 1),
references: "T-2".into(),
},
Error::InvalidSprintId("S 1".into()),
Error::InvalidSprintState("archived".into()),
Error::EmptySprintTitle,
Error::EmptySprintGoal,
Error::InvalidSprintTransition {
from: SprintState::Active,
to: SprintState::Active,
},
Error::SprintNotFound(SprintId::new("S-1").unwrap()),
Error::SprintExists(SprintId::new("S-1").unwrap()),
Error::SprintClosed(SprintId::new("S-1").unwrap()),
Error::InvalidSprintPeriod {
start: chrono::NaiveDate::from_ymd_opt(2026, 7, 20).unwrap(),
end: chrono::NaiveDate::from_ymd_opt(2026, 7, 6).unwrap(),
},
Error::SprintPeriodUnset(SprintId::new("S-1").unwrap()),
Error::SprintEmpty(SprintId::new("S-1").unwrap()),
Error::NotInSprint {
item: ItemId::new("T", 1),
sprint: SprintId::new("S-1").unwrap(),
},
Error::ParentCycle {
child: ItemId::new("T", 1),
parent: ItemId::new("T", 2),
},
Error::SelfReference(ItemId::new("T", 1)),
Error::NotSibling {
item: ItemId::new("T", 1),
reference: ItemId::new("T", 2),
},
Error::EditorNotSet,
Error::EditorLaunch {
editor: "missing-editor".into(),
message: "not found".into(),
},
Error::EditorInvalid {
message: "empty title".into(),
},
Error::NotInitialized {
path: PathBuf::from(".pinto"),
},
Error::Locked {
path: PathBuf::from(".pinto/.lock"),
},
Error::UnsupportedSqliteSchema {
path: PathBuf::from(".pinto/board.sqlite3"),
found: "99".into(),
supported: 1,
},
];
for e in user {
assert!(e.is_user_error(), "expected user error: {e:?}");
}
}
#[test]
fn internal_variants_are_not_user_errors() {
let internal: &[Error] = &[
Error::Io {
path: PathBuf::from("f"),
message: "boom".into(),
},
Error::Task("panicked".into()),
Error::Git("git not found".into()),
];
for e in internal {
assert!(!e.is_user_error(), "expected internal error: {e:?}");
}
}
#[test]
fn domain_errors_have_stable_codes_and_localized_bodies() {
let japanese = crate::i18n::localizer_from(Some("ja_JP.UTF-8"), None);
assert_eq!(Error::EmptyTitle.code(), "empty-title");
assert_eq!(Error::Git("git failed".into()).code(), "git");
assert_eq!(
Error::EmptyTitle.localized(&japanese),
"タイトルは空にできません。"
);
let external = Error::Parse {
path: PathBuf::from("config.toml"),
message: "expected newline".into(),
};
let rendered = external.localized(&japanese);
assert!(rendered.contains("config.toml"));
assert!(rendered.contains("expected newline"));
assert!(rendered.contains("解析"));
}
#[test]
fn every_error_variant_resolves_from_the_catalog() {
let japanese = crate::i18n::localizer_from(Some("ja_JP.UTF-8"), None);
let template = TemplateName::new("item").unwrap();
let sprint = SprintId::new("S-1").unwrap();
let errors = [
Error::InvalidItemId("bad".into()),
Error::InvalidRank("!".into()),
Error::InvalidAutomationPlan,
Error::AutomationPlanSource {
path: PathBuf::from("plan.json"),
message: "permission denied".into(),
},
Error::UnknownStatus("blocked".into()),
Error::EmptyTitle,
Error::InvalidSearchPattern("[".into()),
Error::InvalidFilterOption("--label".into()),
Error::NothingToUpdate,
Error::EmptyDod,
Error::InvalidTemplateName("../item".into()),
Error::TemplateNotFound {
kind: "item",
name: template.clone(),
path: PathBuf::from(".pinto/templates/item/item.md"),
},
Error::TemplateUnreadable {
path: PathBuf::from("item.md"),
message: "invalid UTF-8".into(),
},
Error::NotFound(ItemId::new("T", 1)),
Error::ReferencedItem {
item: ItemId::new("T", 1),
references: "T-2".into(),
},
Error::InvalidSprintId("S 1".into()),
Error::InvalidSprintState("paused".into()),
Error::EmptySprintTitle,
Error::EmptySprintGoal,
Error::InvalidSprintTransition {
from: SprintState::Active,
to: SprintState::Planned,
},
Error::SprintNotFound(sprint.clone()),
Error::SprintExists(sprint.clone()),
Error::SprintClosed(sprint.clone()),
Error::InvalidSprintPeriod {
start: chrono::NaiveDate::from_ymd_opt(2026, 7, 20).unwrap(),
end: chrono::NaiveDate::from_ymd_opt(2026, 7, 6).unwrap(),
},
Error::InvalidDailyWorkHours("NaN".into()),
Error::InvalidDeductionFactor("2".into()),
Error::InvalidSprintHolidays {
holidays: 8,
calendar_days: 5,
},
Error::SprintCapacityPeriodUnset(sprint.clone()),
Error::SprintCapacityUnset(sprint.clone()),
Error::SprintPeriodUnset(sprint.clone()),
Error::SprintEmpty(sprint.clone()),
Error::NotInSprint {
item: ItemId::new("T", 1),
sprint: sprint.clone(),
},
Error::ParentCycle {
child: ItemId::new("T", 1),
parent: ItemId::new("T", 2),
},
Error::NotInitialized {
path: PathBuf::from(".pinto"),
},
Error::Io {
path: PathBuf::from("file"),
message: "permission denied".into(),
},
Error::Parse {
path: PathBuf::from("file"),
message: "invalid TOML".into(),
},
Error::MissingFrontmatter {
path: PathBuf::from("task.md"),
},
Error::UnsupportedSqliteSchema {
path: PathBuf::from("board.sqlite3"),
found: "99".into(),
supported: 1,
},
Error::SelfReference(ItemId::new("T", 1)),
Error::NotSibling {
item: ItemId::new("T", 1),
reference: ItemId::new("T", 2),
},
Error::Task("join failed".into()),
Error::Git("git failed".into()),
Error::EditorNotSet,
Error::EditorLaunch {
editor: "missing-editor".into(),
message: "not found".into(),
},
Error::EditorInvalid {
message: "empty title".into(),
},
Error::Locked {
path: PathBuf::from(".pinto/.lock"),
},
];
for error in errors {
let rendered = error.localized(&japanese);
assert!(
!rendered.starts_with("error-"),
"missing catalog entry: {error:?}"
);
}
}
}