Skip to main content

plumb_codegen/
render.rs

1//! Render an [`InferredConfig`] to a `plumb.toml` string.
2//!
3//! The output starts with a generated header comment that records, in
4//! sorted order, every source the inference pass consumed. The body is
5//! the [`plumb_core::Config`] serialized via `toml::to_string_pretty`,
6//! preserving `IndexMap` insertion order for tokens.
7
8use crate::{CodegenError, InferredConfig, TokenSource, TokenSourceKind};
9
10/// Comment header byline. Single line so the rendered file's first
11/// content line is always the same number of bytes regardless of
12/// whether the inferrer found anything.
13const HEADER: &str =
14    "# Plumb configuration — bootstrapped by `plumb init --from <path>` from the sources below.";
15
16/// Note appended when a Tailwind config was discovered. Plumb's
17/// `extends = "./tailwind.config.*"` directive is still in flight; this
18/// wording is shared with `examples/plumb-tailwind.toml`.
19const TAILWIND_HINT: &str =
20    "# Tailwind config detected. Plumb merges Tailwind theme tokens at lint time —";
21
22/// Render `inferred` to a TOML string.
23///
24/// The output is byte-identical given the same [`InferredConfig`]. The
25/// header lists discovered source files in stable order; the body is
26/// `toml::to_string_pretty` over the [`plumb_core::Config`].
27///
28/// # Errors
29///
30/// Returns [`CodegenError::Render`] if `toml::to_string_pretty` fails
31/// (extremely rare — the [`plumb_core::Config`] schema is `Serialize`).
32pub fn render_toml(inferred: &InferredConfig) -> Result<String, CodegenError> {
33    let mut out = String::new();
34    out.push_str(HEADER);
35    out.push('\n');
36
37    if inferred.sources.is_empty() {
38        out.push_str("# No design-token sources were discovered. Edit the values below to match your system.\n");
39    } else {
40        out.push_str("#\n");
41        write_source_list(&mut out, &inferred.sources);
42    }
43
44    if has_tailwind(&inferred.sources) {
45        out.push_str("#\n");
46        out.push_str(TAILWIND_HINT);
47        out.push('\n');
48        out.push_str("# run `plumb lint` from the same directory and the adapter will resolve Tailwind's theme.\n");
49    }
50
51    out.push('\n');
52
53    let body = toml::to_string_pretty(&inferred.config)?;
54    out.push_str(&body);
55
56    // Always end on a single newline. `toml::to_string_pretty` emits
57    // one already; defensively ensure the file ends consistently.
58    if !out.ends_with('\n') {
59        out.push('\n');
60    }
61
62    Ok(out)
63}
64
65fn write_source_list(out: &mut String, sources: &[TokenSource]) {
66    use std::fmt::Write as _;
67    let mut sorted: Vec<&TokenSource> = sources.iter().collect();
68    sorted.sort_by(|a, b| {
69        a.kind
70            .cmp(&b.kind)
71            .then_with(|| a.relative_path.cmp(&b.relative_path))
72    });
73    for source in sorted {
74        let label = source.kind.label();
75        let path = display_path(&source.relative_path);
76        // Writes to a `String` buffer cannot fail; ignore the result.
77        let _ = writeln!(out, "# - {label}: {path}");
78    }
79}
80
81fn has_tailwind(sources: &[TokenSource]) -> bool {
82    sources
83        .iter()
84        .any(|s| s.kind == TokenSourceKind::TailwindConfig)
85}
86
87fn display_path(path: &std::path::Path) -> String {
88    path.components()
89        .map(|c| c.as_os_str().to_string_lossy().into_owned())
90        .collect::<Vec<_>>()
91        .join("/")
92}
93
94#[cfg(test)]
95#[allow(clippy::unwrap_used, clippy::expect_used)]
96mod tests {
97    use super::*;
98    use plumb_core::Config;
99    use std::path::PathBuf;
100
101    fn fixture(sources: Vec<TokenSource>) -> InferredConfig {
102        InferredConfig {
103            config: Config::default(),
104            summary: Vec::new(),
105            sources,
106        }
107    }
108
109    #[test]
110    fn empty_inputs_render_with_header_note() {
111        let rendered = render_toml(&fixture(Vec::new())).unwrap();
112        assert!(rendered.starts_with(HEADER));
113        assert!(rendered.contains("No design-token sources were discovered"));
114    }
115
116    #[test]
117    fn renders_sources_in_stable_order() {
118        let inferred = fixture(vec![
119            TokenSource {
120                kind: TokenSourceKind::CssCustomProperties,
121                relative_path: PathBuf::from("z.css"),
122            },
123            TokenSource {
124                kind: TokenSourceKind::CssCustomProperties,
125                relative_path: PathBuf::from("a.css"),
126            },
127            TokenSource {
128                kind: TokenSourceKind::TailwindConfig,
129                relative_path: PathBuf::from("tailwind.config.ts"),
130            },
131        ]);
132        let rendered = render_toml(&inferred).unwrap();
133        let tw_pos = rendered.find("tailwind.config.ts").unwrap();
134        let a_pos = rendered.find("a.css").unwrap();
135        let z_pos = rendered.find("z.css").unwrap();
136        assert!(tw_pos < a_pos, "tailwind should come first");
137        assert!(a_pos < z_pos, "css files should be alphabetical");
138        assert!(rendered.contains("Tailwind config detected"));
139    }
140
141    #[test]
142    fn render_emits_canonical_toml_body() {
143        let mut config = Config::default();
144        config
145            .color
146            .tokens
147            .insert("bg/canvas".into(), "#ffffff".into());
148        let rendered = render_toml(&InferredConfig {
149            config,
150            summary: Vec::new(),
151            sources: Vec::new(),
152        })
153        .unwrap();
154        assert!(rendered.contains("[color]"));
155        assert!(rendered.contains("bg/canvas"));
156        assert!(rendered.ends_with('\n'));
157    }
158}