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/// All error codes declared anywhere in the API, in module-declaration order
95/// (depth-first), de-duplicated by `raw_name` (first occurrence wins). Returns
96/// an empty vec when the API declares no error domains.
97pub fn all(api: &Api) -> Vec<ResolvedError> {
98    let mut seen: BTreeSet<String> = BTreeSet::new();
99    let mut out: Vec<ResolvedError> = Vec::new();
100    fn walk(mods: &[Module], seen: &mut BTreeSet<String>, out: &mut Vec<ResolvedError>) {
101        for m in mods {
102            if let Some(domain) = &m.errors {
103                for c in &domain.codes {
104                    if seen.insert(c.name.clone()) {
105                        out.push(ResolvedError {
106                            raw_name: c.name.clone(),
107                            code: c.code,
108                            message: c.message.clone(),
109                            doc: c.doc.clone(),
110                        });
111                    }
112                }
113            }
114            walk(&m.modules, seen, out);
115        }
116    }
117    walk(&api.modules, &mut seen, &mut out);
118    out
119}
120
121/// Whether the API declares any error domains at all.
122pub fn has_domains(api: &Api) -> bool {
123    fn any(mods: &[Module]) -> bool {
124        mods.iter().any(|m| m.errors.is_some() || any(&m.modules))
125    }
126    any(&api.modules)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use weaveffi_ir::ir::{ErrorCode, ErrorDomain, Module};
133
134    fn module_with_errors(name: &str, codes: Vec<(&str, i32, &str)>) -> Module {
135        Module {
136            name: name.into(),
137            functions: vec![],
138            structs: vec![],
139            enums: vec![],
140            callbacks: vec![],
141            listeners: vec![],
142            errors: Some(ErrorDomain {
143                name: format!("{name}Error"),
144                codes: codes
145                    .into_iter()
146                    .map(|(n, c, m)| ErrorCode {
147                        name: n.into(),
148                        code: c,
149                        message: m.into(),
150                        doc: None,
151                    })
152                    .collect(),
153            }),
154            modules: vec![],
155        }
156    }
157
158    fn api_with(mods: Vec<Module>) -> Api {
159        Api {
160            version: "0.3.0".into(),
161            package: None,
162            modules: mods,
163            generators: None,
164        }
165    }
166
167    #[test]
168    fn type_name_avoids_screaming_and_doubling() {
169        assert_eq!(type_name("KEY_NOT_FOUND", "Error"), "KeyNotFoundError");
170        assert_eq!(
171            type_name("KEY_NOT_FOUND", "Exception"),
172            "KeyNotFoundException"
173        );
174        assert_eq!(type_name("AlreadyError", "Error"), "AlreadyError");
175        assert_eq!(type_name("invalid_input", "Error"), "InvalidInputError");
176    }
177
178    #[test]
179    fn member_spellings() {
180        let e = ResolvedError {
181            raw_name: "KEY_NOT_FOUND".into(),
182            code: 1,
183            message: "nope".into(),
184            doc: None,
185        };
186        assert_eq!(e.error_class(), "KeyNotFoundError");
187        assert_eq!(e.exception_class(), "KeyNotFoundException");
188        assert_eq!(e.camel(), "keyNotFound");
189        assert_eq!(e.pascal(), "KeyNotFound");
190        assert_eq!(e.shouty(), "KEY_NOT_FOUND");
191    }
192
193    #[test]
194    fn flattens_and_dedups_across_modules() {
195        let api = api_with(vec![
196            module_with_errors("a", vec![("NOT_FOUND", 1, "x"), ("DENIED", 2, "y")]),
197            module_with_errors("b", vec![("NOT_FOUND", 1, "x"), ("TIMEOUT", 3, "z")]),
198        ]);
199        let codes = all(&api);
200        let names: Vec<_> = codes.iter().map(|c| c.raw_name.as_str()).collect();
201        assert_eq!(names, vec!["NOT_FOUND", "DENIED", "TIMEOUT"]);
202        assert!(has_domains(&api));
203    }
204
205    #[test]
206    fn no_domains_is_empty() {
207        let api = api_with(vec![Module {
208            name: "m".into(),
209            functions: vec![],
210            structs: vec![],
211            enums: vec![],
212            callbacks: vec![],
213            listeners: vec![],
214            errors: None,
215            modules: vec![],
216        }]);
217        assert!(all(&api).is_empty());
218        assert!(!has_domains(&api));
219    }
220}