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