1mod cache;
14mod npm;
15
16use anyhow::{Context, Result};
17use std::path::{Path, PathBuf};
18use walkdir::WalkDir;
19
20#[derive(Debug, Clone)]
22pub struct DiscoveredComponent {
23 pub tag_name: String,
25 pub html_content: String,
27 pub css_content: Option<String>,
29 pub source: String,
31}
32
33#[derive(Debug)]
35pub struct DiscoveryResult {
36 pub source: String,
38 pub components: Vec<DiscoveredComponent>,
40}
41
42enum ComponentSource {
44 NpmPackage(String),
46 Path(PathBuf),
48}
49
50fn classify_source(source: &str) -> ComponentSource {
55 if source.starts_with('.')
56 || source.starts_with('/')
57 || source.starts_with('\\')
58 || (cfg!(windows) && source.len() >= 2 && source.as_bytes()[1] == b':')
59 {
60 ComponentSource::Path(PathBuf::from(source))
61 } else {
62 ComponentSource::NpmPackage(source.to_string())
63 }
64}
65
66pub fn discover_source(source: &str, search_dir: &Path) -> Result<DiscoveryResult> {
70 let mut cache = cache::DiscoveryCache::open()?;
71
72 let components = match classify_source(source) {
73 ComponentSource::NpmPackage(ref name) => npm::resolve(name, search_dir, &mut cache)?,
74 ComponentSource::Path(ref path) => {
75 let resolved = if path.is_relative() {
76 search_dir.join(path)
77 } else {
78 path.clone()
79 };
80 let resolved = resolved
81 .canonicalize()
82 .with_context(|| format!("Component path not found: {}", path.display()))?;
83 discover_from_path(&resolved)?
84 }
85 };
86
87 Ok(DiscoveryResult {
88 source: source.to_string(),
89 components,
90 })
91}
92
93fn discover_from_path(dir: &Path) -> Result<Vec<DiscoveredComponent>> {
98 let source = dir.display().to_string();
99 let mut components = Vec::new();
100
101 for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
102 let path = entry.path();
103 if !path.is_file() {
104 continue;
105 }
106 if path.extension().is_some_and(|ext| ext == "html") {
107 if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
108 if filename.contains('-') {
109 let html_content = std::fs::read_to_string(path).with_context(|| {
110 format!("Failed to read component HTML: {}", path.display())
111 })?;
112 let css_path = path.with_extension("css");
113 let css_content = if css_path.exists() {
114 Some(std::fs::read_to_string(&css_path).with_context(|| {
115 format!("Failed to read component CSS: {}", css_path.display())
116 })?)
117 } else {
118 None
119 };
120 components.push(DiscoveredComponent {
121 tag_name: filename.to_string(),
122 html_content,
123 css_content,
124 source: source.clone(),
125 });
126 }
127 }
128 }
129 }
130
131 Ok(components)
132}
133
134pub fn collect_watch_paths(sources: &[String], search_dir: &Path) -> Vec<PathBuf> {
136 sources
137 .iter()
138 .filter_map(|source| {
139 if let ComponentSource::Path(ref path) = classify_source(source) {
140 let resolved = if path.is_relative() {
141 search_dir.join(path)
142 } else {
143 path.clone()
144 };
145 resolved.canonicalize().ok()
146 } else {
147 None
148 }
149 })
150 .collect()
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn test_classify_relative_path() {
159 assert!(matches!(
160 classify_source("./libs/shared"),
161 ComponentSource::Path(_)
162 ));
163 assert!(matches!(
164 classify_source("../components"),
165 ComponentSource::Path(_)
166 ));
167 }
168
169 #[test]
170 fn test_classify_absolute_path() {
171 assert!(matches!(
172 classify_source("/absolute/path"),
173 ComponentSource::Path(_)
174 ));
175 }
176
177 #[test]
178 fn test_classify_npm_package() {
179 assert!(matches!(
180 classify_source("my-widget"),
181 ComponentSource::NpmPackage(_)
182 ));
183 }
184
185 #[test]
186 fn test_classify_scoped_npm_package() {
187 assert!(matches!(
188 classify_source("@reactive-ui"),
189 ComponentSource::NpmPackage(_)
190 ));
191 assert!(matches!(
192 classify_source("@scope/button"),
193 ComponentSource::NpmPackage(_)
194 ));
195 }
196
197 #[cfg(windows)]
198 #[test]
199 fn test_classify_windows_drive_path() {
200 assert!(matches!(
201 classify_source("C:\\components"),
202 ComponentSource::Path(_)
203 ));
204 }
205}