Skip to main content

hjkl_config/
error.rs

1use std::path::PathBuf;
2
3#[derive(Debug, thiserror::Error)]
4#[non_exhaustive]
5pub enum ConfigError {
6    #[error("no home directory available — XDG resolution failed (no $HOME on this platform)")]
7    NoHomeDir,
8
9    #[error("io error reading {path}: {source}")]
10    Io {
11        path: PathBuf,
12        #[source]
13        source: std::io::Error,
14    },
15
16    #[error("io error writing {path}: {source}")]
17    Write {
18        path: PathBuf,
19        #[source]
20        source: std::io::Error,
21    },
22
23    #[error("parse error in {path} at line {line}, column {col}: {message}\n  | {snippet}")]
24    Parse {
25        path: PathBuf,
26        line: usize,
27        col: usize,
28        message: String,
29        snippet: String,
30    },
31
32    /// Schema-level error: TOML parsed, but didn't match the target type
33    /// (unknown field, wrong type, missing required field after merge,
34    /// invalid bundled defaults). No span info — the structure is post-parse.
35    #[error("invalid config in {path}: {message}")]
36    Invalid { path: PathBuf, message: String },
37}
38
39/// Map a byte offset into `src` to a `(line, col, line_text)` triple.
40///
41/// `line` and `col` are 1-indexed; `col` counts characters, not bytes, so
42/// multibyte text earlier in the line doesn't skew it. `line_text` is the
43/// full line containing the offset, with no trailing newline. Used to
44/// enrich `toml::de::Error` span info into human-readable parse errors.
45pub fn locate(src: &str, byte_offset: usize) -> (usize, usize, String) {
46    let mut clamped = byte_offset.min(src.len());
47    // Defensive: snap to the previous char boundary so the column slice
48    // below can't panic if a span ever lands mid-character.
49    while !src.is_char_boundary(clamped) {
50        clamped -= 1;
51    }
52    let mut line_start = 0usize;
53    let mut line_no = 1usize;
54    for (i, ch) in src.char_indices() {
55        if i >= clamped {
56            break;
57        }
58        if ch == '\n' {
59            line_no += 1;
60            line_start = i + ch.len_utf8();
61        }
62    }
63    let line_end = src[line_start..]
64        .find('\n')
65        .map_or(src.len(), |n| line_start + n);
66    let line_text = src[line_start..line_end].to_string();
67    let col = src[line_start..clamped].chars().count() + 1;
68    (line_no, col, line_text)
69}
70
71#[cfg(test)]
72mod tests {
73    use super::locate;
74
75    #[test]
76    fn locate_first_line() {
77        let (l, c, txt) = locate("hello\nworld\n", 0);
78        assert_eq!((l, c), (1, 1));
79        assert_eq!(txt, "hello");
80    }
81
82    #[test]
83    fn locate_second_line_mid() {
84        let (l, c, txt) = locate("hello\nworld\n", 8);
85        assert_eq!((l, c), (2, 3));
86        assert_eq!(txt, "world");
87    }
88
89    #[test]
90    fn locate_clamps_past_end() {
91        let (l, c, _) = locate("a", 999);
92        assert_eq!((l, c), (1, 2));
93    }
94
95    #[test]
96    fn locate_multibyte_before_offset_counts_chars_not_bytes() {
97        // 'é' is 2 bytes but 1 char — the column must be char-based.
98        let src = "é = x";
99        let x_offset = src.find('x').unwrap();
100        let (l, c, txt) = locate(src, x_offset);
101        assert_eq!((l, c), (1, 5));
102        assert_eq!(txt, "é = x");
103    }
104
105    #[test]
106    fn locate_mid_char_offset_snaps_to_boundary() {
107        // Offset 1 lands inside the 2-byte 'é' — must not panic and must
108        // report the column of the char containing the offset.
109        let (l, c, txt) = locate("é = 1", 1);
110        assert_eq!((l, c), (1, 1));
111        assert_eq!(txt, "é = 1");
112    }
113
114    #[test]
115    fn locate_unicode_offset() {
116        let src = "α = 1\nβ = 2";
117        let beta_offset = src.find('β').unwrap();
118        let (l, c, txt) = locate(src, beta_offset);
119        assert_eq!(l, 2);
120        assert_eq!(c, 1);
121        assert_eq!(txt, "β = 2");
122    }
123}