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