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("not a pinto board: {path} not found (run `pinto init` first)")]
201 NotInitialized {
202 path: PathBuf,
204 },
205
206 #[error("i/o error at {path}: {message}")]
208 Io {
209 path: PathBuf,
211 message: String,
213 },
214
215 #[error("failed to parse {path}: {message}")]
217 Parse {
218 path: PathBuf,
220 message: String,
222 },
223
224 #[error("missing `+++` frontmatter delimiter in {path}")]
226 MissingFrontmatter {
227 path: PathBuf,
229 },
230
231 #[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 path: PathBuf,
238 found: String,
240 supported: u32,
242 },
243
244 #[error("cannot reorder {0} relative to itself")]
248 SelfReference(ItemId),
249
250 #[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 #[error("background task failed: {0}")]
262 Task(String),
263
264 #[error("git backend error: {0}")]
269 Git(String),
270
271 #[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 #[error("failed to launch editor `{editor}`: {message}")]
281 EditorLaunch {
282 editor: String,
284 message: String,
286 },
287
288 #[error("edited content is invalid: {message}")]
293 EditorInvalid {
294 message: String,
296 },
297
298 #[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: PathBuf,
309 },
310}
311
312impl Error {
313 #[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 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 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 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 pub(crate) fn task(source: impl std::fmt::Display) -> Self {
565 Error::Task(source.to_string())
566 }
567
568 #[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
624pub type Result<T> = std::result::Result<T, Error>;
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630
631 #[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 #[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}