mod cache;
mod npm;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct DiscoveredComponent {
pub tag_name: String,
pub html_content: String,
pub css_content: Option<String>,
pub has_script: bool,
pub source: String,
}
#[derive(Debug)]
pub struct DiscoveryResult {
pub source: String,
pub components: Vec<DiscoveredComponent>,
}
enum ComponentSource {
NpmPackage(String),
Path(PathBuf),
}
fn classify_source(source: &str) -> ComponentSource {
if is_local_source(source) {
ComponentSource::Path(PathBuf::from(source))
} else {
ComponentSource::NpmPackage(source.to_string())
}
}
#[must_use]
pub fn is_local_source(source: &str) -> bool {
source.starts_with('.')
|| source.starts_with('/')
|| source.starts_with('\\')
|| (cfg!(windows) && source.len() >= 2 && source.as_bytes()[1] == b':')
}
pub fn discover_source(source: &str, search_dir: &Path) -> Result<DiscoveryResult> {
let mut cache = cache::DiscoveryCache::open()?;
let components = match classify_source(source) {
ComponentSource::NpmPackage(ref name) => npm::resolve(name, search_dir, &mut cache)?,
ComponentSource::Path(ref path) => {
let resolved = if path.is_relative() {
search_dir.join(path)
} else {
path.clone()
};
let resolved = resolved
.canonicalize()
.with_context(|| format!("Component path not found: {}", path.display()))?;
discover_from_path(&resolved)?
}
};
Ok(DiscoveryResult {
source: source.to_string(),
components,
})
}
fn discover_from_path(dir: &Path) -> Result<Vec<DiscoveredComponent>> {
let source = dir.display().to_string();
let mut components = Vec::new();
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().is_some_and(|ext| ext == "html") {
if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
if filename.contains('-') {
let html_content = std::fs::read_to_string(path).with_context(|| {
format!("Failed to read component HTML: {}", path.display())
})?;
let css_path = path.with_extension("css");
let css_content = if css_path.exists() {
Some(std::fs::read_to_string(&css_path).with_context(|| {
format!("Failed to read component CSS: {}", css_path.display())
})?)
} else {
None
};
components.push(DiscoveredComponent {
tag_name: filename.to_string(),
html_content,
css_content,
has_script: has_sibling_script(path),
source: source.clone(),
});
}
}
}
}
Ok(components)
}
fn has_sibling_script(html_path: &Path) -> bool {
html_path.with_extension("ts").exists() || html_path.with_extension("js").exists()
}
pub fn collect_watch_paths(sources: &[String], search_dir: &Path) -> Vec<PathBuf> {
sources
.iter()
.filter_map(|source| {
if let ComponentSource::Path(ref path) = classify_source(source) {
let resolved = if path.is_relative() {
search_dir.join(path)
} else {
path.clone()
};
resolved.canonicalize().ok()
} else {
None
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_classify_relative_path() {
assert!(matches!(
classify_source("./libs/shared"),
ComponentSource::Path(_)
));
assert!(matches!(
classify_source("../components"),
ComponentSource::Path(_)
));
}
#[test]
fn test_classify_absolute_path() {
assert!(matches!(
classify_source("/absolute/path"),
ComponentSource::Path(_)
));
}
#[test]
fn test_classify_npm_package() {
assert!(matches!(
classify_source("my-widget"),
ComponentSource::NpmPackage(_)
));
}
#[test]
fn test_classify_scoped_npm_package() {
assert!(matches!(
classify_source("@reactive-ui"),
ComponentSource::NpmPackage(_)
));
assert!(matches!(
classify_source("@scope/button"),
ComponentSource::NpmPackage(_)
));
}
#[cfg(windows)]
#[test]
fn test_classify_windows_drive_path() {
assert!(matches!(
classify_source("C:\\components"),
ComponentSource::Path(_)
));
}
#[test]
fn test_is_local_source() {
assert!(is_local_source("./libs/shared"));
assert!(is_local_source("../components"));
assert!(is_local_source("/absolute/path"));
assert!(is_local_source("\\unc\\path"));
assert!(!is_local_source("my-widget"));
assert!(!is_local_source("@reactive-ui"));
assert!(!is_local_source("@scope/button"));
}
#[test]
fn test_discover_from_path_preserves_script_ownership() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("plain-card.html"), "<p>{{title}}</p>").unwrap();
std::fs::write(
tmp.path().join("interactive-card.html"),
"<button>go</button>",
)
.unwrap();
std::fs::write(tmp.path().join("interactive-card.ts"), "export {};").unwrap();
let components = discover_from_path(tmp.path()).unwrap();
let plain = components
.iter()
.find(|component| component.tag_name == "plain-card")
.unwrap();
let interactive = components
.iter()
.find(|component| component.tag_name == "interactive-card")
.unwrap();
assert!(!plain.has_script);
assert!(interactive.has_script);
}
}