1use 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#[derive(Debug, Error, PartialEq, Eq)]
15pub enum Error {
16 #[error("invalid item id: {0:?} (expected `<ASCII_LETTERS>-<NUMBER>`)")]
18 InvalidItemId(String),
19
20 #[error("invalid rank: {0:?} (expected non-empty base-36 chars `0-9a-z`)")]
22 InvalidRank(String),
23
24 #[error(
26 "invalid automation plan (expected {{\"commands\":[[\"command\", \"arg\"], ...]}}; API keys are not accepted)"
27 )]
28 InvalidAutomationPlan,
29
30 #[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 #[error("unknown status: {0:?} is not a column in the workflow")]
38 UnknownStatus(String),
39
40 #[error("title must not be empty")]
42 EmptyTitle,
43
44 #[error("invalid search pattern: {0}")]
46 InvalidSearchPattern(String),
47
48 #[error("invalid filter option: {0}")]
50 InvalidFilterOption(String),
51
52 #[error("no fields to update")]
54 NothingToUpdate,
55
56 #[error("definition of done must not be empty")]
58 EmptyDod,
59
60 #[error("invalid template name: {0:?} (expected non-empty ASCII alphanumeric, `-`, or `_`)")]
62 InvalidTemplateName(String),
63
64 #[error("template not found: {kind}/{name} (create {path})")]
66 TemplateNotFound {
67 kind: &'static str,
68 name: TemplateName,
69 path: PathBuf,
70 },
71
72 #[error("template cannot be read: {path} ({message}; fix or replace this plain-text file)")]
74 TemplateUnreadable { path: PathBuf, message: String },
75
76 #[error("backlog item not found: {0}")]
78 NotFound(ItemId),
79
80 #[error(
82 "cannot remove {item}: referenced by {references}; remove these parent/dependency links first"
83 )]
84 ReferencedItem {
85 item: ItemId,
87 references: String,
89 },
90
91 #[error("invalid sprint id: {0:?} (expected non-empty ASCII alphanumeric, `-`, or `_`)")]
93 InvalidSprintId(String),
94
95 #[error("invalid sprint state: {0:?} (expected `planned`, `active`, or `closed`)")]
97 InvalidSprintState(String),
98
99 #[error("sprint title must not be empty")]
101 EmptySprintTitle,
102
103 #[error("sprint goal must be set before starting the sprint")]
105 EmptySprintGoal,
106
107 #[error("invalid sprint transition: {from} -> {to} (allowed: planned -> active -> closed)")]
109 InvalidSprintTransition {
110 from: SprintState,
112 to: SprintState,
114 },
115
116 #[error("sprint not found: {0}")]
118 SprintNotFound(SprintId),
119
120 #[error("sprint already exists: {0} (use `sprint edit`/`remove` to manage it)")]
125 SprintExists(SprintId),
126
127 #[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 #[error("invalid sprint period: start {start} is after end {end}")]
135 InvalidSprintPeriod {
136 start: chrono::NaiveDate,
138 end: chrono::NaiveDate,
140 },
141
142 #[error("daily work hours must be a finite number greater than or equal to 0 (got {0})")]
144 InvalidDailyWorkHours(String),
145
146 #[error("deduction factor must be a finite number from 0 to 1 (got {0})")]
148 InvalidDeductionFactor(String),
149
150 #[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 #[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 #[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 #[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 #[error("sprint {0} has no assigned items (assign one with `sprint add {0} <item-id>`)")]
176 SprintEmpty(SprintId),
177
178 #[error("{item} is not assigned to sprint {sprint}")]
180 NotInSprint {
181 item: ItemId,
183 sprint: SprintId,
185 },
186
187 #[error("setting parent of {child} to {parent} would create a cycle")]
192 ParentCycle {
193 child: ItemId,
195 parent: ItemId,
197 },
198
199 #[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 path: PathBuf,
206 },
207
208 #[error("i/o error at {path}: {message}")]
210 Io {
211 path: PathBuf,
213 message: String,
215 },
216
217 #[error("failed to parse {path}: {message}")]
219 Parse {
220 path: PathBuf,
222 message: String,
224 },
225
226 #[error("missing `+++` frontmatter delimiter in {path}")]
228 MissingFrontmatter {
229 path: PathBuf,
231 },
232
233 #[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 path: PathBuf,
240 found: String,
242 supported: u32,
244 },
245
246 #[error("cannot reorder {0} relative to itself")]
250 SelfReference(ItemId),
251
252 #[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 #[error("background task failed: {0}")]
264 Task(String),
265
266 #[error("git backend error: {0}")]
271 Git(String),
272
273 #[error("cannot undo: {0}")]
278 UndoUnavailable(String),
279
280 #[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 backend: String,
290 },
291
292 #[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 #[error("failed to launch editor `{editor}`: {message}")]
302 EditorLaunch {
303 editor: String,
305 message: String,
307 },
308
309 #[error("edited content is invalid: {message}")]
314 EditorInvalid {
315 message: String,
317 },
318
319 #[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: PathBuf,
330 },
331}
332
333impl Error {
334 #[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 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 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 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 pub(crate) fn task(source: impl std::fmt::Display) -> Self {
604 Error::Task(source.to_string())
605 }
606
607 #[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
665pub type Result<T> = std::result::Result<T, Error>;
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671
672 #[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 #[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}