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 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 source.starts_with('.')
|| source.starts_with('/')
|| source.starts_with('\\')
|| (cfg!(windows) && source.len() >= 2 && source.as_bytes()[1] == b':')
{
ComponentSource::Path(PathBuf::from(source))
} else {
ComponentSource::NpmPackage(source.to_string())
}
}
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,
source: source.clone(),
});
}
}
}
}
Ok(components)
}
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(_)
));
}
}