Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
indexmap = { version = "2", features = ["serde"] }
ahash = "0.8"

# Subprocess discovery (Tailwind adapter).
which = "8"

# Content hashing (Tailwind cache key).
sha2 = "0.10"

# Path canonicalization that strips `\\?\` UNC prefix on Windows
# (Tailwind adapter — node's `require()` doesn't handle the prefix).
dunce = "1"

# Color distance (CIEDE2000, D65).
palette = { version = "0.7", default-features = false, features = ["std"] }

Expand Down
4 changes: 4 additions & 0 deletions crates/plumb-config/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ serde_yaml = { workspace = true }
miette = { workspace = true }
thiserror = { workspace = true }
schemars = { workspace = true }
tracing = { workspace = true }
which = { workspace = true }
sha2 = { workspace = true }
dunce = { workspace = true }

[dev-dependencies]
tempfile = { workspace = true }
Expand Down
35 changes: 35 additions & 0 deletions crates/plumb-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ use thiserror::Error;
mod css_props;
mod dtcg;
mod span;
pub mod tailwind;
mod validate;

pub use css_props::{CssPropertyScrape, ScrapedValue, scrape_css_properties};
pub use dtcg::{DtcgImport, DtcgSource, DtcgWarning, DtcgWarningKind, MAX_NESTING, merge_dtcg};

use span::{SourceFormat, locate_path};
pub use tailwind::{TailwindOptions, merge_tailwind};
use validate::ValidationIssue;

/// Underlying config parse errors.
Expand Down Expand Up @@ -145,6 +147,39 @@ pub enum ConfigError {
/// Human-readable explanation.
reason: String,
},
/// The Tailwind adapter could not run because Node is missing
/// or otherwise unavailable.
#[error("tailwind adapter unavailable: {reason}")]
#[diagnostic(
code(plumb::config::tailwind_unavailable),
help("install Node.js (https://nodejs.org) or pass --tailwind-node <path>")
)]
TailwindUnavailable {
/// Why the adapter couldn't run (missing Node, missing override, …).
reason: String,
},
/// The Tailwind config path is malformed or escapes the project tree.
#[error("invalid tailwind config path `{path}`: {reason}")]
#[diagnostic(code(plumb::config::tailwind_bad_path))]
TailwindBadPath {
/// User-supplied tailwind config path.
path: String,
/// Why we rejected it.
reason: String,
},
/// Node ran but the Tailwind config evaluation failed.
#[error("failed to evaluate tailwind config `{path}`: {reason}")]
#[diagnostic(code(plumb::config::tailwind_eval))]
TailwindEval {
/// User-supplied tailwind config path.
path: String,
/// Reason text — either the loader's structured error or the
/// shape of the subprocess failure.
reason: String,
/// Captured stderr from the Node subprocess. Empty when the
/// child closed without writing diagnostics.
stderr: String,
},
}

/// Load a `Config` from disk. The file extension decides the parser.
Expand Down
207 changes: 207 additions & 0 deletions crates/plumb-config/src/tailwind/cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
//! mtime-based cache for resolved Tailwind themes.
//!
//! ## Determinism
//!
//! The cache is a performance optimization, not a correctness contract.
//! Reads return the cached `theme` value byte-for-byte. Writes happen
//! only after a successful Node spawn produced a valid theme. The
//! decision tree is:
//!
//! 1. Stat the user's Tailwind config file → `mtime_unix_ms`.
//! 2. Hash the absolute config path with SHA-256 → cache filename.
//! 3. If `<cache_dir>/<hash>.json` exists and its `mtime_unix_ms`
//! matches, deserialize and return. Otherwise the caller spawns Node.
//!
//! Mismatched or unreadable cache entries are treated as a miss; we
//! never error out of a corrupted cache.
//!
//! ## No env access
//!
//! This module never reads `TMPDIR` / `TEMP` / `TMP` or any other
//! process-global state. The caller passes the cache directory in
//! explicitly. When no directory is supplied, the caller treats it as
//! "cache disabled" and the functions in this module are not invoked.

use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::SystemTimeError;

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

/// Wire format for the cache file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct CacheEntry {
/// Modification time of the source config file in milliseconds since
/// the Unix epoch. This is the cache key beyond the filename hash.
pub(super) mtime_unix_ms: u128,
/// The resolved theme JSON object. Stored as `serde_json::Value`
/// so the cache file is self-describing — no schema migrations
/// required when Plumb adds new theme keys.
pub(super) theme: serde_json::Value,
}

/// SHA-256 hex digest of the absolute config path. Stable across runs.
pub(super) fn config_path_hash(path: &Path) -> String {
let mut hasher = Sha256::new();
hasher.update(path.as_os_str().as_encoded_bytes());
let digest = hasher.finalize();
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
// hex-encode without an extra dependency.
const TABLE: &[u8; 16] = b"0123456789abcdef";
let upper = TABLE[(byte >> 4) as usize];
let lower = TABLE[(byte & 0x0f) as usize];
hex.push(char::from(upper));
hex.push(char::from(lower));
}
hex
}

