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    /// --max-weight threshold exceeded.
42    MaxWeightExceeded {
43        kind: &'static str,
44        weight: u64,
45        module_count: usize,
46        threshold: u64,
47    },
48}
49
50impl Error {
51    /// User-facing hint to accompany the error message.
52    pub fn hint(&self) -> Option<&str> {
53        match self {
54            Self::UnsupportedFileType(_) => Some(
55                "chainsaw supports TypeScript/JavaScript (.ts, .tsx, .js, .jsx, .mjs, .cjs) and Python (.py) files",
56            ),
57            Self::EntryNotInGraph(_) => Some("is it reachable from the project root?"),
58            Self::TargetIsEntryPoint(flag) => Some(if flag == "--chain" {
59                "--chain finds import chains from the entry to a dependency"
60            } else {
61                "--cut finds where to sever import chains to a dependency"
62            }),
63            Self::EntryRequired => Some("use --entry to specify the entry point to trace"),
64            Self::EntryIsDirectory(_) => {
65                Some("provide a source file (e.g. src/index.ts or main.py)")
66            }
67            _ => None,
68        }
69    }
70}
71
72// Display: lowercase, no trailing punctuation, so it composes into
73// larger error messages.
74impl std::fmt::Display for Error {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            Self::EntryNotFound(path, source) => {
78                write!(f, "cannot find entry file '{}': {source}", path.display())
79            }
80            Self::EntryIsDirectory(path) => {
81                write!(f, "'{}' is a directory, not a source file", path.display())
82            }
83            Self::UnsupportedFileType(Some(ext)) => {
84                write!(f, "unsupported file type '.{ext}'")
85            }
86            Self::UnsupportedFileType(None) => {
87                write!(f, "file has no extension")
88            }
89            Self::EntryNotInGraph(path) => {
90                write!(f, "entry file '{}' not found in graph", path.display())
91            }
92            Self::SnapshotRead(path, source) => {
93                write!(f, "cannot read snapshot '{}': {source}", path.display())
94            }
95            Self::SnapshotParse(path, source) => {
96                write!(f, "invalid snapshot '{}': {source}", path.display())
97            }
98            Self::SnapshotWrite(path, source) => {
99                write!(f, "cannot write snapshot '{}': {source}", path.display())
100            }
101            Self::MutuallyExclusiveFlags(flags) => {
102                write!(f, "{flags} cannot be used together")
103            }
104            Self::TargetIsEntryPoint(flag) => {
105                write!(f, "{flag} target is the entry point itself")
106            }
107            Self::EntryRequired => {
108                write!(
109                    f,
110                    "--entry is required when diffing against a git ref or the working tree"
111                )
112            }
113            Self::NotAGitRepo => write!(f, "not inside a git repository"),
114            Self::NotSnapshotOrRef(arg) => {
115                write!(f, "'{arg}' is not a snapshot file or a valid git ref")
116            }
117            Self::DiffFileNotFound(arg) => write!(f, "file not found: {arg}"),
118            Self::GitError(msg) => write!(f, "git: {msg}"),
119            Self::InvalidTopValue(flag, n) => {
120                write!(f, "invalid value {n} for {flag}: must be -1 (all) or 0+")
121            }
122            Self::Readline(msg) => write!(f, "readline: {msg}"),
123            Self::MaxWeightExceeded {
124                kind,
125                weight,
126                module_count,
127                threshold,
128            } => {
129                let plural = if *module_count == 1 { "" } else { "s" };
130                write!(
131                    f,
132                    "{kind} transitive weight {} ({module_count} module{plural}) exceeds --max-weight threshold {}",
133                    crate::report::format_size(*weight),
134                    crate::report::format_size(*threshold),
135                )
136            }
137        }
138    }
139}
140
141// Implement source() for error chain introspection.
142impl std::error::Error for Error {
143    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
144        match self {
145            Self::EntryNotFound(_, e) | Self::SnapshotRead(_, e) | Self::SnapshotWrite(_, e) => {
146                Some(e)
147            }
148            Self::SnapshotParse(_, e) => Some(e),
149            _ => None,
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn unsupported_file_type_no_extension() {
160        let err = Error::UnsupportedFileType(None);
161        assert!(err.to_string().contains("no extension"));
162        assert!(err.hint().unwrap().contains(".ts"));
163    }
164
165    #[test]
166    fn unsupported_file_type_with_extension() {
167        let err = Error::UnsupportedFileType(Some("rs".to_string()));
168        assert!(err.to_string().contains(".rs"));
169    }
170
171    #[test]
172    fn entry_is_directory_has_hint() {
173        let err = Error::EntryIsDirectory(PathBuf::from("/tmp/src"));
174        assert!(err.hint().unwrap().contains("source file"));
175    }
176}