1mod cache;
14mod catalog;
15mod npm;
16mod plugin;
17mod prepared;
18
19use anyhow::{Context, Result};
20use std::path::{Path, PathBuf};
21
22pub use npm::PackageContext;
23pub use plugin::{DiscoveryPlugin, FastDiscoveryPlugin, WebUIDiscoveryPlugin};
24pub use prepared::{ComponentFile, ComponentFileSource, PreparedPackage};
25
26#[derive(Debug, Clone)]
28pub struct DiscoveredComponent {
29 pub tag_name: String,
31 pub html_content: String,
33 pub css_content: Option<String>,
35 pub is_client_owned: bool,
37 pub source: String,
39}
40
41#[derive(Debug)]
43pub struct DiscoveryResult {
44 pub source: String,
46 pub components: Vec<DiscoveredComponent>,
48}
49
50enum ComponentSource {
52 NpmPackage(String),
54 Path(PathBuf),
56}
57
58fn classify_source(source: &str) -> ComponentSource {
63 if is_local_source(source) {
64 ComponentSource::Path(PathBuf::from(source))
65 } else {
66 ComponentSource::NpmPackage(source.to_string())
67 }
68}
69
70#[must_use]
83pub fn is_local_source(source: &str) -> bool {
84 source.starts_with('.')
85 || source.starts_with('/')
86 || source.starts_with('\\')
87 || (cfg!(windows) && source.len() >= 2 && source.as_bytes()[1] == b':')
88}
89
90pub fn discover_source(source: &str, search_dir: &Path) -> Result<DiscoveryResult> {
94 discover_source_with_plugin(source, search_dir, &WebUIDiscoveryPlugin::new())
95}
96
97pub fn discover_source_with_plugin(
104 source: &str,
105 search_dir: &Path,
106 plugin: &dyn DiscoveryPlugin,
107) -> Result<DiscoveryResult> {
108 let mut cache = cache::DiscoveryCache::open()?;
109
110 let components = match classify_source(source) {
111 ComponentSource::NpmPackage(ref name) => {
112 npm::resolve(name, search_dir, plugin, &mut cache)?
113 }
114 ComponentSource::Path(ref path) => {
115 let resolved = if path.is_relative() {
116 search_dir.join(path)
117 } else {
118 path.clone()
119 };
120 let resolved = resolved
121 .canonicalize()
122 .with_context(|| format!("Component path not found: {}", path.display()))?;
123 plugin.discover_local(&resolved)?
124 }
125 };
126
127 Ok(DiscoveryResult {
128 source: source.to_string(),
129 components,
130 })
131}
132
133pub(crate) fn has_sibling_script(
139 candidate: &mut PathBuf,
140 package_root: Option<&Path>,
141) -> Result<bool> {
142 for ext in ["ts", "js"] {
143 candidate.set_extension(ext);
144 let exists = if let Some(root) = package_root {
145 npm::package_asset_metadata(root, candidate)?.is_some()
146 } else {
147 candidate.try_exists().with_context(|| {
148 format!(
149 "Failed to inspect component script: {}",
150 candidate.display()
151 )
152 })?
153 };
154 if exists {
155 return Ok(true);
156 }
157 }
158 Ok(false)
159}
160
161pub fn collect_watch_paths(sources: &[String], search_dir: &Path) -> Vec<PathBuf> {
163 sources
164 .iter()
165 .filter_map(|source| {
166 if let ComponentSource::Path(ref path) = classify_source(source) {
167 let resolved = if path.is_relative() {
168 search_dir.join(path)
169 } else {
170 path.clone()
171 };
172 resolved.canonicalize().ok()
173 } else {
174 None
175 }
176 })
177 .collect()
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn test_classify_relative_path() {
186 assert!(matches!(
187 classify_source("./libs/shared"),
188 ComponentSource::Path(_)
189 ));
190 assert!(matches!(
191 classify_source("../components"),
192 ComponentSource::Path(_)
193 ));
194 }
195
196 #[test]
197 fn test_classify_absolute_path() {
198 assert!(matches!(
199 classify_source("/absolute/path"),
200 ComponentSource::Path(_)
201 ));
202 }
203
204 #[test]
205 fn test_classify_npm_package() {
206 assert!(matches!(
207 classify_source("my-widget"),
208 ComponentSource::NpmPackage(_)
209 ));
210 }
211
212 #[test]
213 fn test_classify_scoped_npm_package() {
214 assert!(matches!(
215 classify_source("@reactive-ui"),
216 ComponentSource::NpmPackage(_)
217 ));
218 assert!(matches!(
219 classify_source("@scope/button"),
220 ComponentSource::NpmPackage(_)
221 ));
222 }
223
224 #[cfg(windows)]
225 #[test]
226 fn test_classify_windows_drive_path() {
227 assert!(matches!(
228 classify_source("C:\\components"),
229 ComponentSource::Path(_)
230 ));
231 }
232
233 #[test]
234 fn test_is_local_source() {
235 assert!(is_local_source("./libs/shared"));
237 assert!(is_local_source("../components"));
238 assert!(is_local_source("/absolute/path"));
239 assert!(is_local_source("\\unc\\path"));
240 assert!(!is_local_source("my-widget"));
242 assert!(!is_local_source("@reactive-ui"));
243 assert!(!is_local_source("@scope/button"));
244 }
245
246 #[test]
247 fn test_discover_from_path_preserves_script_ownership() {
248 let tmp = tempfile::TempDir::new().unwrap();
249 std::fs::write(tmp.path().join("plain-card.html"), "<p>{{title}}</p>").unwrap();
250 std::fs::write(
251 tmp.path().join("interactive-card.html"),
252 "<button>go</button>",
253 )
254 .unwrap();
255 std::fs::write(tmp.path().join("interactive-card.ts"), "export {};").unwrap();
256
257 let components = WebUIDiscoveryPlugin::new()
258 .discover_local(tmp.path())
259 .unwrap();
260 let plain = components
261 .iter()
262 .find(|component| component.tag_name == "plain-card")
263 .unwrap();
264 let interactive = components
265 .iter()
266 .find(|component| component.tag_name == "interactive-card")
267 .unwrap();
268
269 assert!(!plain.is_client_owned);
270 assert!(interactive.is_client_owned);
271 }
272}