/// Compute the absolute path of the cache entry for a given config.
pub(super) fn cache_path_for(config_path: &Path, dir: &Path) -> PathBuf {
let hash = config_path_hash(config_path);
dir.join(format!("{hash}.json"))
}

/// Read the file's mtime as milliseconds since the Unix epoch.
pub(super) fn mtime_unix_ms(path: &Path) -> Result<u128, MtimeError> {
let meta = fs::metadata(path).map_err(MtimeError::Io)?;
let modified = meta.modified().map_err(MtimeError::Io)?;
let dur = modified
.duration_since(std::time::UNIX_EPOCH)
.map_err(MtimeError::SystemTime)?;
Ok(dur.as_millis())
}

/// Failure modes when reading a file's mtime.
#[derive(Debug)]
pub(super) enum MtimeError {
/// Underlying I/O error (file missing, permission denied, etc.).
Io(io::Error),
/// File mtime predates the Unix epoch — shouldn't happen on any
/// modern filesystem but we surface it instead of panicking.
SystemTime(SystemTimeError),
}

impl std::fmt::Display for MtimeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(err) => write!(f, "{err}"),
Self::SystemTime(err) => write!(f, "{err}"),
}
}
}

/// Look up a cached theme for `config_path`. Returns `None` on any cache
/// miss (file missing, JSON malformed, mtime mismatch). Never errors;
/// cache problems are silent.
pub(super) fn read(config_path: &Path, dir: &Path) -> Option<CacheEntry> {
let mtime = mtime_unix_ms(config_path).ok()?;
let cache_path = cache_path_for(config_path, dir);
let bytes = fs::read(&cache_path).ok()?;
let entry: CacheEntry = serde_json::from_slice(&bytes).ok()?;
if entry.mtime_unix_ms == mtime {
Some(entry)
} else {
None
}
}

/// Persist a resolved theme to the cache. Best-effort — failures are
/// logged at `debug` level and otherwise swallowed; the caller already
/// has a valid theme, so a missing cache entry just costs a re-spawn
/// next time.
pub(super) fn write(
config_path: &Path,
theme: &serde_json::Value,
dir: &Path,
) -> Result<(), io::Error> {
let mtime = mtime_unix_ms(config_path).map_err(|err| match err {
MtimeError::Io(io) => io,
MtimeError::SystemTime(_) => io::Error::other("config mtime predates the Unix epoch"),
})?;
let entry = CacheEntry {
mtime_unix_ms: mtime,
theme: theme.clone(),
};
let cache_path = cache_path_for(config_path, dir);
if let Some(parent) = cache_path.parent() {
fs::create_dir_all(parent)?;
}
let serialized = serde_json::to_vec(&entry).map_err(io::Error::other)?;
// Write to a sibling temp file then rename, so a partial write is
// never observable. The OS rename is atomic on POSIX and Windows
// (`MoveFileEx` with `MOVEFILE_REPLACE_EXISTING` semantics under
// `fs::rename` on stable Rust).
let tmp_path = cache_path.with_extension("json.tmp");
fs::write(&tmp_path, &serialized)?;
fs::rename(&tmp_path, &cache_path)?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn config_path_hash_is_stable_and_64_chars() {
let path = Path::new("/tmp/example/tailwind.config.js");
let h1 = config_path_hash(path);
let h2 = config_path_hash(path);
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64);
assert!(
h1.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
}

#[test]
fn config_path_hash_distinguishes_paths() {
let a = config_path_hash(Path::new("/a/tailwind.config.js"));
let b = config_path_hash(Path::new("/b/tailwind.config.js"));
assert_ne!(a, b);
}

#[test]
fn cache_round_trip_through_temp_dir() {
let dir = tempfile::tempdir().expect("tempdir");
let cfg_path = dir.path().join("tailwind.config.js");
std::fs::write(&cfg_path, "module.exports = {};").expect("write config");

let theme = serde_json::json!({"colors": {"red": "#ff0000"}});
write(&cfg_path, &theme, dir.path()).expect("write cache");

let entry = read(&cfg_path, dir.path()).expect("hit cache");
assert_eq!(entry.theme, theme);
}

#[test]
fn cache_miss_when_mtime_changes() {
let dir = tempfile::tempdir().expect("tempdir");
let cfg_path = dir.path().join("tailwind.config.js");
std::fs::write(&cfg_path, "module.exports = {};").expect("write config");
let theme = serde_json::json!({"colors": {"red": "#ff0000"}});
write(&cfg_path, &theme, dir.path()).expect("write cache");

// Bump mtime by rewriting the config file with later content.
// sleep-free: set mtime explicitly via filetime if available;
// the simpler path is to rewrite and accept that filesystem
// resolution is millisecond-or-coarser — write a guaranteed
// distinct mtime by using `set_modified` from std.
let later = std::time::UNIX_EPOCH + std::time::Duration::from_secs(2_000_000_000);
let file = std::fs::OpenOptions::new()
.write(true)
.open(&cfg_path)
.expect("open");
file.set_modified(later).expect("set mtime");
drop(file);

assert!(
read(&cfg_path, dir.path()).is_none(),
"mtime change should invalidate cache"
);
}
}
Loading
Loading