#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod classify;
mod render;
mod walk;
use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use plumb_config::ConfigError;
use plumb_core::Config;
use thiserror::Error;
pub use render::render_toml;
pub const MAX_WALK_DEPTH: usize = 6;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum CodegenError {
#[error("source directory not found: {0}")]
NotFound(String),
#[error("source path is not a directory: {0}")]
NotADirectory(String),
#[error("failed to read `{path}`: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("failed to parse token source: {0}")]
Source(#[from] ConfigError),
#[error("failed to render TOML: {0}")]
Render(#[from] toml::ser::Error),
}
#[derive(Debug, Clone)]
pub struct InferredConfig {
pub config: Config,
pub summary: Vec<String>,
pub sources: Vec<TokenSource>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TokenSource {
pub kind: TokenSourceKind,
pub relative_path: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum TokenSourceKind {
TailwindConfig,
CssCustomProperties,
Dtcg,
}
impl TokenSourceKind {
fn label(self) -> &'static str {
match self {
Self::TailwindConfig => "tailwind",
Self::CssCustomProperties => "css",
Self::Dtcg => "dtcg",
}
}
}
const TAILWIND_CONFIG_NAMES: &[&str] = &[
"tailwind.config.ts",
"tailwind.config.mts",
"tailwind.config.cts",
"tailwind.config.js",
"tailwind.config.mjs",
"tailwind.config.cjs",
];
pub fn infer_config(source_dir: &Path) -> Result<InferredConfig, CodegenError> {
if !source_dir.exists() {
return Err(CodegenError::NotFound(source_dir.display().to_string()));
}
if !source_dir.is_dir() {
return Err(CodegenError::NotADirectory(
source_dir.display().to_string(),
));
}
let walked = walk::walk(source_dir)?;
let mut config = Config::default();
let mut summary: Vec<(u8, String, String)> = Vec::new();
let mut sources: Vec<TokenSource> = Vec::new();
for tailwind_path in &walked.tailwind_configs {
let relative = relative_to(source_dir, tailwind_path);
sources.push(TokenSource {
kind: TokenSourceKind::TailwindConfig,
relative_path: relative.clone(),
});
summary.push((
order_tag(TokenSourceKind::TailwindConfig),
display_path(&relative),
format!("tailwind config at {}", display_path(&relative)),
));
}
if !walked.css_files.is_empty() {
let scrapes = plumb_config::scrape_css_properties(&walked.css_files)?;
let mut by_file: IndexMap<PathBuf, classify::PerFileStats> = IndexMap::new();
for scrape in &scrapes {
by_file
.entry(scrape.source.clone())
.or_default()
.increment(&scrape.value);
}
classify::classify_css_scrapes(&scrapes, &mut config);
for (path, file_stats) in by_file {
let relative = relative_to(source_dir, &path);
sources.push(TokenSource {
kind: TokenSourceKind::CssCustomProperties,
relative_path: relative.clone(),
});
summary.push((
order_tag(TokenSourceKind::CssCustomProperties),
display_path(&relative),
format!(
"css custom properties from {} ({} colors, {} dimensions, {} other)",
display_path(&relative),
file_stats.colors,
file_stats.dimensions,
file_stats.other,
),
));
}
}
for dtcg_path in &walked.dtcg_files {
let contents = std::fs::read_to_string(dtcg_path).map_err(|source| CodegenError::Io {
path: dtcg_path.display().to_string(),
source,
})?;
let source = plumb_config::DtcgSource {
path: dtcg_path.clone(),
contents,
};
let import = plumb_config::merge_dtcg(&mut config, &source)?;
let relative = relative_to(source_dir, dtcg_path);
sources.push(TokenSource {
kind: TokenSourceKind::Dtcg,
relative_path: relative.clone(),
});
summary.push((
order_tag(TokenSourceKind::Dtcg),
display_path(&relative),
format!(
"dtcg tokens from {} (+{} colors, +{} spacing, +{} type sizes, +{} radii)",
display_path(&relative),
import.color_added,
import.spacing_added,
import.type_size_added,
import.radius_added,
),
));
}
sort_and_dedup(&mut config.spacing.scale);
sort_and_dedup(&mut config.type_scale.scale);
sort_and_dedup(&mut config.radius.scale);
summary.sort();
let summary = summary.into_iter().map(|(_, _, line)| line).collect();
Ok(InferredConfig {
config,
summary,
sources,
})
}
fn order_tag(kind: TokenSourceKind) -> u8 {
match kind {
TokenSourceKind::TailwindConfig => 0,
TokenSourceKind::CssCustomProperties => 1,
TokenSourceKind::Dtcg => 2,
}
}
fn relative_to(base: &Path, path: &Path) -> PathBuf {
path.strip_prefix(base)
.map_or_else(|_| path.to_path_buf(), Path::to_path_buf)
}
fn display_path(path: &Path) -> String {
path.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/")
}
fn sort_and_dedup<T: Ord>(values: &mut Vec<T>) {
values.sort();
values.dedup();
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn missing_source_dir_errors() {
let err = infer_config(Path::new("/nonexistent/plumb/codegen/test"))
.expect_err("infer_config should fail on missing path");
assert!(matches!(err, CodegenError::NotFound(_)));
}
#[test]
fn non_directory_errors() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("not-a-dir.txt");
std::fs::write(&file, "hello").unwrap();
let err = infer_config(&file).expect_err("infer_config should fail on file path");
assert!(matches!(err, CodegenError::NotADirectory(_)));
}
#[test]
fn empty_dir_returns_default_config() {
let dir = tempfile::tempdir().unwrap();
let inferred = infer_config(dir.path()).unwrap();
assert!(inferred.summary.is_empty());
assert!(inferred.sources.is_empty());
assert!(inferred.config.color.tokens.is_empty());
assert!(inferred.config.spacing.scale.is_empty());
}
#[test]
fn detects_tailwind_config() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("tailwind.config.ts"),
"export default { content: [] };\n",
)
.unwrap();
let inferred = infer_config(dir.path()).unwrap();
assert_eq!(inferred.sources.len(), 1);
assert_eq!(inferred.sources[0].kind, TokenSourceKind::TailwindConfig);
assert_eq!(
inferred.sources[0].relative_path,
Path::new("tailwind.config.ts")
);
}
#[test]
fn classifies_css_custom_properties_into_tokens() {
let dir = tempfile::tempdir().unwrap();
let styles = dir.path().join("styles");
std::fs::create_dir_all(&styles).unwrap();
std::fs::write(
styles.join("tokens.css"),
r":root {
--color-bg: #ffffff;
--color-fg: #0b0b0b;
--color-accent: #0b7285;
--space-xs: 4px;
--space-sm: 8px;
--radius-md: 8px;
}",
)
.unwrap();
let inferred = infer_config(dir.path()).unwrap();
assert_eq!(inferred.config.color.tokens.len(), 3);
assert_eq!(
inferred.config.color.tokens.get("color-bg"),
Some(&"#ffffff".to_owned())
);
assert_eq!(inferred.config.spacing.scale, vec![4, 8]);
assert_eq!(inferred.config.radius.scale, vec![8]);
}
#[test]
fn skips_node_modules_and_dotfile_dirs() {
let dir = tempfile::tempdir().unwrap();
for skipped in ["node_modules", "target", ".git", "dist", "build"] {
let nested = dir.path().join(skipped).join("nested");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(
nested.join("trap.css"),
":root { --color-trap: #ff0000; }\n",
)
.unwrap();
}
let inferred = infer_config(dir.path()).unwrap();
assert!(inferred.config.color.tokens.is_empty());
assert!(inferred.sources.is_empty());
}
#[test]
fn deterministic_across_runs() {
let dir = tempfile::tempdir().unwrap();
let styles = dir.path().join("src/styles");
std::fs::create_dir_all(&styles).unwrap();
std::fs::write(
styles.join("a.css"),
":root { --color-a: #aabbcc; --space-xs: 4px; }",
)
.unwrap();
std::fs::write(
styles.join("b.css"),
":root { --color-b: #112233; --space-sm: 8px; }",
)
.unwrap();
let one = infer_config(dir.path()).unwrap();
let two = infer_config(dir.path()).unwrap();
assert_eq!(one.summary, two.summary);
assert_eq!(one.config.color.tokens, two.config.color.tokens);
assert_eq!(one.config.spacing.scale, two.config.spacing.scale);
}
#[test]
fn merges_dtcg_token_files() {
let dir = tempfile::tempdir().unwrap();
let dtcg = r##"{
"color": {
"primary": { "$type": "color", "$value": "#0b7285" }
},
"spacing": {
"xs": { "$type": "dimension", "$value": "4px" }
}
}"##;
std::fs::write(dir.path().join("design.tokens.json"), dtcg).unwrap();
let inferred = infer_config(dir.path()).unwrap();
assert_eq!(
inferred.config.color.tokens.get("color/primary"),
Some(&"#0b7285".to_owned())
);
assert!(inferred.config.spacing.tokens.contains_key("spacing/xs"));
assert_eq!(inferred.sources.len(), 1);
assert_eq!(inferred.sources[0].kind, TokenSourceKind::Dtcg);
}
#[test]
fn order_tag_orders_kinds_predictably() {
assert!(
order_tag(TokenSourceKind::TailwindConfig)
< order_tag(TokenSourceKind::CssCustomProperties)
);
assert!(order_tag(TokenSourceKind::CssCustomProperties) < order_tag(TokenSourceKind::Dtcg));
}
#[test]
fn display_path_uses_forward_slashes() {
let p = Path::new("src").join("styles").join("tokens.css");
assert_eq!(display_path(&p), "src/styles/tokens.css");
}
#[test]
fn label_lookup_is_stable() {
assert_eq!(TokenSourceKind::TailwindConfig.label(), "tailwind");
assert_eq!(TokenSourceKind::CssCustomProperties.label(), "css");
assert_eq!(TokenSourceKind::Dtcg.label(), "dtcg");
}
}