Skip to main content

oxiproto_build/
error.rs

1#![forbid(unsafe_code)]
2
3//! Structured error type for `oxiproto-build` operations.
4
5use oxiproto_core::OxiProtoError;
6use std::io;
7
8/// Rich error type for `.proto` compilation operations.
9///
10/// Carries structured location information where available so that IDEs and
11/// build-script consumers can point the user directly to the offending source
12/// position.
13#[derive(Debug)]
14pub enum BuildError {
15    /// A `.proto` file has a syntax error with location information.
16    Parse {
17        /// Path to the file containing the error (empty when not available).
18        file: String,
19        /// 1-indexed line number (`0` when not available).
20        line: u32,
21        /// 1-indexed column number (`0` when not available).
22        col: u32,
23        /// Human-readable error message.
24        message: String,
25    },
26    /// Code generation failed.
27    Codegen {
28        /// Human-readable description of what went wrong.
29        message: String,
30    },
31    /// An I/O error occurred (e.g. reading a `.proto` file or writing output).
32    Io(io::Error),
33}
34
35impl std::fmt::Display for BuildError {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            BuildError::Parse {
39                file,
40                line,
41                col,
42                message,
43            } => {
44                if file.is_empty() {
45                    write!(f, "parse error: {message}")
46                } else if *col == 0 {
47                    write!(f, "{file}:{line}: {message}")
48                } else {
49                    write!(f, "{file}:{line}:{col}: {message}")
50                }
51            }
52            BuildError::Codegen { message } => write!(f, "codegen error: {message}"),
53            BuildError::Io(e) => write!(f, "I/O error: {e}"),
54        }
55    }
56}
57
58impl std::error::Error for BuildError {
59    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
60        match self {
61            BuildError::Io(e) => Some(e),
62            BuildError::Parse { .. } | BuildError::Codegen { .. } => None,
63        }
64    }
65}
66
67impl From<OxiProtoError> for BuildError {
68    fn from(e: OxiProtoError) -> Self {
69        match &e {
70            OxiProtoError::ParseError(msg) => {
71                // Try to parse "file:line:col: message" prefix from msg.
72                BuildError::from_parse_string(msg)
73            }
74            OxiProtoError::CodegenError(msg) => BuildError::Codegen {
75                message: msg.clone(),
76            },
77            OxiProtoError::IoError(io_err) => {
78                // io::Error is not Clone; reconstruct from kind + Display.
79                BuildError::Io(io::Error::new(io_err.kind(), io_err.to_string()))
80            }
81            OxiProtoError::WireFormatError(w) => BuildError::Codegen {
82                message: w.to_string(),
83            },
84            // #[non_exhaustive] — catch all remaining variants.
85            _ => BuildError::Codegen {
86                message: e.to_string(),
87            },
88        }
89    }
90}
91
92impl From<BuildError> for OxiProtoError {
93    fn from(e: BuildError) -> Self {
94        OxiProtoError::ParseError(e.to_string())
95    }
96}
97
98impl From<io::Error> for BuildError {
99    fn from(e: io::Error) -> Self {
100        BuildError::Io(e)
101    }
102}
103
104impl BuildError {
105    /// Attempt to parse a `"file:line:col: message"` or `"file:line: message"`
106    /// prefix from `msg`. Falls back to `Parse { file: "", line: 0, col: 0, … }`
107    /// when the prefix is absent or malformed.
108    pub(crate) fn from_parse_string(msg: &str) -> Self {
109        // Strategy: split on ':' and try to read u32 segments at positions 1 and 2.
110        // We must handle Windows drive letters like "C:\path\file.proto:3:5: …"
111        // by skipping single-char segments as likely drive letters.
112        let parts: Vec<&str> = msg.splitn(5, ':').collect();
113
114        // Minimum required: file, line, col, (rest) — or file, line, (rest).
115        // We need at least 3 parts to attempt any parsing.
116        if parts.len() >= 3 {
117            // Handle Windows drive letter prefix: if parts[0] is a single ASCII
118            // alpha char the "file" part is actually parts[0] + ":" + parts[1].
119            let (file_raw, line_idx, col_idx) = if parts[0].len() == 1
120                && parts[0]
121                    .chars()
122                    .next()
123                    .is_some_and(|c| c.is_ascii_alphabetic())
124            {
125                // Windows drive letter — need at least 5 parts total.
126                if parts.len() >= 5 {
127                    let file = format!("{}:{}", parts[0], parts[1]);
128                    (file, 2usize, 3usize)
129                } else {
130                    // Not enough parts; fall back.
131                    return Self::fallback(msg);
132                }
133            } else {
134                (parts[0].to_owned(), 1usize, 2usize)
135            };
136
137            if let Ok(line) = parts[line_idx].trim().parse::<u32>() {
138                // We have a valid line number; now try col.
139                if let Ok(col) = parts[col_idx].trim().parse::<u32>() {
140                    // "file:line:col: message" pattern.
141                    let message = parts[(col_idx + 1)..]
142                        .join(":")
143                        .trim_start_matches(' ')
144                        .to_owned();
145                    return BuildError::Parse {
146                        file: file_raw,
147                        line,
148                        col,
149                        message: if message.is_empty() {
150                            msg.to_owned()
151                        } else {
152                            message
153                        },
154                    };
155                }
156                // "file:line: message" pattern (no col).
157                let message = parts[(line_idx + 1)..]
158                    .join(":")
159                    .trim_start_matches(' ')
160                    .to_owned();
161                return BuildError::Parse {
162                    file: file_raw,
163                    line,
164                    col: 0,
165                    message: if message.is_empty() {
166                        msg.to_owned()
167                    } else {
168                        message
169                    },
170                };
171            }
172        }
173
174        Self::fallback(msg)
175    }
176
177    fn fallback(msg: &str) -> Self {
178        BuildError::Parse {
179            file: String::new(),
180            line: 0,
181            col: 0,
182            message: msg.to_owned(),
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn parse_file_line_col() {
193        let e = BuildError::from_parse_string("foo.proto:3:7: unexpected token");
194        match e {
195            BuildError::Parse {
196                file,
197                line,
198                col,
199                message,
200            } => {
201                assert_eq!(file, "foo.proto");
202                assert_eq!(line, 3);
203                assert_eq!(col, 7);
204                assert!(message.contains("unexpected"));
205            }
206            other => panic!("unexpected variant: {other:?}"),
207        }
208    }
209
210    #[test]
211    fn parse_file_line_no_col() {
212        let e = BuildError::from_parse_string("bar.proto:10: missing semicolon");
213        match e {
214            BuildError::Parse {
215                file,
216                line,
217                col,
218                message,
219            } => {
220                assert_eq!(file, "bar.proto");
221                assert_eq!(line, 10);
222                assert_eq!(col, 0);
223                assert!(message.contains("semicolon"));
224            }
225            other => panic!("unexpected variant: {other:?}"),
226        }
227    }
228
229    #[test]
230    fn parse_fallback_on_plain_message() {
231        let e = BuildError::from_parse_string("something went wrong");
232        match e {
233            BuildError::Parse {
234                file,
235                line,
236                col,
237                message,
238            } => {
239                assert!(file.is_empty());
240                assert_eq!(line, 0);
241                assert_eq!(col, 0);
242                assert_eq!(message, "something went wrong");
243            }
244            other => panic!("unexpected variant: {other:?}"),
245        }
246    }
247
248    #[test]
249    fn display_with_location() {
250        let e = BuildError::Parse {
251            file: "test.proto".to_owned(),
252            line: 5,
253            col: 3,
254            message: "oops".to_owned(),
255        };
256        assert_eq!(e.to_string(), "test.proto:5:3: oops");
257    }
258
259    #[test]
260    fn display_without_location() {
261        let e = BuildError::Codegen {
262            message: "bad output".to_owned(),
263        };
264        assert_eq!(e.to_string(), "codegen error: bad output");
265    }
266}