1use crate::{CodegenError, InferredConfig, TokenSource, TokenSourceKind};
9
10const HEADER: &str =
14 "# Plumb configuration — bootstrapped by `plumb init --from <path>` from the sources below.";
15
16const TAILWIND_HINT: &str =
20 "# Tailwind config detected. Plumb merges Tailwind theme tokens at lint time —";
21
22pub 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 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 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}