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(
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 #[error("failed to launch editor `{editor}`: {message}")]
283 EditorLaunch {
284 editor: String,
286 message: String,
288 },
289
290 #[error("edited content is invalid: {message}")]
295 EditorInvalid {
296 message: String,
298 },
299
300 #[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: PathBuf,
311 },
312}
313
314impl Error {
315 #[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 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 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 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 pub(crate) fn task(source: impl std::fmt::Display) -> Self {
577 Error::Task(source.to_string())
578 }
579
580 #[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
636pub type Result<T> = std::result::Result<T, Error>;
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642
643 #[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 #[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}