Skip to main content

chainsaw/
error.rs

1//! Error types for the chainsaw CLI.
2
3use std::path::PathBuf;
4
5/// Errors from entry validation, graph loading, and snapshot I/O.
6#[derive(Debug)]
7#[non_exhaustive]
8pub enum Error {
9    /// Entry point file not found on disk.
10    EntryNotFound(PathBuf, std::io::Error),
11    /// Entry point path refers to a directory, not a file.
12    EntryIsDirectory(PathBuf),
13    /// File has an unsupported or missing extension.
14    UnsupportedFileType(Option<String>),
15    /// Entry point exists but was not found in the dependency graph.
16    EntryNotInGraph(PathBuf),
17    /// Cannot read a snapshot file from disk.
18    SnapshotRead(PathBuf, std::io::Error),
19    /// Snapshot file contains invalid JSON.
20    SnapshotParse(PathBuf, serde_json::Error),
21    /// Cannot write a snapshot file to disk.
22    SnapshotWrite(PathBuf, std::io::Error),
23    /// Mutually exclusive CLI flags were used together.
24    MutuallyExclusiveFlags(String),
25    /// --chain/--cut target is the entry point itself.
26    TargetIsEntryPoint(String),
27    /// --entry is required when comparing git refs.
28    EntryRequired,
29    /// Not inside a git repository.
30    NotAGitRepo,
31    /// Argument is not a snapshot file or valid git ref.
32    NotSnapshotOrRef(String),
33    /// A path-like argument that doesn't exist on disk.
34    DiffFileNotFound(String),
35    /// Git command failed.
36    GitError(String),
37    /// --top or --limit value is invalid (must be >= -1).
38    InvalidTopValue(&'static str, i32),
39    /// Readline/REPL initialization failed.
40    Readline(String),
41}
42
43impl Error {
44    /// User-facing hint to accompany the error message.
45    pub fn hint(&self) -> Option<&str> {
46        match self {
47            Self::UnsupportedFileType(_) => Some(
48                "chainsaw supports TypeScript/JavaScript (.ts, .tsx, .js, .jsx, .mjs, .cjs) and Python (.py) files",
49            ),
50            Self::EntryNotInGraph(_) => Some("is it reachable from the project root?"),
51            Self::TargetIsEntryPoint(flag) => Some(if flag == "--chain" {
52                "--chain finds import chains from the entry to a dependency"
53            } else {
54                "--cut finds where to sever import chains to a dependency"
55            }),
56            Self::EntryRequired => Some("use --entry to specify the entry point to trace"),
57            Self::EntryIsDirectory(_) => {
58                Some("provide a source file (e.g. src/index.ts or main.py)")
59            }
60            _ => None,
61        }
62    }
63}
64
65// Display: lowercase, no trailing punctuation, so it composes into
66// larger error messages.
67impl std::fmt::Display for Error {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            Self::EntryNotFound(path, source) => {
71                write!(f, "cannot find entry file '{}': {source}", path.display())
72            }
73            Self::EntryIsDirectory(path) => {
74                write!(f, "'{}' is a directory, not a source file", path.display())
75            }
76            Self::UnsupportedFileType(Some(ext)) => {
77                write!(f, "unsupported file type '.{ext}'")
78            }
79            Self::UnsupportedFileType(None) => {
80                write!(f, "file has no extension")
81            }
82            Self::EntryNotInGraph(path) => {
83                write!(f, "entry file '{}' not found in graph", path.display())
84            }
85            Self::SnapshotRead(path, source) => {
86                write!(f, "cannot read snapshot '{}': {source}", path.display())
87            }
88            Self::SnapshotParse(path, source) => {
89                write!(f, "invalid snapshot '{}': {source}", path.display())
90            }
91            Self::SnapshotWrite(path, source) => {
92                write!(f, "cannot write snapshot '{}': {source}", path.display())
93            }
94            Self::MutuallyExclusiveFlags(flags) => {
95                write!(f, "{flags} cannot be used together")
96            }
97            Self::TargetIsEntryPoint(flag) => {
98                write!(f, "{flag} target is the entry point itself")
99            }
100            Self::EntryRequired => {
101                write!(
102                    f,
103                    "--entry is required when diffing against a git ref or the working tree"
104                )
105            }
106            Self::NotAGitRepo => write!(f, "not inside a git repository"),
107            Self::NotSnapshotOrRef(arg) => {
108                write!(f, "'{arg}' is not a snapshot file or a valid git ref")
109            }
110            Self::DiffFileNotFound(arg) => write!(f, "file not found: {arg}"),
111            Self::GitError(msg) => write!(f, "git: {msg}"),
112            Self::InvalidTopValue(flag, n) => {
113                write!(f, "invalid value {n} for {flag}: must be -1 (all) or 0+")
114            }
115            Self::Readline(msg) => write!(f, "readline: {msg}"),
116        }
117    }
118}
119
120// Implement source() for error chain introspection.
121impl std::error::Error for Error {
122    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
123        match self {
124            Self::EntryNotFound(_, e) | Self::SnapshotRead(_, e) | Self::SnapshotWrite(_, e) => {
125                Some(e)
126            }
127            Self::SnapshotParse(_, e) => Some(e),
128            _ => None,
129        }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn unsupported_file_type_no_extension() {
139        let err = Error::UnsupportedFileType(None);
140        assert!(err.to_string().contains("no extension"));
141        assert!(err.hint().unwrap().contains(".ts"));
142    }
143
144    #[test]
145    fn unsupported_file_type_with_extension() {
146        let err = Error::UnsupportedFileType(Some("rs".to_string()));
147        assert!(err.to_string().contains(".rs"));
148    }
149
150    #[test]
151    fn entry_is_directory_has_hint() {
152        let err = Error::EntryIsDirectory(PathBuf::from("/tmp/src"));
153        assert!(err.hint().unwrap().contains("source file"));
154    }
155}