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