From b8cbde1c2a8f12b5157369342a936d41c93a8bd2 Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Sat, 25 Apr 2026 12:30:31 -0600 Subject: [PATCH] feat(cli): plumb init with Tailwind detection (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `plumb init` now inspects the current directory before writing `plumb.toml`. When a `tailwind.config.{ts,mts,cts,js,mjs,cjs}` file exists or `package.json` lists `tailwindcss` in any dependency table, the command emits a Tailwind-flavoured starter populated with Tailwind 3.x default spacing, type, and radius scales. The header records the discovered config path via a placeholder substitution so the future `extends` directive (tracked separately) can wire to it without further hand-tuning. Next.js without Tailwind keeps the generic template — we hint at it in the summary line but don't switch flavours. Detection is filesystem-only: no JS evaluation, no env var reads. `package.json` parse failures fall back to the generic template silently — `plumb init` is a scaffold, not a validator. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/plumb-cli/src/commands/init.rs | 234 +++++++++++++++++++++- crates/plumb-cli/tests/cli_integration.rs | 109 ++++++++++ examples/plumb-tailwind.toml | 63 ++++++ 3 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 examples/plumb-tailwind.toml diff --git a/crates/plumb-cli/src/commands/init.rs b/crates/plumb-cli/src/commands/init.rs index b282a53..606af74 100644 --- a/crates/plumb-cli/src/commands/init.rs +++ b/crates/plumb-cli/src/commands/init.rs @@ -1,25 +1,251 @@ //! `plumb init` — write a starter `plumb.toml`. +//! +//! Detects Tailwind (and, as a hint, Next.js) in the current directory +//! and branches between two scaffolds: a generic template, or a +//! Tailwind-flavoured template that records the discovered config in +//! its header comment. +//! +//! Detection is filesystem-only — no JS evaluation, no env var reads. +//! `tailwind.config.{ts,mts,cts,js,mjs,cjs}` in CWD or a `tailwindcss` +//! entry in `package.json`'s `dependencies` / `devDependencies` / +//! `peerDependencies` triggers the Tailwind template. Next.js without +//! Tailwind keeps the generic template — we hint at it in the summary +//! line but don't switch flavours. use std::path::Path; use std::process::ExitCode; use anyhow::{Context, Result, bail}; -const STARTER_CONTENT: &str = include_str!("../../../../examples/plumb.toml"); +const GENERIC_TEMPLATE: &str = include_str!("../../../../examples/plumb.toml"); +const TAILWIND_TEMPLATE: &str = include_str!("../../../../examples/plumb-tailwind.toml"); +const TAILWIND_PLACEHOLDER: &str = "{{TAILWIND_CONFIG}}"; +const TAILWIND_CONFIG_NAMES: &[&str] = &[ + "tailwind.config.ts", + "tailwind.config.mts", + "tailwind.config.cts", + "tailwind.config.js", + "tailwind.config.mjs", + "tailwind.config.cjs", +]; + +/// What `detect` discovered about the current directory. +#[derive(Debug, Clone, Default)] +struct Detection { + /// Bare filename of the discovered Tailwind config, if any. + tailwind_config: Option, + /// `next` listed in `package.json` deps. + has_next: bool, + /// `tailwindcss` listed in `package.json` deps. + has_tailwind_dep: bool, +} + +impl Detection { + fn is_tailwind_project(&self) -> bool { + self.tailwind_config.is_some() || self.has_tailwind_dep + } +} + +/// Run `plumb init`. Returns `ExitCode::SUCCESS` on a fresh write. +/// +/// # Errors +/// +/// Returns an error if the current directory cannot be read, if +/// `plumb.toml` already exists and `force` is `false`, or if the file +/// cannot be written. pub fn run(force: bool) -> Result { - let target = Path::new("plumb.toml"); + let cwd = std::env::current_dir().context("read current working directory")?; + let target = cwd.join("plumb.toml"); if target.exists() && !force { bail!( "{} already exists; pass --force to overwrite.", target.display() ); } - std::fs::write(target, STARTER_CONTENT) + let detection = detect(&cwd); + let (content, summary) = render(&detection); + std::fs::write(&target, content.as_bytes()) .with_context(|| format!("write {}", target.display()))?; #[allow(clippy::print_stdout)] { - println!("Wrote {}.", target.display()); + println!("Wrote {}. {summary}", target.display()); } Ok(ExitCode::SUCCESS) } + +/// Inspect `cwd` for Tailwind / Next.js signals. +fn detect(cwd: &Path) -> Detection { + let mut detection = Detection::default(); + for name in TAILWIND_CONFIG_NAMES { + if cwd.join(name).is_file() { + detection.tailwind_config = Some((*name).to_string()); + break; + } + } + let (has_next, has_tailwind_dep) = read_package_deps(&cwd.join("package.json")); + detection.has_next = has_next; + detection.has_tailwind_dep = has_tailwind_dep; + detection +} + +/// Parse `package.json` for `next` and `tailwindcss` entries across the +/// three dependency tables. Missing or malformed files yield +/// `(false, false)` — detection is best-effort. +fn read_package_deps(path: &Path) -> (bool, bool) { + let Ok(raw) = std::fs::read_to_string(path) else { + return (false, false); + }; + let Ok(parsed) = serde_json::from_str::(&raw) else { + return (false, false); + }; + let mut has_next = false; + let mut has_tailwind = false; + for table in ["dependencies", "devDependencies", "peerDependencies"] { + let Some(map) = parsed.get(table).and_then(|v| v.as_object()) else { + continue; + }; + if map.contains_key("next") { + has_next = true; + } + if map.contains_key("tailwindcss") { + has_tailwind = true; + } + } + (has_next, has_tailwind) +} + +/// Build the file contents and the one-line stdout summary for a given +/// detection result. +fn render(detection: &Detection) -> (String, String) { + if detection.is_tailwind_project() { + let config_label = detection + .tailwind_config + .clone() + .unwrap_or_else(|| "tailwind.config.js".to_string()); + let content = TAILWIND_TEMPLATE.replace(TAILWIND_PLACEHOLDER, &config_label); + let summary = if detection.tailwind_config.is_some() { + if detection.has_next { + format!("Tailwind config detected at ./{config_label} (Next.js project).") + } else { + format!("Tailwind config detected at ./{config_label}.") + } + } else if detection.has_next { + "Tailwind config detected via package.json (Next.js project).".to_string() + } else { + "Tailwind config detected via package.json.".to_string() + }; + (content, summary) + } else { + let summary = if detection.has_next { + "Generic template (Next.js detected, no framework styles found).".to_string() + } else { + "Generic template.".to_string() + }; + (GENERIC_TEMPLATE.to_string(), summary) + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn render_generic_when_nothing_detected() { + let detection = Detection::default(); + let (content, summary) = render(&detection); + assert_eq!(content, GENERIC_TEMPLATE); + assert!(summary.starts_with("Generic template")); + assert!(!summary.contains("Tailwind")); + } + + #[test] + fn render_tailwind_substitutes_config_path() { + let detection = Detection { + tailwind_config: Some("tailwind.config.ts".to_string()), + has_next: true, + has_tailwind_dep: true, + }; + let (content, summary) = render(&detection); + assert!(content.contains("./tailwind.config.ts")); + assert!(!content.contains(TAILWIND_PLACEHOLDER)); + assert!(summary.contains("Tailwind config detected")); + assert!(summary.contains("./tailwind.config.ts")); + assert!(summary.contains("Next.js")); + } + + #[test] + fn render_tailwind_dep_alone_triggers_template() { + let detection = Detection { + tailwind_config: None, + has_next: false, + has_tailwind_dep: true, + }; + let (content, summary) = render(&detection); + assert!(content.contains("Tailwind")); + assert!(!content.contains(TAILWIND_PLACEHOLDER)); + assert!(summary.contains("Tailwind config detected")); + } + + #[test] + fn render_next_alone_keeps_generic_template() { + let detection = Detection { + tailwind_config: None, + has_next: true, + has_tailwind_dep: false, + }; + let (content, summary) = render(&detection); + assert_eq!(content, GENERIC_TEMPLATE); + assert!(!summary.contains("Tailwind")); + assert!(summary.contains("Next.js")); + } + + #[test] + fn read_package_deps_handles_missing_file() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let (has_next, has_tw) = read_package_deps(&dir.path().join("missing.json")); + assert!(!has_next); + assert!(!has_tw); + } + + #[test] + fn read_package_deps_walks_all_three_tables() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("package.json"); + std::fs::write( + &path, + r#"{ + "dependencies": { "react": "18" }, + "devDependencies": { "tailwindcss": "3.4" }, + "peerDependencies": { "next": "14" } + }"#, + ) + .expect("write"); + let (has_next, has_tw) = read_package_deps(&path); + assert!(has_next); + assert!(has_tw); + } + + #[test] + fn read_package_deps_tolerates_malformed_json() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("package.json"); + std::fs::write(&path, "{ not json").expect("write"); + let (has_next, has_tw) = read_package_deps(&path); + assert!(!has_next); + assert!(!has_tw); + } + + #[test] + fn detect_finds_typescript_config_first() { + let dir = tempfile::TempDir::new().expect("tempdir"); + std::fs::write(dir.path().join("tailwind.config.ts"), "").expect("write ts"); + std::fs::write(dir.path().join("tailwind.config.js"), "").expect("write js"); + let detection = detect(dir.path()); + assert_eq!( + detection.tailwind_config.as_deref(), + Some("tailwind.config.ts") + ); + } +} diff --git a/crates/plumb-cli/tests/cli_integration.rs b/crates/plumb-cli/tests/cli_integration.rs index 426ff87..f461ae4 100644 --- a/crates/plumb-cli/tests/cli_integration.rs +++ b/crates/plumb-cli/tests/cli_integration.rs @@ -182,3 +182,112 @@ fn lint_repeats_viewport_flag() -> Result<(), Box> { .stdout(contains("tablet").not()); Ok(()) } + +#[test] +fn init_writes_generic_config_in_clean_dir() -> Result<(), Box> { + let dir = TempDir::new()?; + Command::cargo_bin("plumb")? + .arg("init") + .current_dir(dir.path()) + .assert() + .success() + .stdout(contains("Wrote")) + .stdout(contains("Tailwind").not()); + let written = fs::read_to_string(dir.path().join("plumb.toml"))?; + assert!(written.contains("[viewports.desktop]")); + assert!(!written.contains("Tailwind detected")); + assert!(!written.contains("{{TAILWIND_CONFIG}}")); + Ok(()) +} + +#[test] +fn init_refuses_to_overwrite_without_force() -> Result<(), Box> { + let dir = TempDir::new()?; + fs::write(dir.path().join("plumb.toml"), "# existing\n")?; + Command::cargo_bin("plumb")? + .arg("init") + .current_dir(dir.path()) + .assert() + .code(2) + .stderr(contains("already exists")); + let preserved = fs::read_to_string(dir.path().join("plumb.toml"))?; + assert_eq!(preserved, "# existing\n"); + Ok(()) +} + +#[test] +fn init_overwrites_with_force() -> Result<(), Box> { + let dir = TempDir::new()?; + fs::write(dir.path().join("plumb.toml"), "# sentinel-do-not-keep\n")?; + Command::cargo_bin("plumb")? + .args(["init", "--force"]) + .current_dir(dir.path()) + .assert() + .success(); + let written = fs::read_to_string(dir.path().join("plumb.toml"))?; + assert!(!written.contains("sentinel-do-not-keep")); + assert!(written.contains("[viewports.desktop]")); + Ok(()) +} + +#[test] +fn init_detects_tailwind_and_emits_tailwind_template() -> Result<(), Box> { + let dir = TempDir::new()?; + fs::write( + dir.path().join("tailwind.config.ts"), + "export default { content: [] };\n", + )?; + fs::write( + dir.path().join("package.json"), + r#"{ + "name": "tailwind-fixture", + "private": true, + "devDependencies": { + "next": "14.2.0", + "tailwindcss": "3.4.0" + } + } + "#, + )?; + Command::cargo_bin("plumb")? + .arg("init") + .current_dir(dir.path()) + .assert() + .success() + .stdout(contains("Tailwind config detected")); + let written = fs::read_to_string(dir.path().join("plumb.toml"))?; + assert!(written.contains("./tailwind.config.ts")); + assert!(written.contains("Tailwind config detected")); + assert!(!written.contains("{{TAILWIND_CONFIG}}")); + Ok(()) +} + +#[test] +fn init_then_lint_runs_against_fake_driver() -> Result<(), Box> { + let dir = TempDir::new()?; + fs::write( + dir.path().join("tailwind.config.js"), + "module.exports = { content: [] };\n", + )?; + fs::write( + dir.path().join("package.json"), + r#"{ + "name": "tailwind-lint-fixture", + "private": true, + "dependencies": { "tailwindcss": "3.4.0" } + } + "#, + )?; + Command::cargo_bin("plumb")? + .arg("init") + .current_dir(dir.path()) + .assert() + .success(); + Command::cargo_bin("plumb")? + .args(["lint", "plumb-fake://hello"]) + .current_dir(dir.path()) + .assert() + .code(3) + .stdout(contains("spacing/")); + Ok(()) +} diff --git a/examples/plumb-tailwind.toml b/examples/plumb-tailwind.toml new file mode 100644 index 0000000..8ba27cb --- /dev/null +++ b/examples/plumb-tailwind.toml @@ -0,0 +1,63 @@ +# Plumb configuration — bootstrapped from a Tailwind project. +# +# Tailwind config detected at `./{{TAILWIND_CONFIG}}`. Once Plumb's +# `extends` directive lands you'll be able to inherit Tailwind's spacing +# scale, font scale, color tokens, etc. directly. The placeholder below +# records the discovered file: +# +# extends = "./{{TAILWIND_CONFIG}}" +# +# Until then, edit the tokens below to match your Tailwind theme. + +[viewports.mobile] +width = 375 +height = 667 +device_pixel_ratio = 2.0 + +[viewports.tablet] +width = 768 +height = 1024 +device_pixel_ratio = 2.0 + +[viewports.desktop] +width = 1280 +height = 800 +device_pixel_ratio = 1.0 + +# Tailwind's default spacing scale, in pixels (1 unit = 4 px). +[spacing] +base_unit = 4 +scale = [0, 4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 80, 96] +tokens = { "0" = 0, "1" = 4, "2" = 8, "3" = 12, "4" = 16, "5" = 20, "6" = 24, "8" = 32, "10" = 40, "12" = 48, "16" = 64, "20" = 80, "24" = 96 } + +# Tailwind's default type scale, in pixels. +[type] +families = ["Inter", "system-ui"] +weights = [400, 500, 600, 700] +scale = [12, 14, 16, 18, 20, 24, 30, 36, 48, 60] +tokens = { xs = 12, sm = 14, base = 16, lg = 18, xl = 20, "2xl" = 24, "3xl" = 30, "4xl" = 36, "5xl" = 48, "6xl" = 60 } + +[color] +tokens = { "bg/canvas" = "#ffffff", "fg/primary" = "#0b0b0b", "accent/brand" = "#0b7285" } +delta_e_tolerance = 2.0 + +# Tailwind's default radius scale (`rounded-{none,sm,md,lg,xl,2xl,3xl,full}`). +[radius] +scale = [0, 2, 6, 8, 12, 16, 24, 9999] + +[alignment] +grid_columns = 12 +gutter_px = 24 +tolerance_px = 3 + +[a11y] +min_contrast_ratio = 4.5 + +[a11y.touch_target] +min_width_px = 24 +min_height_px = 24 + +# Example per-rule overrides. Every rule is enabled by default. +# [rules."spacing/grid-conformance"] +# enabled = false +# severity = "error"