Skip to main content

wt/
error.rs

1//! Typed error type for the `wt` library.
2//!
3//! Library APIs return [`Error`]; the binary maps it to a process exit code via
4//! [`Error::exit_code`]. Exit codes follow the spec (§12): `0` success, `1`
5//! user/operation error, `2` usage/argument error, `3` ambiguous query or
6//! nothing selected.
7
8/// A convenient `Result` alias for `wt` library operations.
9pub type Result<T> = std::result::Result<T, Error>;
10
11/// Errors produced by `wt` library operations.
12#[derive(Debug, thiserror::Error)]
13pub enum Error {
14    /// The current directory is not inside a Git repository.
15    #[error("not in a git repository")]
16    NotInRepo,
17
18    /// There is no current worktree (e.g. a bare repository) but the command
19    /// requires one.
20    #[error("no current worktree (bare repository); pass a query")]
21    NoCurrentWorktree,
22
23    /// A query resolved to more than one worktree (spec exit code `3`).
24    #[error("query {query:?} is ambiguous ({} candidates)", candidates.len())]
25    Ambiguous {
26        /// The query string that was ambiguous.
27        query: String,
28        /// Human-readable identifiers of the matching worktrees.
29        candidates: Vec<String>,
30    },
31
32    /// A query matched no worktree (spec exit code `1`).
33    #[error("no worktree matches {query:?}")]
34    NotFound {
35        /// The query string that matched nothing.
36        query: String,
37    },
38
39    /// Nothing was selected, e.g. a cancelled picker (spec exit code `3`).
40    #[error("nothing selected")]
41    NothingSelected,
42
43    /// A usage or argument error (spec exit code `2`).
44    #[error("{0}")]
45    Usage(String),
46
47    /// A configuration error, naming the file, key, and reason.
48    #[error("{file}: {key}: {reason}")]
49    Config {
50        /// Path (or label) of the offending config file.
51        file: String,
52        /// The offending key.
53        key: String,
54        /// Why it was rejected.
55        reason: String,
56    },
57
58    /// A subprocess (`git`, `gh`, or a code agent) failed; `stderr` is surfaced
59    /// verbatim.
60    #[error("{program} failed: {stderr}")]
61    Subprocess {
62        /// The program that failed (e.g. `git`, `gh`, `claude`).
63        program: String,
64        /// Captured standard error, verbatim.
65        stderr: String,
66    },
67
68    /// The `gh` CLI is missing or unauthenticated.
69    #[error("{0}")]
70    GhUnavailable(String),
71
72    /// No code-agent CLI is available (missing binary or failed to launch).
73    #[error("{0}")]
74    AgentUnavailable(String),
75
76    /// An operation failed for the reason described by the message.
77    #[error("{0}")]
78    Operation(String),
79
80    /// An underlying I/O error.
81    #[error("{0}")]
82    Io(#[from] std::io::Error),
83
84    /// A JSON serialization or deserialization error.
85    #[error("json error: {0}")]
86    Json(#[from] serde_json::Error),
87}
88
89impl Error {
90    /// The process exit code this error maps to (spec §12): `2` for usage
91    /// errors, `3` for ambiguous queries or nothing selected, `1` otherwise.
92    pub fn exit_code(&self) -> u8 {
93        match self {
94            Error::Usage(_) => 2,
95            Error::Ambiguous { .. } | Error::NothingSelected => 3,
96            _ => 1,
97        }
98    }
99
100    /// Builds an [`Error::Operation`] from anything string-like.
101    pub fn operation(message: impl Into<String>) -> Self {
102        Error::Operation(message.into())
103    }
104
105    /// Builds an [`Error::Usage`] from anything string-like.
106    pub fn usage(message: impl Into<String>) -> Self {
107        Error::Usage(message.into())
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn exit_codes_match_spec() {
117        assert_eq!(Error::usage("x").exit_code(), 2);
118        assert_eq!(
119            Error::Ambiguous {
120                query: "f".into(),
121                candidates: vec!["a".into(), "b".into()],
122            }
123            .exit_code(),
124            3
125        );
126        assert_eq!(Error::NothingSelected.exit_code(), 3);
127        assert_eq!(Error::NotFound { query: "f".into() }.exit_code(), 1);
128        assert_eq!(Error::NotInRepo.exit_code(), 1);
129        assert_eq!(Error::NoCurrentWorktree.exit_code(), 1);
130        assert_eq!(
131            Error::Config {
132                file: "c".into(),
133                key: "k".into(),
134                reason: "r".into(),
135            }
136            .exit_code(),
137            1
138        );
139        assert_eq!(
140            Error::Subprocess {
141                program: "git".into(),
142                stderr: "boom".into(),
143            }
144            .exit_code(),
145            1
146        );
147        assert_eq!(Error::GhUnavailable("gh".into()).exit_code(), 1);
148        assert_eq!(Error::AgentUnavailable("a".into()).exit_code(), 1);
149        assert_eq!(Error::operation("op").exit_code(), 1);
150        assert_eq!(Error::from(std::io::Error::other("io")).exit_code(), 1);
151        let json_err = serde_json::from_str::<i32>("nope").unwrap_err();
152        assert_eq!(Error::from(json_err).exit_code(), 1);
153    }
154
155    #[test]
156    fn display_messages_are_descriptive() {
157        assert!(Error::NotInRepo.to_string().contains("git repository"));
158        assert!(
159            Error::NoCurrentWorktree
160                .to_string()
161                .contains("no current worktree")
162        );
163        assert!(
164            Error::Ambiguous {
165                query: "feat".into(),
166                candidates: vec!["a".into(), "b".into()],
167            }
168            .to_string()
169            .contains("ambiguous")
170        );
171        assert!(
172            Error::NotFound { query: "x".into() }
173                .to_string()
174                .contains("no worktree")
175        );
176        assert_eq!(Error::NothingSelected.to_string(), "nothing selected");
177        assert_eq!(Error::usage("oops").to_string(), "oops");
178        assert_eq!(
179            Error::Config {
180                file: "f".into(),
181                key: "k".into(),
182                reason: "r".into(),
183            }
184            .to_string(),
185            "f: k: r"
186        );
187        assert_eq!(
188            Error::Subprocess {
189                program: "gh".into(),
190                stderr: "no auth".into(),
191            }
192            .to_string(),
193            "gh failed: no auth"
194        );
195        assert_eq!(Error::GhUnavailable("nope".into()).to_string(), "nope");
196        assert_eq!(Error::AgentUnavailable("nope".into()).to_string(), "nope");
197        assert_eq!(Error::operation("op").to_string(), "op");
198        assert_eq!(Error::from(std::io::Error::other("io")).to_string(), "io");
199    }
200}