1#![forbid(unsafe_code)]
42#![deny(missing_docs)]
43#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
44
45mod classify;
46mod render;
47mod walk;
48
49use std::path::{Path, PathBuf};
50
51use indexmap::IndexMap;
52use plumb_config::ConfigError;
53use plumb_core::Config;
54use thiserror::Error;
55
56pub use render::render_toml;
57
58pub const MAX_WALK_DEPTH: usize = 6;
65
66#[derive(Debug, Error)]
68#[non_exhaustive]
69pub enum CodegenError {
70 #[error("source directory not found: {0}")]
72 NotFound(String),
73 #[error("source path is not a directory: {0}")]
75 NotADirectory(String),
76 #[error("failed to read `{path}`: {source}")]
78 Io {
79 path: String,
81 #[source]
83 source: std::io::Error,
84 },
85 #[error("failed to parse token source: {0}")]
88 Source(#[from] ConfigError),
89 #[error("failed to render TOML: {0}")]
91 Render(#[from] toml::ser::Error),
92}
93
94#[derive(Debug, Clone)]
99pub struct InferredConfig {
100 pub config: Config,
103 pub summary: Vec<String>,
106 pub sources: Vec<TokenSource>,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct TokenSource {
114 pub kind: TokenSourceKind,
116 pub relative_path: PathBuf,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
123#[non_exhaustive]
124pub enum TokenSourceKind {
125 TailwindConfig,
129 CssCustomProperties,
131 Dtcg,
133}
134
135impl TokenSourceKind {
136 fn label(self) -> &'static str {
139 match self {
140 Self::TailwindConfig => "tailwind",
141 Self::CssCustomProperties => "css",
142 Self::Dtcg => "dtcg",
143 }
144 }
145}
146
147const TAILWIND_CONFIG_NAMES: &[&str] = &[
151 "tailwind.config.ts",
152 "tailwind.config.mts",
153 "tailwind.config.cts",
154 "tailwind.config.js",
155 "tailwind.config.mjs",
156 "tailwind.config.cjs",
157];
158
159pub fn infer_config(source_dir: &Path) -> Result<InferredConfig, CodegenError> {
175 if !source_dir.exists() {
176 return Err(CodegenError::NotFound(source_dir.display().to_string()));
177 }
178 if !source_dir.is_dir() {
179 return Err(CodegenError::NotADirectory(
180 source_dir.display().to_string(),
181 ));
182 }
183
184 let walked = walk::walk(source_dir)?;
185
186 let mut config = Config::default();
187 let mut summary: Vec<(u8, String, String)> = Vec::new();
188 let mut sources: Vec<TokenSource> = Vec::new();
189
190 for tailwind_path in &walked.tailwind_configs {
193 let relative = relative_to(source_dir, tailwind_path);
194 sources.push(TokenSource {
195 kind: TokenSourceKind::TailwindConfig,
196 relative_path: relative.clone(),
197 });
198 summary.push((
199 order_tag(TokenSourceKind::TailwindConfig),
200 display_path(&relative),
201 format!("tailwind config at {}", display_path(&relative)),
202 ));
203 }
204
205 if !walked.css_files.is_empty() {
207 let scrapes = plumb_config::scrape_css_properties(&walked.css_files)?;
208 let mut by_file: IndexMap<PathBuf, classify::PerFileStats> = IndexMap::new();
210 for scrape in &scrapes {
211 by_file
212 .entry(scrape.source.clone())
213 .or_default()
214 .increment(&scrape.value);
215 }
216 classify::classify_css_scrapes(&scrapes, &mut config);
217 for (path, file_stats) in by_file {
220 let relative = relative_to(source_dir, &path);
221 sources.push(TokenSource {
222 kind: TokenSourceKind::CssCustomProperties,
223 relative_path: relative.clone(),
224 });
225 summary.push((
226 order_tag(TokenSourceKind::CssCustomProperties),
227 display_path(&relative),
228 format!(
229 "css custom properties from {} ({} colors, {} dimensions, {} other)",
230 display_path(&relative),
231 file_stats.colors,
232 file_stats.dimensions,
233 file_stats.other,
234 ),
235 ));
236 }
237 }
238
239 for dtcg_path in &walked.dtcg_files {
241 let contents = std::fs::read_to_string(dtcg_path).map_err(|source| CodegenError::Io {
242 path: dtcg_path.display().to_string(),
243 source,
244 })?;
245 let source = plumb_config::DtcgSource {
246 path: dtcg_path.clone(),
247 contents,
248 };
249 let import = plumb_config::merge_dtcg(&mut config, &source)?;
250 let relative = relative_to(source_dir, dtcg_path);
251 sources.push(TokenSource {
252 kind: TokenSourceKind::Dtcg,
253 relative_path: relative.clone(),
254 });
255 summary.push((
256 order_tag(TokenSourceKind::Dtcg),
257 display_path(&relative),
258 format!(
259 "dtcg tokens from {} (+{} colors, +{} spacing, +{} type sizes, +{} radii)",
260 display_path(&relative),
261 import.color_added,
262 import.spacing_added,
263 import.type_size_added,
264 import.radius_added,
265 ),
266 ));
267 }
268
269 sort_and_dedup(&mut config.spacing.scale);
272 sort_and_dedup(&mut config.type_scale.scale);
273 sort_and_dedup(&mut config.radius.scale);
274
275 summary.sort();
277 let summary = summary.into_iter().map(|(_, _, line)| line).collect();
278
279 Ok(InferredConfig {
280 config,
281 summary,
282 sources,
283 })
284}
285
286fn order_tag(kind: TokenSourceKind) -> u8 {
289 match kind {
290 TokenSourceKind::TailwindConfig => 0,
291 TokenSourceKind::CssCustomProperties => 1,
292 TokenSourceKind::Dtcg => 2,
293 }
294}
295
296fn relative_to(base: &Path, path: &Path) -> PathBuf {
300 path.strip_prefix(base)
301 .map_or_else(|_| path.to_path_buf(), Path::to_path_buf)
302}
303
304fn display_path(path: &Path) -> String {
307 path.components()
308 .map(|c| c.as_os_str().to_string_lossy().into_owned())
309 .collect::<Vec<_>>()
310 .join("/")
311}
312
313fn sort_and_dedup<T: Ord>(values: &mut Vec<T>) {
314 values.sort();
315 values.dedup();
316}
317
318#[cfg(test)]
319#[allow(clippy::unwrap_used, clippy::expect_used)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn missing_source_dir_errors() {
325 let err = infer_config(Path::new("/nonexistent/plumb/codegen/test"))
326 .expect_err("infer_config should fail on missing path");
327 assert!(matches!(err, CodegenError::NotFound(_)));
328 }
329
330 #[test]
331 fn non_directory_errors() {
332 let dir = tempfile::tempdir().unwrap();
333 let file = dir.path().join("not-a-dir.txt");
334 std::fs::write(&file, "hello").unwrap();
335 let err = infer_config(&file).expect_err("infer_config should fail on file path");
336 assert!(matches!(err, CodegenError::NotADirectory(_)));
337 }
338
339 #[test]
340 fn empty_dir_returns_default_config() {
341 let dir = tempfile::tempdir().unwrap();
342 let inferred = infer_config(dir.path()).unwrap();
343 assert!(inferred.summary.is_empty());
344 assert!(inferred.sources.is_empty());
345 assert!(inferred.config.color.tokens.is_empty());
346 assert!(inferred.config.spacing.scale.is_empty());
347 }
348
349 #[test]
350 fn detects_tailwind_config() {
351 let dir = tempfile::tempdir().unwrap();
352 std::fs::write(
353 dir.path().join("tailwind.config.ts"),
354 "export default { content: [] };\n",
355 )
356 .unwrap();
357 let inferred = infer_config(dir.path()).unwrap();
358 assert_eq!(inferred.sources.len(), 1);
359 assert_eq!(inferred.sources[0].kind, TokenSourceKind::TailwindConfig);
360 assert_eq!(
361 inferred.sources[0].relative_path,
362 Path::new("tailwind.config.ts")
363 );
364 }
365
366 #[test]
367 fn classifies_css_custom_properties_into_tokens() {
368 let dir = tempfile::tempdir().unwrap();
369 let styles = dir.path().join("styles");
370 std::fs::create_dir_all(&styles).unwrap();
371 std::fs::write(
372 styles.join("tokens.css"),
373 r":root {
374 --color-bg: #ffffff;
375 --color-fg: #0b0b0b;
376 --color-accent: #0b7285;
377 --space-xs: 4px;
378 --space-sm: 8px;
379 --radius-md: 8px;
380 }",
381 )
382 .unwrap();
383 let inferred = infer_config(dir.path()).unwrap();
384 assert_eq!(inferred.config.color.tokens.len(), 3);
385 assert_eq!(
386 inferred.config.color.tokens.get("color-bg"),
387 Some(&"#ffffff".to_owned())
388 );
389 assert_eq!(inferred.config.spacing.scale, vec![4, 8]);
390 assert_eq!(inferred.config.radius.scale, vec![8]);
391 }
392
393 #[test]
394 fn skips_node_modules_and_dotfile_dirs() {
395 let dir = tempfile::tempdir().unwrap();
396 for skipped in ["node_modules", "target", ".git", "dist", "build"] {
398 let nested = dir.path().join(skipped).join("nested");
399 std::fs::create_dir_all(&nested).unwrap();
400 std::fs::write(
401 nested.join("trap.css"),
402 ":root { --color-trap: #ff0000; }\n",
403 )
404 .unwrap();
405 }
406 let inferred = infer_config(dir.path()).unwrap();
407 assert!(inferred.config.color.tokens.is_empty());
408 assert!(inferred.sources.is_empty());
409 }
410
411 #[test]
412 fn deterministic_across_runs() {
413 let dir = tempfile::tempdir().unwrap();
414 let styles = dir.path().join("src/styles");
415 std::fs::create_dir_all(&styles).unwrap();
416 std::fs::write(
417 styles.join("a.css"),
418 ":root { --color-a: #aabbcc; --space-xs: 4px; }",
419 )
420 .unwrap();
421 std::fs::write(
422 styles.join("b.css"),
423 ":root { --color-b: #112233; --space-sm: 8px; }",
424 )
425 .unwrap();
426 let one = infer_config(dir.path()).unwrap();
427 let two = infer_config(dir.path()).unwrap();
428 assert_eq!(one.summary, two.summary);
429 assert_eq!(one.config.color.tokens, two.config.color.tokens);
430 assert_eq!(one.config.spacing.scale, two.config.spacing.scale);
431 }
432
433 #[test]
434 fn merges_dtcg_token_files() {
435 let dir = tempfile::tempdir().unwrap();
436 let dtcg = r##"{
437 "color": {
438 "primary": { "$type": "color", "$value": "#0b7285" }
439 },
440 "spacing": {
441 "xs": { "$type": "dimension", "$value": "4px" }
442 }
443 }"##;
444 std::fs::write(dir.path().join("design.tokens.json"), dtcg).unwrap();
445 let inferred = infer_config(dir.path()).unwrap();
446 assert_eq!(
447 inferred.config.color.tokens.get("color/primary"),
448 Some(&"#0b7285".to_owned())
449 );
450 assert!(inferred.config.spacing.tokens.contains_key("spacing/xs"));
451 assert_eq!(inferred.sources.len(), 1);
452 assert_eq!(inferred.sources[0].kind, TokenSourceKind::Dtcg);
453 }
454
455 #[test]
456 fn order_tag_orders_kinds_predictably() {
457 assert!(
458 order_tag(TokenSourceKind::TailwindConfig)
459 < order_tag(TokenSourceKind::CssCustomProperties)
460 );
461 assert!(order_tag(TokenSourceKind::CssCustomProperties) < order_tag(TokenSourceKind::Dtcg));
462 }
463
464 #[test]
465 fn display_path_uses_forward_slashes() {
466 let p = Path::new("src").join("styles").join("tokens.css");
467 assert_eq!(display_path(&p), "src/styles/tokens.css");
468 }
469
470 #[test]
471 fn label_lookup_is_stable() {
472 assert_eq!(TokenSourceKind::TailwindConfig.label(), "tailwind");
473 assert_eq!(TokenSourceKind::CssCustomProperties.label(), "css");
474 assert_eq!(TokenSourceKind::Dtcg.label(), "dtcg");
475 }
476}