Skip to main content

pinto/
error.rs

1//! The crate-wide error type.
2//!
3//! Covers domain-layer validation failures alongside I/O, parsing, and backend errors, so a
4//! single `Error`/`Result` flows through every layer.
5
6use crate::backlog::ItemId;
7use crate::i18n::{Localizer, Message};
8use crate::sprint::{SprintId, SprintState};
9use crate::template::TemplateName;
10use std::path::{Path, PathBuf};
11use thiserror::Error;
12
13/// Errors returned by pinto's domain, service, and persistence layers.
14#[derive(Debug, Error, PartialEq, Eq)]
15pub enum Error {
16    /// PBI ID format is invalid (expected an ASCII-letter prefix and decimal number).
17    #[error("invalid item id: {0:?} (expected `<ASCII_LETTERS>-<NUMBER>`)")]
18    InvalidItemId(String),
19
20    /// Invalid rank string (expected non-empty base-36 alphanumeric `0-9a-z`).
21    #[error("invalid rank: {0:?} (expected non-empty base-36 chars `0-9a-z`)")]
22    InvalidRank(String),
23
24    /// The automation plan is malformed or contains a command that is not permitted.
25    #[error(
26        "invalid automation plan (expected {{\"commands\":[[\"command\", \"arg\"], ...]}}; API keys are not accepted)"
27    )]
28    InvalidAutomationPlan,
29
30    /// A plan file or standard-input source could not be read.
31    #[error(
32        "cannot read automation plan source {path}: {message}; provide inline JSON, a readable file path, or `-` for standard input"
33    )]
34    AutomationPlanSource { path: PathBuf, message: String },
35
36    /// The transition destination does not exist in the workflow column.
37    #[error("unknown status: {0:?} is not a column in the workflow")]
38    UnknownStatus(String),
39
40    /// Title is empty.
41    #[error("title must not be empty")]
42    EmptyTitle,
43
44    /// Search pattern is invalid.
45    #[error("invalid search pattern: {0}")]
46    InvalidSearchPattern(String),
47
48    /// A filter option was used without the value required by its display mode.
49    #[error("invalid filter option: {0}")]
50    InvalidFilterOption(String),
51
52    /// No fields were specified to update (empty update of `edit`).
53    #[error("no fields to update")]
54    NothingToUpdate,
55
56    /// An attempt was made to set an empty string (only spaces) to the common DoD.
57    #[error("definition of done must not be empty")]
58    EmptyDod,
59
60    /// Template name cannot be used as a safe file name.
61    #[error("invalid template name: {0:?} (expected non-empty ASCII alphanumeric, `-`, or `_`)")]
62    InvalidTemplateName(String),
63
64    /// The specified template file does not exist.
65    #[error("template not found: {kind}/{name} (create {path})")]
66    TemplateNotFound {
67        kind: &'static str,
68        name: TemplateName,
69        path: PathBuf,
70    },
71
72    /// Cannot read template file as body text.
73    #[error("template cannot be read: {path} ({message}; fix or replace this plain-text file)")]
74    TemplateUnreadable { path: PathBuf, message: String },
75
76    /// No backlog item exists with the specified ID.
77    #[error("backlog item not found: {0}")]
78    NotFound(ItemId),
79
80    /// The PBI cannot be physically deleted because active PBIs still refer to it.
81    #[error(
82        "cannot remove {item}: referenced by {references}; remove these parent/dependency links first"
83    )]
84    ReferencedItem {
85        /// PBI targeted for permanent deletion.
86        item: ItemId,
87        /// IDs of active PBIs that refer to `item`.
88        references: String,
89    },
90
91    /// The sprint ID is malformed; it must contain one or more ASCII letters, digits, `-`, or `_`.
92    #[error("invalid sprint id: {0:?} (expected non-empty ASCII alphanumeric, `-`, or `_`)")]
93    InvalidSprintId(String),
94
95    /// Invalid sprint status string (expected `planned` / `active` / `closed`).
96    #[error("invalid sprint state: {0:?} (expected `planned`, `active`, or `closed`)")]
97    InvalidSprintState(String),
98
99    /// Sprint title is empty.
100    #[error("sprint title must not be empty")]
101    EmptySprintTitle,
102
103    /// Sprint goal is required before starting a sprint.
104    #[error("sprint goal must be set before starting the sprint")]
105    EmptySprintGoal,
106
107    /// The sprint state transition is invalid; only `planned → active → closed` is allowed.
108    #[error("invalid sprint transition: {from} -> {to} (allowed: planned -> active -> closed)")]
109    InvalidSprintTransition {
110        /// Current state.
111        from: SprintState,
112        /// The state to transition to.
113        to: SprintState,
114    },
115
116    /// A sprint with the specified ID cannot be found.
117    #[error("sprint not found: {0}")]
118    SprintNotFound(SprintId),
119
120    /// A sprint with the requested ID already exists.
121    ///
122    /// Creating a sprint never overwrites an existing sprint. Use `edit`/`remove` to manage the
123    /// existing record, or `start`/`close` to advance its state.
124    #[error("sprint already exists: {0} (use `sprint edit`/`remove` to manage it)")]
125    SprintExists(SprintId),
126
127    /// A PBI cannot be assigned to a Sprint after that Sprint has been closed.
128    #[error(
129        "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)"
130    )]
131    SprintClosed(SprintId),
132
133    /// The planned sprint date is incorrect (start later than end).
134    #[error("invalid sprint period: start {start} is after end {end}")]
135    InvalidSprintPeriod {
136        /// Planned start date.
137        start: chrono::NaiveDate,
138        /// Planned end date.
139        end: chrono::NaiveDate,
140    },
141
142    /// The number of working hours per day is not a finite number greater than or equal to 0.
143    #[error("daily work hours must be a finite number greater than or equal to 0 (got {0})")]
144    InvalidDailyWorkHours(String),
145
146    /// The deduction rate is not in the range of 0 to 1.
147    #[error("deduction factor must be a finite number from 0 to 1 (got {0})")]
148    InvalidDeductionFactor(String),
149
150    /// The specified number of holidays exceeds the number of days in the sprint period.
151    #[error(
152        "holiday days ({holidays}) must not exceed the {calendar_days} calendar day(s) in the sprint period"
153    )]
154    InvalidSprintHolidays { holidays: u32, calendar_days: u32 },
155
156    /// The sprint period required for capacity setting has not been set.
157    #[error(
158        "sprint {0} has no start/end dates (set them with `sprint edit {0} --start <YYYY-MM-DD> --end <YYYY-MM-DD>` before setting capacity)"
159    )]
160    SprintCapacityPeriodUnset(SprintId),
161
162    /// Capacity setting is not set.
163    #[error(
164        "sprint {0} has no capacity settings (set them with `sprint capacity {0} --daily-hours <HOURS> --holidays <DAYS> --deduction-factor <0..1>`)"
165    )]
166    SprintCapacityUnset(SprintId),
167
168    /// Planned dates (start and end) are not set for the sprint (burndown period cannot be determined).
169    #[error(
170        "sprint {0} has no start/end dates (set them with `sprint edit {0} --start <YYYY-MM-DD> --end <YYYY-MM-DD>`)"
171    )]
172    SprintPeriodUnset(SprintId),
173
174    /// No PBIs are assigned to the sprint (no burndown target).
175    #[error("sprint {0} has no assigned items (assign one with `sprint add {0} <item-id>`)")]
176    SprintEmpty(SprintId),
177
178    /// The backlog item is not assigned to the specified sprint.
179    #[error("{item} is not assigned to sprint {sprint}")]
180    NotInSprint {
181        /// Target PBI.
182        item: ItemId,
183        /// The sprint you tried to unassign.
184        sprint: SprintId,
185    },
186
187    /// A parent link would create a cycle by assigning the item to itself or one of its descendants.
188    ///
189    /// Parent-child links must remain acyclic so that the hierarchy stays a tree. Dependency links
190    /// (`depends_on`) have separate cycle handling and are not rejected by this variant.
191    #[error("setting parent of {child} to {parent} would create a cycle")]
192    ParentCycle {
193        /// PBI attempting to set parent.
194        child: ItemId,
195        /// Proposed parent; assigning it would create a cycle.
196        parent: ItemId,
197    },
198
199    /// Board is uninitialized (`.pinto/` is missing).
200    #[error(
201        "not a pinto board: {path} not found after checking this directory and its ancestors (run `pinto init`, or use `--dir PATH` / `PINTO_DIR`)"
202    )]
203    NotInitialized {
204        /// The expected path of `.pinto/`.
205        path: PathBuf,
206    },
207
208    /// File I/O failed.
209    #[error("i/o error at {path}: {message}")]
210    Io {
211        /// Target path.
212        path: PathBuf,
213        /// Message returned by the OS.
214        message: String,
215    },
216
217    /// Frontmatter or body parsing failed.
218    #[error("failed to parse {path}: {message}")]
219    Parse {
220        /// Target path.
221        path: PathBuf,
222        /// The message returned by the parser.
223        message: String,
224    },
225
226    /// Frontmatter delimiter (`+++`) is missing.
227    #[error("missing `+++` frontmatter delimiter in {path}")]
228    MissingFrontmatter {
229        /// Target path.
230        path: PathBuf,
231    },
232
233    /// The SQLite database uses a schema version this build does not understand.
234    #[error(
235        "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)"
236    )]
237    UnsupportedSqliteSchema {
238        /// SQLite database path.
239        path: PathBuf,
240        /// Raw value stored in the schema metadata.
241        found: String,
242        /// Only schema version currently understood by this build.
243        supported: u32,
244    },
245
246    /// The reorder reference is the same item (`reorder <id> --before/--after <id>` use one ID).
247    ///
248    /// An item cannot be moved relative to itself.
249    #[error("cannot reorder {0} relative to itself")]
250    SelfReference(ItemId),
251
252    /// `reorder <item> --before/--after <reference>` names a `reference` that is not a
253    /// sibling of `item` (different parent or different column).
254    ///
255    /// Reorder only changes order **within a sibling group** (same parent and same
256    /// status); use `edit --parent` to move an item between groups.
257    #[error(
258        "cannot reorder {item} relative to {reference}: they are not siblings (same parent and column); use `edit --parent` to regroup"
259    )]
260    NotSibling { item: ItemId, reference: ItemId },
261
262    /// Execution of a parallel I/O task failed (internal error such as panic).
263    #[error("background task failed: {0}")]
264    Task(String),
265
266    /// Git backend operation failed (`git` absent, command failure, etc.).
267    ///
268    /// For example, if `git` is not on `PATH`, install Git or set
269    /// `[storage] backend = "file"`.
270    #[error("git backend error: {0}")]
271    Git(String),
272
273    /// `undo` cannot proceed on the Git backend because there is no pinto mutation to revert.
274    ///
275    /// The repository has no commits yet, or the most recent commit was not authored by pinto.
276    /// The embedded reason explains how to recover manually.
277    #[error("cannot undo: {0}")]
278    UndoUnavailable(String),
279
280    /// `undo` is not supported because the selected backend keeps no history.
281    ///
282    /// Only the Git backend records each mutation as a commit. The message names the current
283    /// backend and lists the recovery options.
284    #[error(
285        "undo requires the git backend; the {backend} backend keeps no history. Recover from a backup or version-control checkout, or set `[storage] backend = \"git\"` to enable undo for future mutations"
286    )]
287    UndoUnsupported {
288        /// The configured backend that lacks history (`file` or `sqlite`).
289        backend: String,
290    },
291
292    /// Neither `$EDITOR` nor `$VISUAL` is set, so editing cannot start.
293    ///
294    /// Set one of those environment variables or provide the content directly with `--body`.
295    #[error(
296        "no editor configured: set $VISUAL or $EDITOR, or provide content directly (e.g. `pinto add <title> --body ...` or `pinto edit <id> --title ...`)"
297    )]
298    EditorNotSet,
299
300    /// The configured editor failed to start or terminate normally.
301    #[error("failed to launch editor `{editor}`: {message}")]
302    EditorLaunch {
303        /// The command selected from `$VISUAL` or `$EDITOR`.
304        editor: String,
305        /// OS startup error or editor exit status.
306        message: String,
307    },
308
309    /// The edited content is invalid and cannot be applied to the backlog item.
310    ///
311    /// This includes syntax errors and empty frontmatter titles. The original item remains unchanged;
312    /// correct the content and try again.
313    #[error("edited content is invalid: {message}")]
314    EditorInvalid {
315        /// The reason the parser/validator returned.
316        message: String,
317    },
318
319    /// Another process was holding the board lock (`.pinto/.lock`) and the write could not be serialized.
320    ///
321    /// An advisory lock prevents simultaneous writes from losing updates through last-writer-wins
322    /// behavior. The usual remedy is to wait for the other process. If a crash leaves the lock file
323    /// behind, confirm that no `pinto` process is running and remove the file manually.
324    #[error(
325        "board is locked by another process ({path}); retry shortly, or remove the file if no pinto is running"
326    )]
327    Locked {
328        /// Path of the lock file (`.pinto/.lock`) that could not be obtained.
329        path: PathBuf,
330    },
331}
332
333impl Error {
334    /// Return the stable machine-facing code for this error variant.
335    #[must_use]
336    pub const fn code(&self) -> &'static str {
337        match self {
338            Self::InvalidItemId(_) => "invalid-item-id",
339            Self::InvalidRank(_) => "invalid-rank",
340            Self::InvalidAutomationPlan => "invalid-automation-plan",
341            Self::AutomationPlanSource { .. } => "automation-plan-source",
342            Self::UnknownStatus(_) => "unknown-status",
343            Self::EmptyTitle => "empty-title",
344            Self::InvalidSearchPattern(_) => "invalid-search-pattern",
345            Self::InvalidFilterOption(_) => "invalid-filter-option",
346            Self::NothingToUpdate => "nothing-to-update",
347            Self::EmptyDod => "empty-dod",
348            Self::InvalidTemplateName(_) => "invalid-template-name",
349            Self::TemplateNotFound { .. } => "template-not-found",
350            Self::TemplateUnreadable { .. } => "template-unreadable",
351            Self::NotFound(_) => "not-found",
352            Self::ReferencedItem { .. } => "referenced-item",
353            Self::InvalidSprintId(_) => "invalid-sprint-id",
354            Self::InvalidSprintState(_) => "invalid-sprint-state",
355            Self::EmptySprintTitle => "empty-sprint-title",
356            Self::EmptySprintGoal => "empty-sprint-goal",
357            Self::InvalidSprintTransition { .. } => "invalid-sprint-transition",
358            Self::SprintNotFound(_) => "sprint-not-found",
359            Self::SprintExists(_) => "sprint-exists",
360            Self::SprintClosed(_) => "sprint-closed",
361            Self::InvalidSprintPeriod { .. } => "invalid-sprint-period",
362            Self::InvalidDailyWorkHours(_) => "invalid-daily-work-hours",
363            Self::InvalidDeductionFactor(_) => "invalid-deduction-factor",
364            Self::InvalidSprintHolidays { .. } => "invalid-sprint-holidays",
365            Self::SprintCapacityPeriodUnset(_) => "sprint-capacity-period-unset",
366            Self::SprintCapacityUnset(_) => "sprint-capacity-unset",
367            Self::SprintPeriodUnset(_) => "sprint-period-unset",
368            Self::SprintEmpty(_) => "sprint-empty",
369            Self::NotInSprint { .. } => "not-in-sprint",
370            Self::ParentCycle { .. } => "parent-cycle",
371            Self::NotInitialized { .. } => "not-initialized",
372            Self::Io { .. } => "io",
373            Self::Parse { .. } => "parse",
374            Self::MissingFrontmatter { .. } => "missing-frontmatter",
375            Self::UnsupportedSqliteSchema { .. } => "unsupported-sqlite-schema",
376            Self::SelfReference(_) => "self-reference",
377            Self::NotSibling { .. } => "not-sibling",
378            Self::Task(_) => "task",
379            Self::Git(_) => "git",
380            Self::UndoUnavailable(_) => "undo-unavailable",
381            Self::UndoUnsupported { .. } => "undo-unsupported",
382            Self::EditorNotSet => "editor-not-set",
383            Self::EditorLaunch { .. } => "editor-launch",
384            Self::EditorInvalid { .. } => "editor-invalid",
385            Self::Locked { .. } => "locked",
386        }
387    }
388
389    /// Render this error through the selected locale for CLI/TUI boundaries.
390    ///
391    /// Values originating in the operating system, Git, TOML, or another parser are passed to
392    /// the catalog unchanged. They are intentionally not translated because their exact wording
393    /// is the actionable diagnostic users need when repairing an external condition. The
394    /// `Display` implementation generated by `thiserror` remains the English fallback for
395    /// library consumers that do not have a locale boundary. The English catalog is kept equal to
396    /// that `Display` output for every variant, so choosing English does not change a diagnostic.
397    pub fn localized(&self, localizer: &Localizer) -> String {
398        macro_rules! message {
399            ($message:expr $(, $name:expr => $value:expr)* $(,)?) => {{
400                let values = [$(($name, $value.to_string())),*];
401                localizer.format(
402                    $message,
403                    values.iter().map(|(name, value)| (*name, value.as_str())),
404                )
405            }};
406        }
407
408        match self {
409            Self::InvalidItemId(value) => {
410                message!(Message::ErrorInvalidItemId, "value" => format!("{value:?}"))
411            }
412            Self::InvalidRank(value) => {
413                message!(Message::ErrorInvalidRank, "value" => format!("{value:?}"))
414            }
415            Self::InvalidAutomationPlan => localizer.text(Message::ErrorInvalidAutomationPlan),
416            Self::AutomationPlanSource {
417                path,
418                message: detail,
419            } => message!(
420                Message::ErrorAutomationPlanSource,
421                "path" => path.display(),
422                "message" => detail,
423            ),
424            Self::UnknownStatus(status) => message!(
425                Message::ErrorUnknownStatus,
426                "status" => format!("{status:?}")
427            ),
428            Self::EmptyTitle => localizer.text(Message::ErrorEmptyTitle),
429            Self::InvalidSearchPattern(pattern) => message!(
430                Message::ErrorInvalidSearchPattern,
431                "pattern" => pattern,
432            ),
433            Self::InvalidFilterOption(option) => message!(
434                Message::ErrorInvalidFilterOption,
435                "option" => option,
436            ),
437            Self::NothingToUpdate => localizer.text(Message::ErrorNothingToUpdate),
438            Self::EmptyDod => localizer.text(Message::ErrorEmptyDod),
439            Self::InvalidTemplateName(name) => message!(
440                Message::ErrorInvalidTemplateName,
441                "name" => format!("{name:?}"),
442            ),
443            Self::TemplateNotFound { kind, name, path } => message!(
444                Message::ErrorTemplateNotFound,
445                "kind" => kind,
446                "name" => name,
447                "path" => path.display(),
448            ),
449            Self::TemplateUnreadable {
450                path,
451                message: detail,
452            } => message!(
453                Message::ErrorTemplateUnreadable,
454                "path" => path.display(),
455                "message" => detail,
456            ),
457            Self::NotFound(id) => message!(Message::ErrorNotFound, "id" => id),
458            Self::ReferencedItem { item, references } => message!(
459                Message::ErrorReferencedItem,
460                "item" => item,
461                "references" => references,
462            ),
463            Self::InvalidSprintId(id) => message!(
464                Message::ErrorInvalidSprintId,
465                "id" => format!("{id:?}"),
466            ),
467            Self::InvalidSprintState(state) => message!(
468                Message::ErrorInvalidSprintState,
469                "state" => format!("{state:?}"),
470            ),
471            Self::EmptySprintTitle => localizer.text(Message::ErrorEmptySprintTitle),
472            Self::EmptySprintGoal => localizer.text(Message::ErrorEmptySprintGoal),
473            Self::InvalidSprintTransition { from, to } => message!(
474                Message::ErrorInvalidSprintTransition,
475                "from" => from,
476                "to" => to,
477            ),
478            Self::SprintNotFound(id) => message!(Message::ErrorSprintNotFound, "id" => id),
479            Self::SprintExists(id) => message!(Message::ErrorSprintExists, "id" => id),
480            Self::SprintClosed(id) => message!(Message::ErrorSprintClosed, "id" => id),
481            Self::InvalidSprintPeriod { start, end } => message!(
482                Message::ErrorInvalidSprintPeriod,
483                "start" => start,
484                "end" => end,
485            ),
486            Self::InvalidDailyWorkHours(value) => message!(
487                Message::ErrorInvalidDailyWorkHours,
488                "value" => value,
489            ),
490            Self::InvalidDeductionFactor(value) => message!(
491                Message::ErrorInvalidDeductionFactor,
492                "value" => value,
493            ),
494            Self::InvalidSprintHolidays {
495                holidays,
496                calendar_days,
497            } => message!(
498                Message::ErrorInvalidSprintHolidays,
499                "holidays" => holidays,
500                "calendar_days" => calendar_days,
501            ),
502            Self::SprintCapacityPeriodUnset(id) => message!(
503                Message::ErrorSprintCapacityPeriodUnset,
504                "id" => id,
505            ),
506            Self::SprintCapacityUnset(id) => {
507                message!(Message::ErrorSprintCapacityUnset, "id" => id)
508            }
509            Self::SprintPeriodUnset(id) => message!(Message::ErrorSprintPeriodUnset, "id" => id),
510            Self::SprintEmpty(id) => message!(Message::ErrorSprintEmpty, "id" => id),
511            Self::NotInSprint { item, sprint } => message!(
512                Message::ErrorNotInSprint,
513                "item" => item,
514                "sprint" => sprint,
515            ),
516            Self::ParentCycle { child, parent } => message!(
517                Message::ErrorParentCycle,
518                "child" => child,
519                "parent" => parent,
520            ),
521            Self::NotInitialized { path } => message!(
522                Message::ErrorNotInitialized,
523                "path" => path.display(),
524            ),
525            Self::Io {
526                path,
527                message: detail,
528            } => message!(
529                Message::ErrorIo,
530                "path" => path.display(),
531                "message" => detail,
532            ),
533            Self::Parse {
534                path,
535                message: detail,
536            } => message!(
537                Message::ErrorParse,
538                "path" => path.display(),
539                "message" => detail,
540            ),
541            Self::MissingFrontmatter { path } => message!(
542                Message::ErrorMissingFrontmatter,
543                "path" => path.display(),
544            ),
545            Self::UnsupportedSqliteSchema {
546                path,
547                found,
548                supported,
549            } => message!(
550                Message::ErrorUnsupportedSqliteSchema,
551                "path" => path.display(),
552                "found" => format!("{found:?}"),
553                "supported" => supported,
554            ),
555            Self::SelfReference(id) => message!(Message::ErrorSelfReference, "id" => id),
556            Self::NotSibling { item, reference } => message!(
557                Message::ErrorNotSibling,
558                "item" => item,
559                "reference" => reference,
560            ),
561            Self::Task(detail) => message!(Message::ErrorTask, "message" => detail),
562            Self::Git(detail) => message!(Message::ErrorGit, "message" => detail),
563            Self::UndoUnavailable(reason) => {
564                message!(Message::ErrorUndoUnavailable, "reason" => reason)
565            }
566            Self::UndoUnsupported { backend } => {
567                message!(Message::ErrorUndoUnsupported, "backend" => backend)
568            }
569            Self::EditorNotSet => localizer.text(Message::ErrorEditorNotSet),
570            Self::EditorLaunch {
571                editor,
572                message: detail,
573            } => message!(
574                Message::ErrorEditorLaunch,
575                "editor" => editor,
576                "message" => detail,
577            ),
578            Self::EditorInvalid { message: detail } => message!(
579                Message::ErrorEditorInvalid,
580                "message" => detail,
581            ),
582            Self::Locked { path } => message!(Message::ErrorLocked, "path" => path.display()),
583        }
584    }
585
586    /// Convert I/O errors to [`Error::Io`] with target path.
587    pub(crate) fn io(path: &Path, source: &std::io::Error) -> Self {
588        Error::Io {
589            path: path.to_path_buf(),
590            message: source.to_string(),
591        }
592    }
593
594    /// Convert parser/serializer errors to [`Error::Parse`] with target path.
595    pub(crate) fn parse(path: &Path, message: impl Into<String>) -> Self {
596        Error::Parse {
597            path: path.to_path_buf(),
598            message: message.into(),
599        }
600    }
601
602    /// Convert asynchronous task join failures ([`tokio::task::JoinError`], etc.) to [`Error::Task`].
603    pub(crate) fn task(source: impl std::fmt::Display) -> Self {
604        Error::Task(source.to_string())
605    }
606
607    /// Is this error caused by the user (bad input or a missing target)?
608    ///
609    /// The CLI maps user-fixable errors to exit code 1 and unexpected I/O or task failures to
610    /// code 2. Add any new user-facing variant here so the classification stays in one place and
611    /// no subcommand has to repeat it.
612    #[must_use]
613    pub fn is_user_error(&self) -> bool {
614        matches!(
615            self,
616            Error::InvalidItemId(_)
617                | Error::InvalidRank(_)
618                | Error::InvalidAutomationPlan
619                | Error::AutomationPlanSource { .. }
620                | Error::UnknownStatus(_)
621                | Error::EmptyTitle
622                | Error::InvalidSearchPattern(_)
623                | Error::InvalidFilterOption(_)
624                | Error::NothingToUpdate
625                | Error::EmptyDod
626                | Error::InvalidTemplateName(_)
627                | Error::TemplateNotFound { .. }
628                | Error::TemplateUnreadable { .. }
629                | Error::NotFound(_)
630                | Error::ReferencedItem { .. }
631                | Error::InvalidSprintId(_)
632                | Error::InvalidSprintState(_)
633                | Error::EmptySprintTitle
634                | Error::EmptySprintGoal
635                | Error::InvalidSprintTransition { .. }
636                | Error::SprintNotFound(_)
637                | Error::SprintExists(_)
638                | Error::SprintClosed(_)
639                | Error::InvalidSprintPeriod { .. }
640                | Error::InvalidDailyWorkHours(_)
641                | Error::InvalidDeductionFactor(_)
642                | Error::InvalidSprintHolidays { .. }
643                | Error::SprintCapacityPeriodUnset(_)
644                | Error::SprintCapacityUnset(_)
645                | Error::SprintPeriodUnset(_)
646                | Error::SprintEmpty(_)
647                | Error::NotInSprint { .. }
648                | Error::ParentCycle { .. }
649                | Error::SelfReference(_)
650                | Error::NotSibling { .. }
651                | Error::NotInitialized { .. }
652                | Error::Parse { .. }
653                | Error::MissingFrontmatter { .. }
654                | Error::UnsupportedSqliteSchema { .. }
655                | Error::EditorNotSet
656                | Error::EditorLaunch { .. }
657                | Error::EditorInvalid { .. }
658                | Error::Locked { .. }
659                | Error::UndoUnavailable(_)
660                | Error::UndoUnsupported { .. }
661        )
662    }
663}
664
665/// Common crate `Result` alias.
666pub type Result<T> = std::result::Result<T, Error>;
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    /// Variants classified as user-fixable (exit code 1).
673    #[test]
674    fn user_facing_variants_are_user_errors() {
675        let user: &[Error] = &[
676            Error::InvalidItemId("x".into()),
677            Error::InvalidRank("".into()),
678            Error::Parse {
679                path: PathBuf::from("f"),
680                message: "bad".into(),
681            },
682            Error::MissingFrontmatter {
683                path: PathBuf::from("f"),
684            },
685            Error::UnknownStatus("archived".into()),
686            Error::EmptyTitle,
687            Error::NothingToUpdate,
688            Error::EmptyDod,
689            Error::NotFound(ItemId::new("T", 1)),
690            Error::ReferencedItem {
691                item: ItemId::new("T", 1),
692                references: "T-2".into(),
693            },
694            Error::InvalidSprintId("S 1".into()),
695            Error::InvalidSprintState("archived".into()),
696            Error::EmptySprintTitle,
697            Error::EmptySprintGoal,
698            Error::InvalidSprintTransition {
699                from: SprintState::Active,
700                to: SprintState::Active,
701            },
702            Error::SprintNotFound(SprintId::new("S-1").unwrap()),
703            Error::SprintExists(SprintId::new("S-1").unwrap()),
704            Error::SprintClosed(SprintId::new("S-1").unwrap()),
705            Error::InvalidSprintPeriod {
706                start: chrono::NaiveDate::from_ymd_opt(2026, 7, 20).unwrap(),
707                end: chrono::NaiveDate::from_ymd_opt(2026, 7, 6).unwrap(),
708            },
709            Error::SprintPeriodUnset(SprintId::new("S-1").unwrap()),
710            Error::SprintEmpty(SprintId::new("S-1").unwrap()),
711            Error::NotInSprint {
712                item: ItemId::new("T", 1),
713                sprint: SprintId::new("S-1").unwrap(),
714            },
715            Error::ParentCycle {
716                child: ItemId::new("T", 1),
717                parent: ItemId::new("T", 2),
718            },
719            Error::SelfReference(ItemId::new("T", 1)),
720            Error::NotSibling {
721                item: ItemId::new("T", 1),
722                reference: ItemId::new("T", 2),
723            },
724            Error::EditorNotSet,
725            Error::EditorLaunch {
726                editor: "missing-editor".into(),
727                message: "not found".into(),
728            },
729            Error::EditorInvalid {
730                message: "empty title".into(),
731            },
732            Error::NotInitialized {
733                path: PathBuf::from(".pinto"),
734            },
735            Error::Locked {
736                path: PathBuf::from(".pinto/.lock"),
737            },
738            Error::UnsupportedSqliteSchema {
739                path: PathBuf::from(".pinto/board.sqlite3"),
740                found: "99".into(),
741                supported: 1,
742            },
743        ];
744        for e in user {
745            assert!(e.is_user_error(), "expected user error: {e:?}");
746        }
747    }
748
749    /// Variants classified as internal or unexpected (exit code 2), such as I/O and task failures
750    /// that cannot be fixed with user input.
751    #[test]
752    fn internal_variants_are_not_user_errors() {
753        let internal: &[Error] = &[
754            Error::Io {
755                path: PathBuf::from("f"),
756                message: "boom".into(),
757            },
758            Error::Task("panicked".into()),
759            Error::Git("git not found".into()),
760        ];
761        for e in internal {
762            assert!(!e.is_user_error(), "expected internal error: {e:?}");
763        }
764    }
765
766    #[test]
767    fn domain_errors_have_stable_codes_and_localized_bodies() {
768        let japanese = crate::i18n::localizer_from(Some("ja_JP.UTF-8"), None);
769        assert_eq!(Error::EmptyTitle.code(), "empty-title");
770        assert_eq!(Error::Git("git failed".into()).code(), "git");
771        assert_eq!(
772            Error::EmptyTitle.localized(&japanese),
773            "タイトルは空にできません。"
774        );
775
776        let external = Error::Parse {
777            path: PathBuf::from("config.toml"),
778            message: "expected newline".into(),
779        };
780        let rendered = external.localized(&japanese);
781        assert!(rendered.contains("config.toml"));
782        assert!(rendered.contains("expected newline"));
783        assert!(rendered.contains("解析"));
784    }
785
786    #[test]
787    fn english_localization_preserves_display_quoting_for_special_values() {
788        let english = crate::i18n::localizer_from(Some("en_US.UTF-8"), None);
789        let errors = [
790            Error::InvalidItemId("bad\"id".into()),
791            Error::InvalidRank("bad\nrank".into()),
792            Error::UnknownStatus("two words".into()),
793            Error::InvalidTemplateName("a\"b".into()),
794            Error::InvalidSprintId("S \"1".into()),
795            Error::InvalidSprintState("unknown state".into()),
796            Error::UnsupportedSqliteSchema {
797                path: PathBuf::from("board.sqlite3"),
798                found: "1\"2".into(),
799                supported: 1,
800            },
801        ];
802
803        for error in errors {
804            assert_eq!(
805                error.localized(&english),
806                error.to_string(),
807                "special values must use the same quoting at the English boundary: {error:?}"
808            );
809        }
810    }
811
812    #[test]
813    fn every_error_variant_resolves_from_the_catalog() {
814        let english = crate::i18n::localizer_from(Some("en_US.UTF-8"), None);
815        let japanese = crate::i18n::localizer_from(Some("ja_JP.UTF-8"), None);
816        let template = TemplateName::new("item").unwrap();
817        let sprint = SprintId::new("S-1").unwrap();
818        let errors = [
819            Error::InvalidItemId("bad".into()),
820            Error::InvalidRank("!".into()),
821            Error::InvalidAutomationPlan,
822            Error::AutomationPlanSource {
823                path: PathBuf::from("plan.json"),
824                message: "permission denied".into(),
825            },
826            Error::UnknownStatus("blocked".into()),
827            Error::EmptyTitle,
828            Error::InvalidSearchPattern("[".into()),
829            Error::InvalidFilterOption("--label".into()),
830            Error::NothingToUpdate,
831            Error::EmptyDod,
832            Error::InvalidTemplateName("../item".into()),
833            Error::TemplateNotFound {
834                kind: "item",
835                name: template.clone(),
836                path: PathBuf::from(".pinto/templates/item/item.md"),
837            },
838            Error::TemplateUnreadable {
839                path: PathBuf::from("item.md"),
840                message: "invalid UTF-8".into(),
841            },
842            Error::NotFound(ItemId::new("T", 1)),
843            Error::ReferencedItem {
844                item: ItemId::new("T", 1),
845                references: "T-2".into(),
846            },
847            Error::InvalidSprintId("S 1".into()),
848            Error::InvalidSprintState("paused".into()),
849            Error::EmptySprintTitle,
850            Error::EmptySprintGoal,
851            Error::InvalidSprintTransition {
852                from: SprintState::Active,
853                to: SprintState::Planned,
854            },
855            Error::SprintNotFound(sprint.clone()),
856            Error::SprintExists(sprint.clone()),
857            Error::SprintClosed(sprint.clone()),
858            Error::InvalidSprintPeriod {
859                start: chrono::NaiveDate::from_ymd_opt(2026, 7, 20).unwrap(),
860                end: chrono::NaiveDate::from_ymd_opt(2026, 7, 6).unwrap(),
861            },
862            Error::InvalidDailyWorkHours("NaN".into()),
863            Error::InvalidDeductionFactor("2".into()),
864            Error::InvalidSprintHolidays {
865                holidays: 8,
866                calendar_days: 5,
867            },
868            Error::SprintCapacityPeriodUnset(sprint.clone()),
869            Error::SprintCapacityUnset(sprint.clone()),
870            Error::SprintPeriodUnset(sprint.clone()),
871            Error::SprintEmpty(sprint.clone()),
872            Error::NotInSprint {
873                item: ItemId::new("T", 1),
874                sprint: sprint.clone(),
875            },
876            Error::ParentCycle {
877                child: ItemId::new("T", 1),
878                parent: ItemId::new("T", 2),
879            },
880            Error::NotInitialized {
881                path: PathBuf::from(".pinto"),
882            },
883            Error::Io {
884                path: PathBuf::from("file"),
885                message: "permission denied".into(),
886            },
887            Error::Parse {
888                path: PathBuf::from("file"),
889                message: "invalid TOML".into(),
890            },
891            Error::MissingFrontmatter {
892                path: PathBuf::from("task.md"),
893            },
894            Error::UnsupportedSqliteSchema {
895                path: PathBuf::from("board.sqlite3"),
896                found: "99".into(),
897                supported: 1,
898            },
899            Error::SelfReference(ItemId::new("T", 1)),
900            Error::NotSibling {
901                item: ItemId::new("T", 1),
902                reference: ItemId::new("T", 2),
903            },
904            Error::Task("join failed".into()),
905            Error::Git("git failed".into()),
906            Error::EditorNotSet,
907            Error::EditorLaunch {
908                editor: "missing-editor".into(),
909                message: "not found".into(),
910            },
911            Error::EditorInvalid {
912                message: "empty title".into(),
913            },
914            Error::Locked {
915                path: PathBuf::from(".pinto/.lock"),
916            },
917        ];
918
919        let mismatches: Vec<_> = errors
920            .iter()
921            .filter_map(|error| {
922                let localized = error.localized(&english);
923                let display = error.to_string();
924                (localized != display)
925                    .then(|| format!("{error:?}: localized={localized:?}, display={display:?}"))
926            })
927            .collect();
928        assert!(
929            mismatches.is_empty(),
930            "English localization must be the library Display fallback:\n{}",
931            mismatches.join("\n")
932        );
933
934        for error in errors {
935            let rendered = error.localized(&japanese);
936            assert!(
937                !rendered.starts_with("error-"),
938                "missing catalog entry: {error:?}"
939            );
940        }
941    }
942}