Skip to main content

olai_codegen/
postprocess.rs

1//! Rewrites of the buffa model output, whose JSON choices are fixed in the buf plugin:
2//!
3//! - **Field names.** [`JsonFieldNames::Proto`] serializes the snake_case proto names instead of
4//!   lowerCamelCase (both are still read). An explicit `json_name` is kept.
5//! - **64-bit integers.** `int64_module` points buffa's string helpers at the `json_int64`
6//!   module, which writes numbers.
7//! - **Free-form JSON.** `json_value_module` makes `google.protobuf.Value`/`Struct`/`ListValue`
8//!   fields write whole numbers as integers (`1`, not `1.0`).
9//! - **Field options** ([`field_options`]): explicit `json_name`s.
10//!
11//! The rewrites are idempotent and skip the files trestle writes itself.
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15use std::sync::LazyLock;
16
17use regex::Regex;
18
19use crate::error::{Error, Result};
20use crate::utils::strings::lower_camel;
21
22/// The entries of the models directory that trestle writes itself.
23const TRESTLE_FILES: &[&str] = &[
24    "mod.rs",
25    "labels.rs",
26    "pyo3_impls.rs",
27    crate::codegen::EXTRACTORS_SUBDIR,
28];
29
30/// How the JSON field names of one proto package are serialized.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum JsonFieldNames {
33    /// lowerCamelCase field names, buffa's proto3-JSON default.
34    #[default]
35    Json,
36    /// snake_case (proto) field names.
37    Proto,
38}
39
40/// The rewrites to apply to the buffa model output.
41#[derive(Debug, Clone, Default)]
42pub struct ModelRewrites {
43    /// Field-name style of every package not in [`packages`](Self::packages).
44    pub field_names: JsonFieldNames,
45    /// Field-name style per proto package (`my.pkg.v1`), overriding
46    /// [`field_names`](Self::field_names). A package file is `<package>.rs`.
47    pub packages: BTreeMap<String, JsonFieldNames>,
48    /// Path of the `json_int64` helper module (e.g. `crate::models::json_int64`); when set,
49    /// buffa's 64-bit integer helpers are pointed at it.
50    pub int64_module: Option<String>,
51    /// Path of the `json_value` helper module (e.g. `crate::models::json_value`); when set, the
52    /// free-form JSON fields serialize through it.
53    pub json_value_module: Option<String>,
54    /// The fields with options that change their JSON, by proto package; see [`field_options`].
55    pub fields: BTreeMap<String, PackageFields>,
56}
57
58/// The fields of one proto package with JSON options, keyed `Message.field`.
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct PackageFields {
61    /// Explicit `json_name`s, by field.
62    pub json_names: BTreeMap<String, String>,
63}
64
65/// The fields of `metadata` with JSON options, by package, for [`ModelRewrites::fields`].
66pub fn field_options(
67    metadata: &crate::parsing::CodeGenMetadata,
68) -> BTreeMap<String, PackageFields> {
69    let mut packages: BTreeMap<String, PackageFields> = BTreeMap::new();
70    for message in metadata.messages.values() {
71        let name = message.name.trim_start_matches('.');
72        let package = name
73            .split('.')
74            .take_while(|s| s.starts_with(|c: char| c.is_ascii_lowercase()))
75            .collect::<Vec<_>>()
76            .join(".");
77        let simple = name.rsplit('.').next().unwrap_or(name);
78        for field in &message.fields {
79            let key = format!("{simple}.{}", field.name);
80            if let Some(json) = &field.json_name {
81                packages
82                    .entry(package.clone())
83                    .or_default()
84                    .json_names
85                    .insert(key, json.clone());
86            }
87        }
88    }
89    packages
90}
91
92impl ModelRewrites {
93    /// Whether any rewrite is enabled.
94    pub fn is_empty(&self) -> bool {
95        self.field_names == JsonFieldNames::Json
96            && self.packages.values().all(|n| *n == JsonFieldNames::Json)
97            && self.int64_module.is_none()
98            && self.json_value_module.is_none()
99            && self.fields.is_empty()
100    }
101
102    /// The field-name style of the model file `file_name`: `<package>.rs` (the buf plugin) or
103    /// `<package>.<proto file>.rs` (buffa-build).
104    fn field_names_for(&self, file_name: &str) -> JsonFieldNames {
105        let Some(stem) = file_name.strip_suffix(".rs") else {
106            return self.field_names;
107        };
108        let parent = stem.rsplit_once('.').map(|(package, _)| package);
109        self.packages
110            .get(stem)
111            .or_else(|| parent.and_then(|package| self.packages.get(package)))
112            .copied()
113            .unwrap_or(self.field_names)
114    }
115
116    /// The [`PackageFields`] of the model file `file_name` (see [`Self::field_names_for`]).
117    fn fields_for(&self, file_name: &str) -> Option<&PackageFields> {
118        let stem = file_name.strip_suffix(".rs")?;
119        let parent = stem.rsplit_once('.').map(|(package, _)| package);
120        self.fields
121            .get(stem)
122            .or_else(|| parent.and_then(|package| self.fields.get(package)))
123    }
124
125    /// Rewrite the source of the model file `file_name`.
126    pub fn rewrite(&self, file_name: &str, src: &str) -> String {
127        let mut out = src.to_string();
128        if self.field_names_for(file_name) == JsonFieldNames::Proto {
129            out = swap_serde_renames(&out);
130            out = snakeify_serialize_entries(&out);
131        }
132        if let Some(fields) = self.fields_for(file_name) {
133            out = apply_field_options(&out, fields);
134        }
135        if let Some(module) = &self.int64_module {
136            out = int64_helpers_to(&out, module);
137            out = int64_containers_to(&out, module);
138        }
139        if let Some(module) = &self.json_value_module {
140            out = json_value_serializers(&out, module);
141        }
142        out
143    }
144
145    /// Rewrite the model files under `dir` in place. Returns the number of files changed.
146    pub fn rewrite_dir(&self, dir: &Path) -> Result<usize> {
147        if self.is_empty() || !dir.exists() {
148            return Ok(0);
149        }
150        let mut changed = 0;
151        for path in model_files(dir)? {
152            let src = std::fs::read_to_string(&path).map_err(Error::Io)?;
153            let file_name = path
154                .file_name()
155                .and_then(|n| n.to_str())
156                .unwrap_or_default();
157            let rewritten = self.rewrite(file_name, &src);
158            if rewritten != src {
159                std::fs::write(&path, rewritten).map_err(Error::Io)?;
160                changed += 1;
161            }
162        }
163        Ok(changed)
164    }
165}
166
167/// Every `.rs` file under `dir`, sorted, without the [`TRESTLE_FILES`].
168fn model_files(dir: &Path) -> Result<Vec<PathBuf>> {
169    let mut out = Vec::new();
170    let mut stack = vec![dir.to_path_buf()];
171    while let Some(d) = stack.pop() {
172        for entry in std::fs::read_dir(&d).map_err(Error::Io)? {
173            let path = entry.map_err(Error::Io)?.path();
174            let trestle_file = d == dir
175                && path
176                    .file_name()
177                    .and_then(|n| n.to_str())
178                    .is_some_and(|n| TRESTLE_FILES.contains(&n));
179            if trestle_file {
180                continue;
181            }
182            if path.is_dir() {
183                stack.push(path);
184            } else if path.extension().is_some_and(|e| e == "rs") {
185                out.push(path);
186            }
187        }
188    }
189    out.sort();
190    Ok(out)
191}
192
193/// Swap `rename`/`alias` in each `#[serde(...)]` whose `rename` is the default proto3-JSON name
194/// of its `alias`, so serialization uses the proto name. An explicit `json_name` is kept.
195fn swap_serde_renames(src: &str) -> String {
196    // One `#[serde( ... )]` attribute; the body never contains `)]`.
197    static SERDE_ATTR: LazyLock<Regex> =
198        LazyLock::new(|| Regex::new(r"#\[serde\((?P<body>[^)]*)\)\]").expect("valid regex"));
199    static RENAME: LazyLock<Regex> =
200        LazyLock::new(|| Regex::new(r#"rename = "(?P<v>[^"]*)""#).expect("valid regex"));
201    static ALIAS: LazyLock<Regex> =
202        LazyLock::new(|| Regex::new(r#"alias = "(?P<v>[^"]*)""#).expect("valid regex"));
203
204    SERDE_ATTR
205        .replace_all(src, |caps: &regex::Captures| {
206            let whole = &caps[0];
207            let body = &caps["body"];
208            let (Some(rn), Some(al)) = (RENAME.captures(body), ALIAS.captures(body)) else {
209                return whole.to_string();
210            };
211            let rename_val = rn["v"].to_string();
212            let alias_val = al["v"].to_string();
213            // Swap only the default JSON name; this also keeps a second run a no-op.
214            if rename_val == alias_val || rename_val != lower_camel(&alias_val) {
215                return whole.to_string();
216            }
217            let body = ALIAS.replace(body, format!(r#"alias = "{rename_val}""#));
218            let body = RENAME.replace(&body, format!(r#"rename = "{alias_val}""#));
219            format!("#[serde({body})]")
220        })
221        .into_owned()
222}
223
224/// Convert the default `serialize_entry("camelCase", …)` names of the `*View` `Serialize` impls
225/// to snake_case.
226fn snakeify_serialize_entries(src: &str) -> String {
227    static ENTRY: LazyLock<Regex> =
228        LazyLock::new(|| Regex::new(r#"serialize_entry\("(?P<name>[^"]*)""#).expect("valid regex"));
229    ENTRY
230        .replace_all(src, |caps: &regex::Captures| {
231            let name = &caps["name"];
232            let snake = camel_to_snake(name);
233            let is_default_json_name =
234                name.starts_with(|c: char| c.is_ascii_lowercase()) && lower_camel(&snake) == name;
235            if is_default_json_name {
236                format!(r#"serialize_entry("{snake}""#)
237            } else {
238                caps[0].to_string()
239            }
240        })
241        .into_owned()
242}
243
244/// Point buffa's `int64`, `opt_int64`, `uint64` and `opt_uint64` helpers at `module`.
245fn int64_helpers_to(src: &str, module: &str) -> String {
246    static HELPER: LazyLock<Regex> = LazyLock::new(|| {
247        Regex::new(r"::buffa::json_helpers::(?P<name>(?:opt_)?u?int64)\b").expect("valid regex")
248    });
249    HELPER
250        .replace_all(src, |caps: &regex::Captures| {
251            format!("{module}::{}", &caps["name"])
252        })
253        .into_owned()
254}
255
256/// Apply the field options: an explicit `json_name` becomes the `rename` (restoring what
257/// [`swap_serde_renames`] may have swapped).
258fn apply_field_options(src: &str, fields: &PackageFields) -> String {
259    static STRUCT: LazyLock<Regex> =
260        LazyLock::new(|| Regex::new(r"^\s*pub struct (\w+)").expect("valid regex"));
261    static FIELD: LazyLock<Regex> =
262        LazyLock::new(|| Regex::new(r"^\s*pub (?:r#)?(\w+):").expect("valid regex"));
263    static RENAME: LazyLock<Regex> =
264        LazyLock::new(|| Regex::new(r#"rename = "[^"]*""#).expect("valid regex"));
265    static ALIAS: LazyLock<Regex> =
266        LazyLock::new(|| Regex::new(r#"alias = "[^"]*""#).expect("valid regex"));
267
268    let lines: Vec<&str> = src.lines().collect();
269    let mut out: Vec<String> = Vec::with_capacity(lines.len());
270    let mut message = None;
271    for (i, line) in lines.iter().enumerate() {
272        if let Some(caps) = STRUCT.captures(line) {
273            message = Some(caps[1].to_string());
274        } else if let (Some(message), Some(caps)) = (&message, FIELD.captures(line)) {
275            let name = &caps[1];
276            let key = format!("{message}.{name}");
277            if let Some(json) = fields.json_names.get(&key) {
278                let attrs = attribute_lines(&lines, i);
279                let text = lines[attrs.clone()].join("\n");
280                let text = RENAME
281                    .replace(&text, format!(r#"rename = "{json}""#))
282                    .into_owned();
283                let text = ALIAS
284                    .replace(&text, format!(r#"alias = "{name}""#))
285                    .into_owned();
286                out.truncate(out.len() - attrs.len());
287                out.extend(text.lines().map(str::to_string));
288            }
289        }
290        out.push(line.to_string());
291    }
292    let mut out = out.join("\n");
293    if src.ends_with('\n') {
294        out.push('\n');
295    }
296    out
297}
298
299/// Point repeated `int64`/`uint64` fields and maps of them at the `repeated_int64` and
300/// `map_int64` helpers of `module`, instead of buffa's `proto_seq`/`proto_map`.
301fn int64_containers_to(src: &str, module: &str) -> String {
302    let lines: Vec<&str> = src.lines().collect();
303    let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
304    for i in 0..lines.len() {
305        let trimmed = lines[i].trim_start();
306        if !trimmed.starts_with("pub ") || !trimmed.contains(':') && !trimmed.ends_with('<') {
307            continue;
308        }
309        let (decl, _) = declaration(&lines, i);
310        let int64 = |ty: &str| ty == "i64" || ty == "u64";
311        let (from, to) = if decl
312            .split_once("Vec<")
313            .is_some_and(|(_, rest)| int64(rest.split('>').next().unwrap_or_default()))
314        {
315            ("::buffa::json_helpers::proto_seq", "repeated_int64")
316        } else if decl
317            .split_once("HashMap<")
318            .and_then(|(_, rest)| rest.rsplit_once('>'))
319            .and_then(|(args, _)| args.rsplit_once(','))
320            .is_some_and(|(_, value)| int64(value.trim()))
321        {
322            ("::buffa::json_helpers::proto_map", "map_int64")
323        } else {
324            continue;
325        };
326        for j in attribute_lines(&lines, i) {
327            out[j] = out[j].replace(from, &format!("{module}::{to}"));
328        }
329    }
330    let mut out = out.join("\n");
331    if src.ends_with('\n') {
332        out.push('\n');
333    }
334    out
335}
336
337/// The field declaration at line `start`, whitespace collapsed (`pub a: Vec<i64>,`), and the
338/// line it ends on.
339fn declaration(lines: &[&str], start: usize) -> (String, usize) {
340    let mut end = start;
341    let mut decl = String::new();
342    loop {
343        decl.push_str(lines[end].trim());
344        decl.push(' ');
345        let depth = decl.matches('<').count() as isize - decl.matches('>').count() as isize;
346        if (depth <= 0 && lines[end].trim_end().ends_with(',')) || end + 1 == lines.len() {
347            break;
348        }
349        end += 1;
350    }
351    let decl = decl
352        .replace(", >", ">")
353        .replace("< ", "<")
354        .replace(" >", ">");
355    (decl, end)
356}
357
358/// Give each free-form JSON field (singular, repeated or map value) a `serialize_with` of
359/// `module`, unless it has one already.
360fn json_value_serializers(src: &str, module: &str) -> String {
361    const JSON: &str = "::buffa_types::google::protobuf::";
362    let lines: Vec<&str> = src.lines().collect();
363    let mut out = String::with_capacity(src.len() + 256);
364    let mut i = 0;
365    while i < lines.len() {
366        let line = lines[i];
367        let trimmed = line.trim_start();
368        if trimmed.starts_with("pub ") && trimmed.contains(": ") && line.contains(JSON) || {
369            // A type that rustfmt broke onto the next lines.
370            trimmed.starts_with("pub ") && trimmed.ends_with('<')
371        } {
372            let (decl, end) = declaration(&lines, i);
373            let kind = if !decl.contains(JSON) {
374                None
375            } else if decl.contains(": ::buffa::MessageField<") {
376                Some("message_field")
377            } else if decl.contains(": ::buffa::alloc::vec::Vec<") {
378                Some("repeated")
379            } else if decl.contains("HashMap<") {
380                Some("map")
381            } else {
382                None
383            };
384            let attrs = field_attributes(&lines, i);
385            if let Some(kind) = kind
386                && !has_serde_key(&attrs, "serialize_with")
387                && !has_serde_key(&attrs, "with")
388            {
389                let indent = &line[..line.len() - trimmed.len()];
390                out.push_str(&format!(
391                    "{indent}#[serde(serialize_with = \"{module}::{kind}\")]\n"
392                ));
393            }
394            for l in &lines[i..=end] {
395                out.push_str(l);
396                out.push('\n');
397            }
398            i = end + 1;
399            continue;
400        }
401        out.push_str(line);
402        out.push('\n');
403        i += 1;
404    }
405    if !src.ends_with('\n') {
406        out.pop();
407    }
408    out
409}
410
411/// Whether the serde attributes `attrs` set `key` as a whole word (`deserialize_with` does not
412/// set `with`).
413fn has_serde_key(attrs: &str, key: &str) -> bool {
414    attrs.match_indices(key).any(|(at, _)| {
415        let before = attrs[..at].chars().next_back();
416        let after = attrs[at + key.len()..].trim_start();
417        !before.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_') && after.starts_with('=')
418    })
419}
420
421/// The attributes above the field declaration at line `field`, as one string.
422fn field_attributes(lines: &[&str], field: usize) -> String {
423    attribute_lines(lines, field)
424        .map(|j| lines[j].trim())
425        .collect()
426}
427
428/// The lines of the attributes and doc comments above the field declaration at line `field`.
429fn attribute_lines(lines: &[&str], field: usize) -> std::ops::Range<usize> {
430    let mut j = field;
431    while j > 0 {
432        let prev = lines[j - 1].trim();
433        if prev.starts_with("//") || prev.starts_with("#[") && prev.ends_with(']') {
434            j -= 1;
435        } else if prev.ends_with(")]") {
436            // A multi-line attribute: back up to its `#[`.
437            let mut k = j - 1;
438            while k > 0 && !lines[k].trim_start().starts_with("#[") {
439                k -= 1;
440            }
441            j = k;
442        } else {
443            break;
444        }
445    }
446    j..field
447}
448
449/// Invert the proto3 JSON mapping: each uppercase letter becomes `_` + its lowercase.
450fn camel_to_snake(name: &str) -> String {
451    let mut out = String::with_capacity(name.len() + 4);
452    for ch in name.chars() {
453        if ch.is_ascii_uppercase() {
454            out.push('_');
455            out.push(ch.to_ascii_lowercase());
456        } else {
457            out.push(ch);
458        }
459    }
460    out
461}
462
463/// Put blank lines between the items of a pretty-printed Rust file (prettyplease prints none;
464/// rustfmt keeps them but never adds them): before an item that follows a `}` or a `use` group
465/// at the same indentation, and after the file's inner attributes. Idempotent.
466pub fn space_items(src: &str) -> String {
467    let lines: Vec<&str> = src.lines().collect();
468    let mut out = String::with_capacity(src.len() + src.len() / 16);
469    for (i, line) in lines.iter().enumerate() {
470        out.push_str(line);
471        out.push('\n');
472        if lines
473            .get(i + 1)
474            .is_some_and(|next| blank_between(line, next))
475        {
476            out.push('\n');
477        }
478    }
479    out
480}
481
482/// Whether a blank line goes between the lines `line` and `next`.
483fn blank_between(line: &str, next: &str) -> bool {
484    let indent = |l: &str| l.len() - l.trim_start().len();
485    if line.trim().is_empty() || next.trim().is_empty() || indent(line) != indent(next) {
486        return false;
487    }
488    let (line, next) = (line.trim(), next.trim());
489    let is_use = |l: &str| {
490        ["use ", "pub use ", "pub(crate) use ", "pub(super) use "]
491            .iter()
492            .any(|p| l.starts_with(p))
493    };
494    let is_inner = |l: &str| l.starts_with("//!") || l.starts_with("#![");
495    if is_inner(line) {
496        return !is_inner(next);
497    }
498    if !starts_item(next) {
499        return false;
500    }
501    match line {
502        "}" => true,
503        // A multi-line `use` ends with `};`: the next `use` stays in the group.
504        "};" => !is_use(next),
505        _ => is_use(line) && line.ends_with(';') && !is_use(next),
506    }
507}
508
509/// Whether `line` (trimmed) starts an item, or the attributes or docs of one.
510fn starts_item(line: &str) -> bool {
511    const STARTS: &[&str] = &[
512        "#[",
513        "///",
514        "/**",
515        "pub ",
516        "pub(",
517        "fn ",
518        "async fn ",
519        "const fn ",
520        "unsafe ",
521        "extern ",
522        "impl ",
523        "impl<",
524        "struct ",
525        "enum ",
526        "trait ",
527        "mod ",
528        "type ",
529        "const ",
530        "static ",
531        "use ",
532        "macro_rules!",
533        "include!",
534    ];
535    STARTS.iter().any(|p| line.starts_with(p))
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    fn proto() -> ModelRewrites {
543        ModelRewrites {
544            field_names: JsonFieldNames::Proto,
545            ..Default::default()
546        }
547    }
548
549    #[test]
550    fn camel_to_snake_conversions() {
551        assert_eq!(camel_to_snake("displayName"), "display_name");
552        assert_eq!(camel_to_snake("nextPageToken"), "next_page_token");
553        assert_eq!(camel_to_snake("name"), "name");
554    }
555
556    #[test]
557    fn snakeifies_view_serialize_entry_literal() {
558        let src = r#"                __map.serialize_entry("displayName", self.display_name)?;"#;
559        let out = proto().rewrite("demo.v1.rs", src);
560        assert!(out.contains(r#"serialize_entry("display_name", self.display_name)"#));
561    }
562
563    #[test]
564    fn view_serialize_entry_explicit_names_untouched() {
565        for src in [
566            r#"__map.serialize_entry("name", self.name)?;"#,
567            r#"__map.serialize_entry("metadata-location", self.metadata_location)?;"#,
568            r#"__map.serialize_entry("Resources", self.resources)?;"#,
569        ] {
570            assert_eq!(proto().rewrite("demo.v1.rs", src), src);
571        }
572    }
573
574    #[test]
575    fn swaps_multiword_rename_alias_pair() {
576        let src = r#"    #[serde(
577        rename = "storageRoot",
578        alias = "storage_root",
579        skip_serializing_if = "::core::option::Option::is_none"
580    )]
581    pub storage_root: Option<String>,"#;
582        let out = swap_serde_renames(src);
583        assert!(out.contains(r#"rename = "storage_root""#));
584        assert!(out.contains(r#"alias = "storageRoot""#));
585        assert!(out.contains(r#"skip_serializing_if = "::core::option::Option::is_none""#));
586    }
587
588    #[test]
589    fn single_word_rename_only_is_untouched() {
590        let src = r#"    #[serde(rename = "name", skip_serializing_if = "Option::is_none")]"#;
591        assert_eq!(swap_serde_renames(src), src);
592    }
593
594    #[test]
595    fn explicit_json_name_is_kept() {
596        // `json_name = "metadata-location"`: not the default JSON name of `metadata_location`.
597        let src = r#"#[serde(rename = "metadata-location", alias = "metadata_location")]"#;
598        assert_eq!(swap_serde_renames(src), src);
599        let src = r#"#[serde(rename = "Resources", alias = "resources")]"#;
600        assert_eq!(swap_serde_renames(src), src);
601    }
602
603    #[test]
604    fn enum_and_scalar_with_helpers_preserved() {
605        let src = r#"    #[serde(
606        rename = "catalogType",
607        alias = "catalog_type",
608        with = "::buffa::json_helpers::opt_enum",
609        skip_serializing_if = "::core::option::Option::is_none"
610    )]"#;
611        let out = swap_serde_renames(src);
612        assert!(out.contains(r#"rename = "catalog_type""#));
613        assert!(out.contains(r#"alias = "catalogType""#));
614        assert!(out.contains(r#"with = "::buffa::json_helpers::opt_enum""#));
615    }
616
617    #[test]
618    fn swap_is_idempotent_on_already_swapped_output() {
619        let camel = r#"    #[serde(rename = "storageRoot", alias = "storage_root", skip_serializing_if = "x")]"#;
620        let once = swap_serde_renames(camel);
621        assert!(once.contains(r#"rename = "storage_root", alias = "storageRoot""#));
622        assert_eq!(swap_serde_renames(&once), once);
623    }
624
625    #[test]
626    fn non_field_serde_attr_is_untouched() {
627        assert_eq!(swap_serde_renames("#[serde(default)]"), "#[serde(default)]");
628    }
629
630    #[test]
631    fn package_override_keeps_camel_case() {
632        let mut rewrites = proto();
633        rewrites
634            .packages
635            .insert("demo.scim.v2".into(), JsonFieldNames::Json);
636        let src = r#"#[serde(rename = "userName", alias = "user_name")]"#;
637        assert_eq!(rewrites.rewrite("demo.scim.v2.rs", src), src);
638        assert_eq!(rewrites.rewrite("demo.scim.v2.users.rs", src), src);
639        assert!(
640            rewrites
641                .rewrite("demo.v1.rs", src)
642                .contains(r#"rename = "user_name""#)
643        );
644    }
645
646    #[test]
647    fn field_options_set_json_names() {
648        let src = r#"pub struct Spec {
649    #[serde(rename = "spec_id", alias = "specId")]
650    pub spec_id: i32,
651}
652pub struct Other {
653    #[serde(rename = "spec_id", alias = "specId")]
654    pub spec_id: i32,
655}
656"#;
657        let fields = PackageFields {
658            json_names: [("Spec.spec_id".to_string(), "specId".to_string())].into(),
659        };
660        let out = apply_field_options(src, &fields);
661        assert!(
662            out.contains(r#"rename = "specId", alias = "spec_id""#),
663            "{out}"
664        );
665        // Only `Spec.spec_id` is renamed.
666        assert_eq!(out.matches(r#"rename = "specId""#).count(), 1, "{out}");
667    }
668
669    #[test]
670    fn int64_containers_write_numbers() {
671        let src = r#"    #[serde(rename = "sizes", with = "::buffa::json_helpers::proto_seq")]
672    pub sizes: ::buffa::alloc::vec::Vec<i64>,
673    #[serde(
674        rename = "counts",
675        with = "::buffa::json_helpers::proto_map"
676    )]
677    pub counts: ::buffa::__private::HashMap<
678        ::buffa::alloc::string::String,
679        i64,
680    >,
681    #[serde(rename = "ratios", with = "::buffa::json_helpers::proto_seq")]
682    pub ratios: ::buffa::alloc::vec::Vec<f64>,
683"#;
684        let out = int64_containers_to(src, "crate::models::json_int64");
685        assert!(out.contains(r#"with = "crate::models::json_int64::repeated_int64""#));
686        assert!(out.contains(r#"with = "crate::models::json_int64::map_int64""#));
687        assert_eq!(
688            out.matches("::buffa::json_helpers::proto_seq").count(),
689            1,
690            "{out}"
691        );
692        assert!(out.ends_with(",\n"));
693    }
694
695    #[test]
696    fn int64_helpers_point_at_module() {
697        let rewrites = ModelRewrites {
698            int64_module: Some("crate::models::json_int64".into()),
699            ..Default::default()
700        };
701        let src = r#"with = "::buffa::json_helpers::opt_int64",
702            ::buffa::json_helpers::int64::serialize(&self.0, __s)
703            with = "::buffa::json_helpers::uint64",
704            with = "::buffa::json_helpers::int32","#;
705        let out = rewrites.rewrite("demo.v1.rs", src);
706        assert!(out.contains(r#"with = "crate::models::json_int64::opt_int64""#));
707        assert!(out.contains("crate::models::json_int64::int64::serialize(&self.0, __s)"));
708        assert!(out.contains(r#"with = "crate::models::json_int64::uint64""#));
709        assert!(out.contains(r#"with = "::buffa::json_helpers::int32""#));
710        // Idempotent.
711        assert_eq!(rewrites.rewrite("demo.v1.rs", &out), out);
712    }
713
714    #[test]
715    fn rewrite_dir_leaves_trestle_files_alone() {
716        let rewrites = ModelRewrites {
717            int64_module: Some("crate::models::json_int64".into()),
718            ..Default::default()
719        };
720        let dir = tempfile::tempdir().unwrap();
721        let src = r#"with = "::buffa::json_helpers::int64","#;
722        std::fs::create_dir(dir.path().join("extractors")).unwrap();
723        for file in ["demo.v1.rs", "mod.rs", "extractors/mod.rs"] {
724            std::fs::write(dir.path().join(file), src).unwrap();
725        }
726        assert_eq!(rewrites.rewrite_dir(dir.path()).unwrap(), 1);
727        let read = |file: &str| std::fs::read_to_string(dir.path().join(file)).unwrap();
728        assert!(read("demo.v1.rs").contains("crate::models::json_int64::int64"));
729        assert_eq!(read("mod.rs"), src);
730        assert_eq!(read("extractors/mod.rs"), src);
731    }
732
733    #[test]
734    fn space_items_separates_items_and_functions() {
735        let src = "//! Docs.
736use a::B;
737use c::D;
738/// A thing.
739pub struct S {
740    x: u8,
741}
742impl S {
743    fn a(&self) {
744        if true {
745            f();
746        }
747        g();
748    }
749    #[inline]
750    fn b(&self) -> u8 {
751        match self.x {
752            0 => {
753                1
754            }
755            _ => 2,
756        }
757    }
758}
759";
760        let spaced = space_items(src);
761        assert_eq!(
762            spaced,
763            "//! Docs.
764
765use a::B;
766use c::D;
767
768/// A thing.
769pub struct S {
770    x: u8,
771}
772
773impl S {
774    fn a(&self) {
775        if true {
776            f();
777        }
778        g();
779    }
780
781    #[inline]
782    fn b(&self) -> u8 {
783        match self.x {
784            0 => {
785                1
786            }
787            _ => 2,
788        }
789    }
790}
791"
792        );
793        assert_eq!(space_items(&spaced), spaced);
794    }
795
796    #[test]
797    fn json_value_fields_serialize_through_the_module() {
798        let rewrites = ModelRewrites {
799            json_value_module: Some("crate::models::json_value".into()),
800            ..Default::default()
801        };
802        let src = r#"    #[serde(rename = "attributes")]
803    pub attributes: ::buffa::MessageField<::buffa_types::google::protobuf::Struct>,
804    #[serde(rename = "notes", deserialize_with = "::buffa::json_helpers::null_as_default")]
805    pub notes: ::buffa::alloc::vec::Vec<::buffa_types::google::protobuf::Value>,
806    #[serde(rename = "rows")]
807    pub rows: ::buffa::__private::HashMap<
808        ::buffa::alloc::string::String,
809        ::buffa_types::google::protobuf::ListValue,
810    >,
811    #[serde(rename = "name")]
812    pub name: ::buffa::alloc::string::String,
813"#;
814        let out = rewrites.rewrite("demo.v1.rs", src);
815        for kind in ["message_field", "repeated", "map"] {
816            assert!(
817                out.contains(&format!(
818                    "#[serde(serialize_with = \"crate::models::json_value::{kind}\")]"
819                )),
820                "{out}"
821            );
822        }
823        assert_eq!(
824            out.matches("serialize_with = \"crate::models").count(),
825            3,
826            "{out}"
827        );
828        assert!(out.contains("    pub rows: ::buffa::__private::HashMap<\n"));
829    }
830}