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