es_fluent_cli/core/
errors.rs

1//! CLI error types using miette for beautiful Rust-style diagnostics.
2//!
3//! These error types provide clippy-style error messages with source snippets,
4//! labels, and helpful suggestions.
5
6// Fields in these structs are read by miette's Diagnostic derive macro
7#![allow(unused)]
8
9use miette::{Diagnostic, NamedSource, SourceSpan};
10use std::path::PathBuf;
11use thiserror::Error;
12
13/// Error when the i18n.toml configuration file is not found.
14#[derive(Debug, Diagnostic, Error)]
15#[error("i18n.toml configuration file not found")]
16#[diagnostic(
17    code(es_fluent::config::not_found),
18    help(
19        "Create an i18n.toml file in your crate root with the following content:\n\n  \
20          fallback_language = \"en\"\n  \
21          assets_dir = \"i18n\"\n"
22    )
23)]
24pub struct ConfigNotFoundError {
25    /// The path where the config was expected.
26    pub expected_path: PathBuf,
27}
28
29/// Error when parsing the i18n.toml configuration file.
30#[derive(Debug, Diagnostic, Error)]
31#[error("failed to parse i18n.toml configuration")]
32#[diagnostic(code(es_fluent::config::parse_error))]
33pub struct ConfigParseError {
34    /// The source content of the config file.
35    #[source_code]
36    pub src: NamedSource<String>,
37
38    /// The span where the error occurred.
39    #[label("error occurred here")]
40    pub span: Option<SourceSpan>,
41
42    /// The underlying parse error message.
43    #[help]
44    pub help: String,
45}
46
47/// Error when the assets directory doesn't exist.
48#[derive(Debug, Diagnostic, Error)]
49#[error("assets directory not found: {path}")]
50#[diagnostic(
51    code(es_fluent::config::assets_not_found),
52    help("Create the assets directory or update assets_dir in i18n.toml")
53)]
54pub struct AssetsNotFoundError {
55    /// The path that was expected.
56    pub path: PathBuf,
57}
58
59/// Error when the fallback language directory doesn't exist.
60#[derive(Debug, Diagnostic, Error)]
61#[error("fallback language directory not found: {language}")]
62#[diagnostic(
63    code(es_fluent::config::fallback_not_found),
64    help("Create a directory named '{language}' in your assets folder")
65)]
66pub struct FallbackLanguageNotFoundError {
67    /// The fallback language.
68    pub language: String,
69}
70
71/// Error when a language identifier is invalid.
72#[derive(Debug, Diagnostic, Error)]
73#[error("invalid language identifier: {identifier}")]
74#[diagnostic(
75    code(es_fluent::config::invalid_language),
76    help("Use a valid BCP 47 language tag (e.g., 'en', 'en-US', 'zh-Hans')")
77)]
78pub struct InvalidLanguageError {
79    /// The invalid language identifier.
80    pub identifier: String,
81}
82
83/// Error when a specified locale doesn't exist.
84#[derive(Debug, Diagnostic, Error)]
85#[error("locale '{locale}' not found")]
86#[diagnostic(
87    code(es_fluent::config::locale_not_found),
88    help("Available locales: {available}")
89)]
90pub struct LocaleNotFoundError {
91    /// The locale that was specified but not found.
92    pub locale: String,
93    /// Comma-separated list of available locales.
94    pub available: String,
95}
96
97/// A single missing key diagnostic.
98#[derive(Debug, Diagnostic, Error)]
99#[error("missing translation key")]
100#[diagnostic(code(es_fluent::validate::missing_key), severity(Error))]
101pub struct MissingKeyError {
102    /// The source content of the FTL file.
103    #[source_code]
104    pub src: NamedSource<String>,
105
106    /// The key that is missing.
107    pub key: String,
108
109    /// The locale where the key is missing.
110    pub locale: String,
111
112    /// Help text.
113    #[help]
114    pub help: String,
115}
116
117/// A single missing variable diagnostic (warning).
118#[derive(Debug, Diagnostic, Error)]
119#[error("translation omits variable")]
120#[diagnostic(code(es_fluent::validate::missing_variable), severity(Warning))]
121pub struct MissingVariableWarning {
122    /// The source content of the FTL file.
123    #[source_code]
124    pub src: NamedSource<String>,
125
126    /// The span where the message is defined.
127    #[label("this message omits variable '${variable}'")]
128    pub span: SourceSpan,
129
130    /// The variable that is missing.
131    pub variable: String,
132
133    /// The key containing the issue.
134    pub key: String,
135
136    /// The locale where the issue exists.
137    pub locale: String,
138
139    /// Help text.
140    #[help]
141    pub help: String,
142}
143
144/// Error when an FTL file has syntax errors.
145#[derive(Debug, Diagnostic, Error)]
146#[error("FTL syntax error in {locale}/{file_name}")]
147#[diagnostic(code(es_fluent::validate::syntax_error))]
148pub struct FtlSyntaxError {
149    /// The source content of the FTL file.
150    #[source_code]
151    pub src: NamedSource<String>,
152
153    /// The span where the error occurred.
154    #[label("syntax error here")]
155    pub span: SourceSpan,
156
157    /// The locale.
158    pub locale: String,
159
160    /// The file name.
161    pub file_name: String,
162
163    /// Help text.
164    #[help]
165    pub help: String,
166}
167
168/// Aggregated validation report containing multiple issues.
169#[derive(Debug, Diagnostic, Error)]
170#[error("validation found {error_count} error(s) and {warning_count} warning(s)")]
171#[diagnostic(code(es_fluent::validate::report))]
172pub struct ValidationReport {
173    /// Number of errors found.
174    pub error_count: usize,
175
176    /// Number of warnings found.
177    pub warning_count: usize,
178
179    /// Related diagnostics (missing keys, missing variables, etc.).
180    #[related]
181    pub issues: Vec<ValidationIssue>,
182}
183
184/// A validation issue (either error or warning).
185#[derive(Debug, Diagnostic, Error)]
186pub enum ValidationIssue {
187    #[error(transparent)]
188    #[diagnostic(transparent)]
189    MissingKey(#[from] MissingKeyError),
190
191    #[error(transparent)]
192    #[diagnostic(transparent)]
193    MissingVariable(#[from] MissingVariableWarning),
194
195    #[error(transparent)]
196    #[diagnostic(transparent)]
197    SyntaxError(#[from] FtlSyntaxError),
198}
199
200/// Error when formatting fails for an FTL file.
201#[derive(Debug, Diagnostic, Error)]
202#[error("failed to format {path}")]
203#[diagnostic(code(es_fluent::format::failed))]
204pub struct FormatError {
205    /// The path to the file.
206    pub path: PathBuf,
207
208    /// The underlying error.
209    #[help]
210    pub help: String,
211}
212
213/// Report for format command results.
214#[derive(Debug, Diagnostic, Error)]
215#[error("formatted {formatted_count} file(s), {error_count} error(s)")]
216#[diagnostic(code(es_fluent::format::report))]
217pub struct FormatReport {
218    /// Number of files formatted.
219    pub formatted_count: usize,
220
221    /// Number of errors.
222    pub error_count: usize,
223
224    /// Related format errors.
225    #[related]
226    pub errors: Vec<FormatError>,
227}
228
229/// Warning when a key needs to be synced to another locale.
230#[derive(Debug, Diagnostic, Error)]
231#[error("missing translation for key '{key}' in locale '{target_locale}'")]
232#[diagnostic(code(es_fluent::sync::missing), severity(Warning))]
233pub struct SyncMissingKey {
234    /// The key that is missing.
235    pub key: String,
236
237    /// The target locale where the key is missing.
238    pub target_locale: String,
239
240    /// The source locale (fallback).
241    pub source_locale: String,
242}
243
244/// Report for sync command results.
245#[derive(Debug, Diagnostic, Error)]
246#[error("sync: added {added_count} key(s) to {locale_count} locale(s)")]
247#[diagnostic(code(es_fluent::sync::report))]
248pub struct SyncReport {
249    /// Number of keys added.
250    pub added_count: usize,
251
252    /// Number of locales affected.
253    pub locale_count: usize,
254
255    /// Keys that were synced.
256    #[related]
257    pub synced_keys: Vec<SyncMissingKey>,
258}
259
260#[derive(Debug, Diagnostic, Error)]
261pub enum CliError {
262    #[error(transparent)]
263    #[diagnostic(transparent)]
264    ConfigNotFound(#[from] ConfigNotFoundError),
265
266    #[error(transparent)]
267    #[diagnostic(transparent)]
268    ConfigParse(#[from] ConfigParseError),
269
270    #[error(transparent)]
271    #[diagnostic(transparent)]
272    AssetsNotFound(#[from] AssetsNotFoundError),
273
274    #[error(transparent)]
275    #[diagnostic(transparent)]
276    FallbackNotFound(#[from] FallbackLanguageNotFoundError),
277
278    #[error(transparent)]
279    #[diagnostic(transparent)]
280    InvalidLanguage(#[from] InvalidLanguageError),
281
282    #[error(transparent)]
283    #[diagnostic(transparent)]
284    LocaleNotFound(#[from] LocaleNotFoundError),
285
286    #[error(transparent)]
287    #[diagnostic(transparent)]
288    Validation(#[from] ValidationReport),
289
290    #[error(transparent)]
291    #[diagnostic(transparent)]
292    Format(#[from] FormatReport),
293
294    #[error(transparent)]
295    #[diagnostic(transparent)]
296    Sync(#[from] SyncReport),
297
298    #[error("IO error: {0}")]
299    #[diagnostic(code(es_fluent::io))]
300    Io(#[from] std::io::Error),
301
302    #[error("{0}")]
303    #[diagnostic(code(es_fluent::other))]
304    Other(String),
305}
306
307impl From<anyhow::Error> for CliError {
308    fn from(err: anyhow::Error) -> Self {
309        CliError::Other(err.to_string())
310    }
311}
312
313/// Calculate SourceSpan from line and column in source text.
314#[allow(dead_code)]
315pub fn span_from_line_col(source: &str, line: usize, col: usize, len: usize) -> SourceSpan {
316    let mut offset = 0;
317    for (i, line_content) in source.lines().enumerate() {
318        if i + 1 == line {
319            offset += col.saturating_sub(1);
320            break;
321        }
322        offset += line_content.len() + 1; // +1 for newline
323    }
324    SourceSpan::new(offset.into(), len)
325}
326
327/// Find the byte offset and length of a key in the FTL source.
328#[allow(dead_code)]
329pub fn find_key_span(source: &str, key: &str) -> Option<SourceSpan> {
330    // Look for the key at the start of a line (message definition)
331    for (line_idx, line) in source.lines().enumerate() {
332        let trimmed = line.trim_start();
333        if let Some(rest) = trimmed.strip_prefix(key)
334            && (rest.starts_with(" =") || rest.starts_with('='))
335        {
336            // Found the key
337            let line_start: usize = source.lines().take(line_idx).map(|l| l.len() + 1).sum();
338            let key_start = line_start + (line.len() - trimmed.len());
339            return Some(SourceSpan::new(key_start.into(), key.len()));
340        }
341    }
342    None
343}
344
345/// Find all lines containing a message definition for a key.
346#[allow(dead_code)]
347pub fn find_message_span(source: &str, key: &str) -> Option<SourceSpan> {
348    let mut in_message = false;
349    let mut start_offset = 0;
350    let mut current_offset = 0;
351
352    for line in source.lines() {
353        let trimmed = line.trim_start();
354
355        if let Some(rest) = trimmed.strip_prefix(key) {
356            if rest.starts_with(" =") || rest.starts_with('=') {
357                in_message = true;
358                start_offset = current_offset + (line.len() - trimmed.len());
359            }
360        } else if in_message {
361            // Check if this is a continuation line (starts with whitespace) or a new entry
362            if !line.starts_with(' ') && !line.starts_with('\t') && !trimmed.is_empty() {
363                // End of message
364                let end_offset = current_offset;
365                return Some(SourceSpan::new(
366                    start_offset.into(),
367                    end_offset - start_offset,
368                ));
369            }
370        }
371
372        current_offset += line.len() + 1; // +1 for newline
373    }
374
375    // If we're still in a message at EOF
376    if in_message {
377        return Some(SourceSpan::new(
378            start_offset.into(),
379            current_offset.saturating_sub(1) - start_offset,
380        ));
381    }
382
383    None
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn test_find_key_span() {
392        let source = "## Comment\nhello = Hello\nworld = World";
393        let span = find_key_span(source, "hello").unwrap();
394        assert_eq!(span.offset(), 11);
395        assert_eq!(span.len(), 5);
396    }
397
398    #[test]
399    fn test_find_key_span_with_spaces() {
400        let source = "hello =Hello\nworld = World";
401        let span = find_key_span(source, "hello").unwrap();
402        assert_eq!(span.offset(), 0);
403        assert_eq!(span.len(), 5);
404    }
405
406    #[test]
407    fn test_find_message_span_multiline() {
408        let source = "greeting = Hello\n    World\nnext = Next";
409        let span = find_message_span(source, "greeting").unwrap();
410        assert_eq!(span.offset(), 0);
411        // Should include the full multiline message
412    }
413
414    #[test]
415    fn test_span_from_line_col() {
416        let source = "line1\nline2\nline3";
417        let span = span_from_line_col(source, 2, 1, 5);
418        assert_eq!(span.offset(), 6); // "line1\n" = 6 chars
419        assert_eq!(span.len(), 5);
420    }
421}