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    /// Neither `$EDITOR` nor `$VISUAL` is set, so editing cannot start.
274    ///
275    /// Set one of those environment variables or provide the content directly with `--body`.
276    #[error(
277        "no editor configured: set $VISUAL or $EDITOR, or provide content directly (e.g. `pinto add <title> --body ...` or `pinto edit <id> --title ...`)"
278    )]
279    EditorNotSet,
280
281    /// The configured editor failed to start or terminate normally.
282    #[error("failed to launch editor `{editor}`: {message}")]
283    EditorLaunch {
284        /// The command selected from `$VISUAL` or `$EDITOR`.
285        editor: String,
286        /// OS startup error or editor exit status.
287        message: String,
288    },
289
290    /// The edited content is invalid and cannot be applied to the backlog item.
291    ///
292    /// This includes syntax errors and empty frontmatter titles. The original item remains unchanged;
293    /// correct the content and try again.
294    #[error("edited content is invalid: {message}")]
295    EditorInvalid {
296        /// The reason the parser/validator returned.
297        message: String,
298    },
299
300    /// Another process was holding the board lock (`.pinto/.lock`) and the write could not be serialized.
301    ///
302    /// An advisory lock prevents simultaneous writes from losing updates through last-writer-wins
303    /// behavior. The usual remedy is to wait for the other process. If a crash leaves the lock file
304    /// behind, confirm that no `pinto` process is running and remove the file manually.
305    #[error(
306        "board is locked by another process ({path}); retry shortly, or remove the file if no pinto is running"
307    )]
308    Locked {
309        /// Path of the lock file (`.pinto/.lock`) that could not be obtained.
310        path: PathBuf,
311    },
312}
313
314impl Error {
315    /// Return the stable machine-facing code for this error variant.
316    #[must_use]
317    pub const fn code(&self) -> &'static str {
318        match self {
319            Self::InvalidItemId(_) => "invalid-item-id",
320            Self::InvalidRank(_) => "invalid-rank",
321            Self::InvalidAutomationPlan => "invalid-automation-plan",
322            Self::AutomationPlanSource { .. } => "automation-plan-source",
323            Self::UnknownStatus(_) => "unknown-status",
324            Self::EmptyTitle => "empty-title",
325            Self::InvalidSearchPattern(_) => "invalid-search-pattern",
326            Self::InvalidFilterOption(_) => "invalid-filter-option",
327            Self::NothingToUpdate => "nothing-to-update",
328            Self::EmptyDod => "empty-dod",
329            Self::InvalidTemplateName(_) => "invalid-template-name",
330            Self::TemplateNotFound { .. } => "template-not-found",
331            Self::TemplateUnreadable { .. } => "template-unreadable",
332            Self::NotFound(_) => "not-found",
333            Self::ReferencedItem { .. } => "referenced-item",
334            Self::InvalidSprintId(_) => "invalid-sprint-id",
335            Self::InvalidSprintState(_) => "invalid-sprint-state",
336            Self::EmptySprintTitle => "empty-sprint-title",
337            Self::EmptySprintGoal => "empty-sprint-goal",
338            Self::InvalidSprintTransition { .. } => "invalid-sprint-transition",
339            Self::SprintNotFound(_) => "sprint-not-found",
340            Self::SprintExists(_) => "sprint-exists",
341            Self::SprintClosed(_) => "sprint-closed",
342            Self::InvalidSprintPeriod { .. } => "invalid-sprint-period",
343            Self::InvalidDailyWorkHours(_) => "invalid-daily-work-hours",
344            Self::InvalidDeductionFactor(_) => "invalid-deduction-factor",
345            Self::InvalidSprintHolidays { .. } => "invalid-sprint-holidays",
346            Self::SprintCapacityPeriodUnset(_) => "sprint-capacity-period-unset",
347            Self::SprintCapacityUnset(_) => "sprint-capacity-unset",
348            Self::SprintPeriodUnset(_) => "sprint-period-unset",
349            Self::SprintEmpty(_) => "sprint-empty",
350            Self::NotInSprint { .. } => "not-in-sprint",
351            Self::ParentCycle { .. } => "parent-cycle",
352            Self::NotInitialized { .. } => "not-initialized",
353            Self::Io { .. } => "io",
354            Self::Parse { .. } => "parse",
355            Self::MissingFrontmatter { .. } => "missing-frontmatter",
356            Self::UnsupportedSqliteSchema { .. } => "unsupported-sqlite-schema",
357            Self::SelfReference(_) => "self-reference",
358            Self::NotSibling { .. } => "not-sibling",
359            Self::Task(_) => "task",
360            Self::Git(_) => "git",
361            Self::EditorNotSet => "editor-not-set",
362            Self::EditorLaunch { .. } => "editor-launch",
363            Self::EditorInvalid { .. } => "editor-invalid",
364            Self::Locked { .. } => "locked",
365        }
366    }
367
368    /// Render this error through the selected locale for CLI/TUI boundaries.
369    ///
370    /// Values originating in the operating system, Git, TOML, or another parser are passed to
371    /// the catalog unchanged. They are intentionally not translated because their exact wording
372    /// is the actionable diagnostic users need when repairing an external condition. The
373    /// `Display` implementation generated by `thiserror` remains the English fallback for
374    /// library consumers that do not have a locale boundary. The English catalog is kept equal to
375    /// that `Display` output for every variant, so choosing English does not change a diagnostic.
376    pub fn localized(&self, localizer: &Localizer) -> String {
377        macro_rules! message {
378            ($message:expr $(, $name:expr => $value:expr)* $(,)?) => {{
379                let values = [$(($name, $value.to_string())),*];
380                localizer.format(
381                    $message,
382                    values.iter().map(|(name, value)| (*name, value.as_str())),
383                )
384            }};
385        }
386
387        match self {
388            Self::InvalidItemId(value) => {
389                message!(Message::ErrorInvalidItemId, "value" => format!("{value:?}"))
390            }
391            Self::InvalidRank(value) => {
392                message!(Message::ErrorInvalidRank, "value" => format!("{value:?}"))
393            }
394            Self::InvalidAutomationPlan => localizer.text(Message::ErrorInvalidAutomationPlan),
395            Self::AutomationPlanSource {
396                path,
397                message: detail,
398            } => message!(
399                Message::ErrorAutomationPlanSource,
400                "path" => path.display(),
401                "message" => detail,
402            ),
403            Self::UnknownStatus(status) => message!(
404                Message::ErrorUnknownStatus,
405                "status" => format!("{status:?}")
406            ),
407            Self::EmptyTitle => localizer.text(Message::ErrorEmptyTitle),
408            Self::InvalidSearchPattern(pattern) => message!(
409                Message::ErrorInvalidSearchPattern,
410                "pattern" => pattern,
411            ),
412            Self::InvalidFilterOption(option) => message!(
413                Message::ErrorInvalidFilterOption,
414                "option" => option,
415            ),
416            Self::NothingToUpdate => localizer.text(Message::ErrorNothingToUpdate),
417            Self::EmptyDod => localizer.text(Message::ErrorEmptyDod),
418            Self::InvalidTemplateName(name) => message!(
419                Message::ErrorInvalidTemplateName,
420                "name" => format!("{name:?}"),
421            ),
422            Self::TemplateNotFound { kind, name, path } => message!(
423                Message::ErrorTemplateNotFound,
424                "kind" => kind,
425                "name" => name,
426                "path" => path.display(),
427            ),
428            Self::TemplateUnreadable {
429                path,
430                message: detail,
431            } => message!(
432                Message::ErrorTemplateUnreadable,
433                "path" => path.display(),
434                "message" => detail,
435            ),
436            Self::NotFound(id) => message!(Message::ErrorNotFound, "id" => id),
437            Self::ReferencedItem { item, references } => message!(
438                Message::ErrorReferencedItem,
439                "item" => item,
440                "references" => references,
441            ),
442            Self::InvalidSprintId(id) => message!(
443                Message::ErrorInvalidSprintId,
444                "id" => format!("{id:?}"),
445            ),
446            Self::InvalidSprintState(state) => message!(
447                Message::ErrorInvalidSprintState,
448                "state" => format!("{state:?}"),
449            ),
450            Self::EmptySprintTitle => localizer.text(Message::ErrorEmptySprintTitle),
451            Self::EmptySprintGoal => localizer.text(Message::ErrorEmptySprintGoal),
452            Self::InvalidSprintTransition { from, to } => message!(
453                Message::ErrorInvalidSprintTransition,
454                "from" => from,
455                "to" => to,
456            ),
457            Self::SprintNotFound(id) => message!(Message::ErrorSprintNotFound, "id" => id),
458            Self::SprintExists(id) => message!(Message::ErrorSprintExists, "id" => id),
459            Self::SprintClosed(id) => message!(Message::ErrorSprintClosed, "id" => id),
460            Self::InvalidSprintPeriod { start, end } => message!(
461                Message::ErrorInvalidSprintPeriod,
462                "start" => start,
463                "end" => end,
464            ),
465            Self::InvalidDailyWorkHours(value) => message!(
466                Message::ErrorInvalidDailyWorkHours,
467                "value" => value,
468            ),
469            Self::InvalidDeductionFactor(value) => message!(
470                Message::ErrorInvalidDeductionFactor,
471                "value" => value,
472            ),
473            Self::InvalidSprintHolidays {
474                holidays,
475                calendar_days,
476            } => message!(
477                Message::ErrorInvalidSprintHolidays,
478                "holidays" => holidays,
479                "calendar_days" => calendar_days,
480            ),
481            Self::SprintCapacityPeriodUnset(id) => message!(
482                Message::ErrorSprintCapacityPeriodUnset,
483                "id" => id,
484            ),
485            Self::SprintCapacityUnset(id) => {
486                message!(Message::ErrorSprintCapacityUnset, "id" => id)
487            }
488            Self::SprintPeriodUnset(id) => message!(Message::ErrorSprintPeriodUnset, "id" => id),
489            Self::SprintEmpty(id) => message!(Message::ErrorSprintEmpty, "id" => id),
490            Self::NotInSprint { item, sprint } => message!(
491                Message::ErrorNotInSprint,
492                "item" => item,
493                "sprint" => sprint,
494            ),
495            Self::ParentCycle { child, parent } => message!(
496                Message::ErrorParentCycle,
497                "child" => child,
498                "parent" => parent,
499            ),
500            Self::NotInitialized { path } => message!(
501                Message::ErrorNotInitialized,
502                "path" => path.display(),
503            ),
504            Self::Io {
505                path,
506                message: detail,
507            } => message!(
508                Message::ErrorIo,
509                "path" => path.display(),
510                "message" => detail,
511            ),
512            Self::Parse {
513                path,
514                message: detail,
515            } => message!(
516                Message::ErrorParse,
517                "path" => path.display(),
518                "message" => detail,
519            ),
520            Self::MissingFrontmatter { path } => message!(
521                Message::ErrorMissingFrontmatter,
522                "path" => path.display(),
523            ),
524            Self::UnsupportedSqliteSchema {
525                path,
526                found,
527                supported,
528            } => message!(
529                Message::ErrorUnsupportedSqliteSchema,
530                "path" => path.display(),
531                "found" => format!("{found:?}"),
532                "supported" => supported,
533            ),
534            Self::SelfReference(id) => message!(Message::ErrorSelfReference, "id" => id),
535            Self::NotSibling { item, reference } => message!(
536                Message::ErrorNotSibling,
537                "item" => item,
538                "reference" => reference,
539            ),
540            Self::Task(detail) => message!(Message::ErrorTask, "message" => detail),
541            Self::Git(detail) => message!(Message::ErrorGit, "message" => detail),
542            Self::EditorNotSet => localizer.text(Message::ErrorEditorNotSet),
543            Self::EditorLaunch {
544                editor,
545                message: detail,
546            } => message!(
547                Message::ErrorEditorLaunch,
548                "editor" => editor,
549                "message" => detail,
550            ),
551            Self::EditorInvalid { message: detail } => message!(
552                Message::ErrorEditorInvalid,
553                "message" => detail,
554            ),
555            Self::Locked { path } => message!(Message::ErrorLocked, "path" => path.display()),
556        }
557    }
558
559    /// Convert I/O errors to [`Error::Io`] with target path.
560    pub(crate) fn io(path: &Path, source: &std::io::Error) -> Self {
561        Error::Io {
562            path: path.to_path_buf(),
563            message: source.to_string(),
564        }
565    }
566
567    /// Convert parser/serializer errors to [`Error::Parse`] with target path.
568    pub(crate) fn parse(path: &Path, message: impl Into<String>) -> Self {
569        Error::Parse {
570            path: path.to_path_buf(),
571            message: message.into(),
572        }
573    }
574
575    /// Convert asynchronous task join failures ([`tokio::task::JoinError`], etc.) to [`Error::Task`].
576    pub(crate) fn task(source: impl std::fmt::Display) -> Self {
577        Error::Task(source.to_string())
578    }
579
580    /// Is this error caused by the user (bad input or a missing target)?
581    ///
582    /// The CLI maps user-fixable errors to exit code 1 and unexpected I/O or task failures to
583    /// code 2. Add any new user-facing variant here so the classification stays in one place and
584    /// no subcommand has to repeat it.
585    #[must_use]
586    pub fn is_user_error(&self) -> bool {
587        matches!(
588            self,
589            Error::InvalidItemId(_)
590                | Error::InvalidRank(_)
591                | Error::InvalidAutomationPlan
592                | Error::AutomationPlanSource { .. }
593                | Error::UnknownStatus(_)
594                | Error::EmptyTitle
595                | Error::InvalidSearchPattern(_)
596                | Error::InvalidFilterOption(_)
597                | Error::NothingToUpdate
598                | Error::EmptyDod
599                | Error::InvalidTemplateName(_)
600                | Error::TemplateNotFound { .. }
601                | Error::TemplateUnreadable { .. }
602                | Error::NotFound(_)
603                | Error::ReferencedItem { .. }
604                | Error::InvalidSprintId(_)
605                | Error::InvalidSprintState(_)
606                | Error::EmptySprintTitle
607                | Error::EmptySprintGoal
608                | Error::InvalidSprintTransition { .. }
609                | Error::SprintNotFound(_)
610                | Error::SprintExists(_)
611                | Error::SprintClosed(_)
612                | Error::InvalidSprintPeriod { .. }
613                | Error::InvalidDailyWorkHours(_)
614                | Error::InvalidDeductionFactor(_)
615                | Error::InvalidSprintHolidays { .. }
616                | Error::SprintCapacityPeriodUnset(_)
617                | Error::SprintCapacityUnset(_)
618                | Error::SprintPeriodUnset(_)
619                | Error::SprintEmpty(_)
620                | Error::NotInSprint { .. }
621                | Error::ParentCycle { .. }
622                | Error::SelfReference(_)
623                | Error::NotSibling { .. }
624                | Error::NotInitialized { .. }
625                | Error::Parse { .. }
626                | Error::MissingFrontmatter { .. }
627                | Error::UnsupportedSqliteSchema { .. }
628                | Error::EditorNotSet
629                | Error::EditorLaunch { .. }
630                | Error::EditorInvalid { .. }
631                | Error::Locked { .. }
632        )
633    }
634}
635
636/// Common crate `Result` alias.
637pub type Result<T> = std::result::Result<T, Error>;
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642
643    /// Variants classified as user-fixable (exit code 1).
644    #[test]
645    fn user_facing_variants_are_user_errors() {
646        let user: &[Error] = &[
647            Error::InvalidItemId("x".into()),
648            Error::InvalidRank("".into()),
649            Error::Parse {
650                path: PathBuf::from("f"),
651                message: "bad".into(),
652            },
653            Error::MissingFrontmatter {
654                path: PathBuf::from("f"),
655            },
656            Error::UnknownStatus("archived".into()),
657            Error::EmptyTitle,
658            Error::NothingToUpdate,
659            Error::EmptyDod,
660            Error::NotFound(ItemId::new("T", 1)),
661            Error::ReferencedItem {
662                item: ItemId::new("T", 1),
663                references: "T-2".into(),
664            },
665            Error::InvalidSprintId("S 1".into()),
666            Error::InvalidSprintState("archived".into()),
667            Error::EmptySprintTitle,
668            Error::EmptySprintGoal,
669            Error::InvalidSprintTransition {
670                from: SprintState::Active,
671                to: SprintState::Active,
672            },
673            Error::SprintNotFound(SprintId::new("S-1").unwrap()),
674            Error::SprintExists(SprintId::new("S-1").unwrap()),
675            Error::SprintClosed(SprintId::new("S-1").unwrap()),
676            Error::InvalidSprintPeriod {
677                start: chrono::NaiveDate::from_ymd_opt(2026, 7, 20).unwrap(),
678                end: chrono::NaiveDate::from_ymd_opt(2026, 7, 6).unwrap(),
679            },
680            Error::SprintPeriodUnset(SprintId::new("S-1").unwrap()),
681            Error::SprintEmpty(SprintId::new("S-1").unwrap()),
682            Error::NotInSprint {
683                item: ItemId::new("T", 1),
684                sprint: SprintId::new("S-1").unwrap(),
685            },
686            Error::ParentCycle {
687                child: ItemId::new("T", 1),
688                parent: ItemId::new("T", 2),
689            },
690            Error::SelfReference(ItemId::new("T", 1)),
691            Error::NotSibling {
692                item: ItemId::new("T", 1),
693                reference: ItemId::new("T", 2),
694            },
695            Error::EditorNotSet,
696            Error::EditorLaunch {
697                editor: "missing-editor".into(),
698                message: "not found".into(),
699            },
700            Error::EditorInvalid {
701                message: "empty title".into(),
702            },
703            Error::NotInitialized {
704                path: PathBuf::from(".pinto"),
705            },
706            Error::Locked {
707                path: PathBuf::from(".pinto/.lock"),
708            },
709            Error::UnsupportedSqliteSchema {
710                path: PathBuf::from(".pinto/board.sqlite3"),
711                found: "99".into(),
712                supported: 1,
713            },
714        ];
715        for e in user {
716            assert!(e.is_user_error(), "expected user error: {e:?}");
717        }
718    }
719
720    /// Variants classified as internal or unexpected (exit code 2), such as I/O and task failures
721    /// that cannot be fixed with user input.
722    #[test]
723    fn internal_variants_are_not_user_errors() {
724        let internal: &[Error] = &[
725            Error::Io {
726                path: PathBuf::from("f"),
727                message: "boom".into(),
728            },
729            Error::Task("panicked".into()),
730            Error::Git("git not found".into()),
731        ];
732        for e in internal {
733            assert!(!e.is_user_error(), "expected internal error: {e:?}");
734        }
735    }
736
737    #[test]
738    fn domain_errors_have_stable_codes_and_localized_bodies() {
739        let japanese = crate::i18n::localizer_from(Some("ja_JP.UTF-8"), None);
740        assert_eq!(Error::EmptyTitle.code(), "empty-title");
741        assert_eq!(Error::Git("git failed".into()).code(), "git");
742        assert_eq!(
743            Error::EmptyTitle.localized(&japanese),
744            "タイトルは空にできません。"
745        );
746
747        let external = Error::Parse {
748            path: PathBuf::from("config.toml"),
749            message: "expected newline".into(),
750        };
751        let rendered = external.localized(&japanese);
752        assert!(rendered.contains("config.toml"));
753        assert!(rendered.contains("expected newline"));
754        assert!(rendered.contains("解析"));
755    }
756
757    #[test]
758    fn english_localization_preserves_display_quoting_for_special_values() {
759        let english = crate::i18n::localizer_from(Some("en_US.UTF-8"), None);
760        let errors = [
761            Error::InvalidItemId("bad\"id".into()),
762            Error::InvalidRank("bad\nrank".into()),
763            Error::UnknownStatus("two words".into()),
764            Error::InvalidTemplateName("a\"b".into()),
765            Error::InvalidSprintId("S \"1".into()),
766            Error::InvalidSprintState("unknown state".into()),
767            Error::UnsupportedSqliteSchema {
768                path: PathBuf::from("board.sqlite3"),
769                found: "1\"2".into(),
770                supported: 1,
771            },
772        ];
773
774        for error in errors {
775            assert_eq!(
776                error.localized(&english),
777                error.to_string(),
778                "special values must use the same quoting at the English boundary: {error:?}"
779            );
780        }
781    }
782
783    #[test]
784    fn every_error_variant_resolves_from_the_catalog() {
785        let english = crate::i18n::localizer_from(Some("en_US.UTF-8"), None);
786        let japanese = crate::i18n::localizer_from(Some("ja_JP.UTF-8"), None);
787        let template = TemplateName::new("item").unwrap();
788        let sprint = SprintId::new("S-1").unwrap();
789        let errors = [
790            Error::InvalidItemId("bad".into()),
791            Error::InvalidRank("!".into()),
792            Error::InvalidAutomationPlan,
793            Error::AutomationPlanSource {
794                path: PathBuf::from("plan.json"),
795                message: "permission denied".into(),
796            },
797            Error::UnknownStatus("blocked".into()),
798            Error::EmptyTitle,
799            Error::InvalidSearchPattern("[".into()),
800            Error::InvalidFilterOption("--label".into()),
801            Error::NothingToUpdate,
802            Error::EmptyDod,
803            Error::InvalidTemplateName("../item".into()),
804            Error::TemplateNotFound {
805                kind: "item",
806                name: template.clone(),
807                path: PathBuf::from(".pinto/templates/item/item.md"),
808            },
809            Error::TemplateUnreadable {
810                path: PathBuf::from("item.md"),
811                message: "invalid UTF-8".into(),
812            },
813            Error::NotFound(ItemId::new("T", 1)),
814            Error::ReferencedItem {
815                item: ItemId::new("T", 1),
816                references: "T-2".into(),
817            },
818            Error::InvalidSprintId("S 1".into()),
819            Error::InvalidSprintState("paused".into()),
820            Error::EmptySprintTitle,
821            Error::EmptySprintGoal,
822            Error::InvalidSprintTransition {
823                from: SprintState::Active,
824                to: SprintState::Planned,
825            },
826            Error::SprintNotFound(sprint.clone()),
827            Error::SprintExists(sprint.clone()),
828            Error::SprintClosed(sprint.clone()),
829            Error::InvalidSprintPeriod {
830                start: chrono::NaiveDate::from_ymd_opt(2026, 7, 20).unwrap(),
831                end: chrono::NaiveDate::from_ymd_opt(2026, 7, 6).unwrap(),
832            },
833            Error::InvalidDailyWorkHours("NaN".into()),
834            Error::InvalidDeductionFactor("2".into()),
835            Error::InvalidSprintHolidays {
836                holidays: 8,
837                calendar_days: 5,
838            },
839            Error::SprintCapacityPeriodUnset(sprint.clone()),
840            Error::SprintCapacityUnset(sprint.clone()),
841            Error::SprintPeriodUnset(sprint.clone()),
842            Error::SprintEmpty(sprint.clone()),
843            Error::NotInSprint {
844                item: ItemId::new("T", 1),
845                sprint: sprint.clone(),
846            },
847            Error::ParentCycle {
848                child: ItemId::new("T", 1),
849                parent: ItemId::new("T", 2),
850            },
851            Error::NotInitialized {
852                path: PathBuf::from(".pinto"),
853            },
854            Error::Io {
855                path: PathBuf::from("file"),
856                message: "permission denied".into(),
857            },
858            Error::Parse {
859                path: PathBuf::from("file"),
860                message: "invalid TOML".into(),
861            },
862            Error::MissingFrontmatter {
863                path: PathBuf::from("task.md"),
864            },
865            Error::UnsupportedSqliteSchema {
866                path: PathBuf::from("board.sqlite3"),
867                found: "99".into(),
868                supported: 1,
869            },
870            Error::SelfReference(ItemId::new("T", 1)),
871            Error::NotSibling {
872                item: ItemId::new("T", 1),
873                reference: ItemId::new("T", 2),
874            },
875            Error::Task("join failed".into()),
876            Error::Git("git failed".into()),
877            Error::EditorNotSet,
878            Error::EditorLaunch {
879                editor: "missing-editor".into(),
880                message: "not found".into(),
881            },
882            Error::EditorInvalid {
883                message: "empty title".into(),
884            },
885            Error::Locked {
886                path: PathBuf::from(".pinto/.lock"),
887            },
888        ];
889
890        let mismatches: Vec<_> = errors
891            .iter()
892            .filter_map(|error| {
893                let localized = error.localized(&english);
894                let display = error.to_string();
895                (localized != display)
896                    .then(|| format!("{error:?}: localized={localized:?}, display={display:?}"))
897            })
898            .collect();
899        assert!(
900            mismatches.is_empty(),
901            "English localization must be the library Display fallback:\n{}",
902            mismatches.join("\n")
903        );
904
905        for error in errors {
906            let rendered = error.localized(&japanese);
907            assert!(
908                !rendered.starts_with("error-"),
909                "missing catalog entry: {error:?}"
910            );
911        }
912    }
913}