Skip to main content

weaveffi_core/
errors.rs

1//! Shared error-domain model and naming policy.
2//!
3//! Every `errors:` domain declared anywhere in the API is flattened into a
4//! single, de-duplicated list ([`all`]) so all backends surface the *same*
5//! typed errors, and the idiomatic naming policy is centralized here so we
6//! never again emit drift like `KEY_NOT_FOUNDError` (raw SCREAMING_SNAKE with a
7//! naive `Error` suffix) in one language and `keyNotFound` in another.
8//!
9//! Backends pick the brand/suffix that matches their ecosystem
10//! ([`ERROR_BRAND`] for Swift/Python/TS/C++/Ruby/Go, [`EXCEPTION_BRAND`] for
11//! Kotlin/.NET/Dart) and case-convert each code's [`ResolvedError::raw_name`]
12//! through the helpers below.
13
14use std::collections::BTreeSet;
15
16use heck::{ToLowerCamelCase, ToShoutySnakeCase, ToUpperCamelCase};
17use weaveffi_ir::ir::{Api, Module};
18
19/// Canonical brand stem. Always `WeaveFFI` (uppercase `FFI`), never the
20/// `heck`-derived `Weaveffi` that several generators used to emit.
21pub const BRAND_STEM: &str = "WeaveFFI";
22
23/// Base error type for ecosystems that use the `Error` suffix
24/// (Swift, Python, TypeScript/Node, C++, Ruby, Go).
25pub const ERROR_BRAND: &str = "WeaveFFIError";
26
27/// Base exception type for ecosystems that use the `Exception` suffix
28/// (Kotlin/Android, .NET, Dart).
29pub const EXCEPTION_BRAND: &str = "WeaveFFIException";
30
31/// A single error code, flattened across the whole API.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ResolvedError {
34    /// Raw identifier exactly as written in the IDL (e.g. `KEY_NOT_FOUND`).
35    pub raw_name: String,
36    /// Numeric ABI code carried in `weaveffi_error.code`.
37    pub code: i32,
38    /// Human-readable default message for the code.
39    pub message: String,
40    /// Optional doc comment.
41    pub doc: Option<String>,
42}
43
44impl ResolvedError {
45    /// PascalCase type name with exactly one `Error` suffix.
46    /// `KEY_NOT_FOUND` → `KeyNotFoundError`.
47    pub fn error_class(&self) -> String {
48        type_name(&self.raw_name, "Error")
49    }
50
51    /// PascalCase type name with exactly one `Exception` suffix.
52    /// `KEY_NOT_FOUND` → `KeyNotFoundException`.
53    pub fn exception_class(&self) -> String {
54        type_name(&self.raw_name, "Exception")
55    }
56
57    /// lowerCamelCase member name (Swift enum case, JS field).
58    /// `KEY_NOT_FOUND` → `keyNotFound`.
59    pub fn camel(&self) -> String {
60        self.raw_name.to_lower_camel_case()
61    }
62
63    /// PascalCase name without a suffix. `KEY_NOT_FOUND` → `KeyNotFound`.
64    pub fn pascal(&self) -> String {
65        self.raw_name.to_upper_camel_case()
66    }
67
68    /// SCREAMING_SNAKE constant spelling. `KeyNotFound` → `KEY_NOT_FOUND`.
69    pub fn shouty(&self) -> String {
70        self.raw_name.to_shouty_snake_case()
71    }
72}
73
74/// PascalCase form of a raw error code name, with no suffix.
75/// `KEY_NOT_FOUND` → `KeyNotFound`. Use for languages whose error variants are
76/// nested types/cases (Kotlin sealed subclasses, etc.) rather than standalone
77/// `*Error` classes.
78pub fn pascal(raw: &str) -> String {
79    raw.to_upper_camel_case()
80}
81
82/// PascalCase + exactly one `suffix`, avoiding doubled or SCREAMING suffixes.
83/// `("KEY_NOT_FOUND", "Error")` → `KeyNotFoundError`;
84/// `("AlreadyError", "Error")` → `AlreadyError`.
85pub fn type_name(raw: &str, suffix: &str) -> String {
86    let pascal = raw.to_upper_camel_case();
87    if pascal.ends_with(suffix) {
88        pascal
89    } else {
90        format!("{pascal}{suffix}")
91    }
92}
93
94/// Exception-branded type name for an error domain, for targets whose
95/// idiomatic errors are exceptions rather than `*Error` types.
96/// A trailing `Error` stem is replaced instead of stacked:
97/// `KvError` → `KvException`; `Failure` → `FailureException`.
98pub fn exception_type_name(raw: &str) -> String {
99    let pascal = raw.to_upper_camel_case();
100    let stem = pascal.strip_suffix("Error").unwrap_or(&pascal);
101    if stem.is_empty() {
102        "WeaveFFIException".to_string()
103    } else {
104        type_name(stem, "Exception")
105    }
106}
107
108/// All error codes declared anywhere in the API, in module-declaration order
109/// (depth-first), de-duplicated by `raw_name` (first occurrence wins). Returns
110/// an empty vec when the API declares no error domains.
111pub fn all(api: &Api) -> Vec<ResolvedError> {
112    let mut seen: BTreeSet<String> = BTreeSet::new();
113    let mut out: Vec<ResolvedError> = Vec::new();
114    fn walk(mods: &[Module], seen: &mut BTreeSet<String>, out: &mut Vec<ResolvedError>) {
115        for m in mods {
116            if let Some(domain) = &m.errors {
117                for c in &domain.codes {
118                    if seen.insert(c.name.clone()) {
119                        out.push(ResolvedError {
120                            raw_name: c.name.clone(),
121                            code: c.code,
122                            message: c.message.clone(),
123                            doc: c.doc.clone(),
124                        });
125                    }
126                }
127            }
128            walk(&m.modules, seen, out);
129        }
130    }
131    walk(&api.modules, &mut seen, &mut out);
132    out
133}
134
135/// Whether the API declares any error domains at all.
136pub fn has_domains(api: &Api) -> bool {
137    fn any(mods: &[Module]) -> bool {
138        mods.iter().any(|m| m.errors.is_some() || any(&m.modules))
139    }
140    any(&api.modules)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use weaveffi_ir::ir::{ErrorCode, ErrorDomain, Module};
147
148    fn module_with_errors(name: &str, codes: Vec<(&str, i32, &str)>) -> Module {
149        Module {
150            name: name.into(),
151            functions: vec![],
152            interfaces: vec![],
153            structs: vec![],
154            enums: vec![],
155            callbacks: vec![],
156            listeners: vec![],
157            errors: Some(ErrorDomain {
158                name: format!("{name}Error"),
159                codes: codes
160                    .into_iter()
161                    .map(|(n, c, m)| ErrorCode {
162                        name: n.into(),
163                        code: c,
164                        message: m.into(),
165                        doc: None,
166                    })
167                    .collect(),
168            }),
169            modules: vec![],
170        }
171    }
172
173    fn api_with(mods: Vec<Module>) -> Api {
174        Api {
175            version: "0.5.0".into(),
176            package: None,
177            modules: mods,
178            generators: None,
179        }
180    }
181
182    #[test]
183    fn type_name_avoids_screaming_and_doubling() {
184        assert_eq!(type_name("KEY_NOT_FOUND", "Error"), "KeyNotFoundError");
185        assert_eq!(
186            type_name("KEY_NOT_FOUND", "Exception"),
187            "KeyNotFoundException"
188        );
189        assert_eq!(type_name("AlreadyError", "Error"), "AlreadyError");
190        assert_eq!(type_name("invalid_input", "Error"), "InvalidInputError");
191    }
192
193    #[test]
194    fn exception_type_name_replaces_error_stem() {
195        assert_eq!(exception_type_name("KvError"), "KvException");
196        assert_eq!(exception_type_name("ContactsError"), "ContactsException");
197        assert_eq!(exception_type_name("Failure"), "FailureException");
198        assert_eq!(exception_type_name("KvException"), "KvException");
199        assert_eq!(exception_type_name("Error"), "WeaveFFIException");
200    }
201
202    #[test]
203    fn member_spellings() {
204        let e = ResolvedError {
205            raw_name: "KEY_NOT_FOUND".into(),
206            code: 1,
207            message: "nope".into(),
208            doc: None,
209        };
210        assert_eq!(e.error_class(), "KeyNotFoundError");
211        assert_eq!(e.exception_class(), "KeyNotFoundException");
212        assert_eq!(e.camel(), "keyNotFound");
213        assert_eq!(e.pascal(), "KeyNotFound");
214        assert_eq!(e.shouty(), "KEY_NOT_FOUND");
215    }
216
217    #[test]
218    fn flattens_and_dedups_across_modules() {
219        let api = api_with(vec![
220            module_with_errors("a", vec![("NOT_FOUND", 1, "x"), ("DENIED", 2, "y")]),
221            module_with_errors("b", vec![("NOT_FOUND", 1, "x"), ("TIMEOUT", 3, "z")]),
222        ]);
223        let codes = all(&api);
224        let names: Vec<_> = codes.iter().map(|c| c.raw_name.as_str()).collect();
225        assert_eq!(names, vec!["NOT_FOUND", "DENIED", "TIMEOUT"]);
226        assert!(has_domains(&api));
227    }
228
229    #[test]
230    fn no_domains_is_empty() {
231        let api = api_with(vec![Module {
232            name: "m".into(),
233            functions: vec![],
234            interfaces: vec![],
235            structs: vec![],
236            enums: vec![],
237            callbacks: vec![],
238            listeners: vec![],
239            errors: None,
240            modules: vec![],
241        }]);
242        assert!(all(&api).is_empty());
243        assert!(!has_domains(&api));
244    }
245}