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 is_client_owned: bool,
31 pub source: String,
33}
34
35#[derive(Debug)]
37pub struct DiscoveryResult {
38 pub source: String,
40 pub components: Vec<DiscoveredComponent>,
42}
43
44enum ComponentSource {
46 NpmPackage(String),
48 Path(PathBuf),
50}
51
52fn classify_source(source: &str) -> ComponentSource {
57 if is_local_source(source) {
58 ComponentSource::Path(PathBuf::from(source))
59 } else {
60 ComponentSource::NpmPackage(source.to_string())
61 }
62}
63
64#[must_use]
77pub fn is_local_source(source: &str) -> bool {
78 source.starts_with('.')
79 || source.starts_with('/')
80 || source.starts_with('\\')
81 || (cfg!(windows) && source.len() >= 2 && source.as_bytes()[1] == b':')
82}
83
84pub fn discover_source(source: &str, search_dir: &Path) -> Result<DiscoveryResult> {
88 let mut cache = cache::DiscoveryCache::open()?;
89
90 let components = match classify_source(source) {
91 ComponentSource::NpmPackage(ref name) => npm::resolve(name, search_dir, &mut cache)?,
92 ComponentSource::Path(ref path) => {
93 let resolved = if path.is_relative() {
94 search_dir.join(path)
95 } else {
96 path.clone()
97 };
98 let resolved = resolved
99 .canonicalize()
100 .with_context(|| format!("Component path not found: {}", path.display()))?;
101 discover_from_path(&resolved)?
102 }
103 };
104
105 Ok(DiscoveryResult {
106 source: source.to_string(),
107 components,
108 })
109}
110
111fn discover_from_path(dir: &Path) -> Result<Vec<DiscoveredComponent>> {
116 let source = dir.display().to_string();
117 let mut components = Vec::new();
118
119 for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
120 let path = entry.path();
121 if !path.is_file() {
122 continue;
123 }
124 if path.extension().is_some_and(|ext| ext == "html") {
125 if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
126 if filename.contains('-') {
127 let html_content = std::fs::read_to_string(path).with_context(|| {
128 format!("Failed to read component HTML: {}", path.display())
129 })?;
130 let css_path = path.with_extension("css");
131 let css_content = if css_path.exists() {
132 Some(std::fs::read_to_string(&css_path).with_context(|| {
133 format!("Failed to read component CSS: {}", css_path.display())
134 })?)
135 } else {
136 None
137 };
138 let is_client_owned = has_sibling_script(path)?;
139 components.push(DiscoveredComponent {
140 tag_name: filename.to_string(),
141 html_content,
142 css_content,
143 is_client_owned,
144 source: source.clone(),
145 });
146 }
147 }
148 }
149 }
150
151 Ok(components)
152}
153
154fn has_sibling_script(html_path: &Path) -> Result<bool> {
160 for ext in ["ts", "js"] {
161 let candidate = html_path.with_extension(ext);
162 if candidate.try_exists().with_context(|| {
163 format!(
164 "Failed to inspect component script: {}",
165 candidate.display()
166 )
167 })? {
168 return Ok(true);
169 }
170 }
171 Ok(false)
172}
173
174pub fn collect_watch_paths(sources: &[String], search_dir: &Path) -> Vec<PathBuf> {
176 sources
177 .iter()
178 .filter_map(|source| {
179 if let ComponentSource::Path(ref path) = classify_source(source) {
180 let resolved = if path.is_relative() {
181 search_dir.join(path)
182 } else {
183 path.clone()
184 };
185 resolved.canonicalize().ok()
186 } else {
187 None
188 }
189 })
190 .collect()
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn test_classify_relative_path() {
199 assert!(matches!(
200 classify_source("./libs/shared"),
201 ComponentSource::Path(_)
202 ));
203 assert!(matches!(
204 classify_source("../components"),
205 ComponentSource::Path(_)
206 ));
207 }
208
209 #[test]
210 fn test_classify_absolute_path() {
211 assert!(matches!(
212 classify_source("/absolute/path"),
213 ComponentSource::Path(_)
214 ));
215 }
216
217 #[test]
218 fn test_classify_npm_package() {
219 assert!(matches!(
220 classify_source("my-widget"),
221 ComponentSource::NpmPackage(_)
222 ));
223 }
224
225 #[test]
226 fn test_classify_scoped_npm_package() {
227 assert!(matches!(
228 classify_source("@reactive-ui"),
229 ComponentSource::NpmPackage(_)
230 ));
231 assert!(matches!(
232 classify_source("@scope/button"),
233 ComponentSource::NpmPackage(_)
234 ));
235 }
236
237 #[cfg(windows)]
238 #[test]
239 fn test_classify_windows_drive_path() {
240 assert!(matches!(
241 classify_source("C:\\components"),
242 ComponentSource::Path(_)
243 ));
244 }
245
246 #[test]
247 fn test_is_local_source() {
248 assert!(is_local_source("./libs/shared"));
250 assert!(is_local_source("../components"));
251 assert!(is_local_source("/absolute/path"));
252 assert!(is_local_source("\\unc\\path"));
253 assert!(!is_local_source("my-widget"));
255 assert!(!is_local_source("@reactive-ui"));
256 assert!(!is_local_source("@scope/button"));
257 }
258
259 #[test]
260 fn test_discover_from_path_preserves_script_ownership() {
261 let tmp = tempfile::TempDir::new().unwrap();
262 std::fs::write(tmp.path().join("plain-card.html"), "<p>{{title}}</p>").unwrap();
263 std::fs::write(
264 tmp.path().join("interactive-card.html"),
265 "<button>go</button>",
266 )
267 .unwrap();
268 std::fs::write(tmp.path().join("interactive-card.ts"), "export {};").unwrap();
269
270 let components = discover_from_path(tmp.path()).unwrap();
271 let plain = components
272 .iter()
273 .find(|component| component.tag_name == "plain-card")
274 .unwrap();
275 let interactive = components
276 .iter()
277 .find(|component| component.tag_name == "interactive-card")
278 .unwrap();
279
280 assert!(!plain.is_client_owned);
281 assert!(interactive.is_client_owned);
282 }
283}