1use std::path::{Path, PathBuf};
9
10use crate::error::{Error, Result};
11use crate::manifest::{
12 DepDetail, Provides, absolute, find_workspace_root, parse_manifest_from_text, read_cargo_toml,
13 read_package_name_from_text, read_package_version, read_workspace_dependencies,
14};
15use crate::registry::ComponentRegistry;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum SourceKind {
20 Workspace,
22 Vendor,
24 CratesIo,
26}
27
28impl SourceKind {
29 pub fn as_str(self) -> &'static str {
31 match self {
32 SourceKind::Workspace => "workspace",
33 SourceKind::Vendor => "vendor",
34 SourceKind::CratesIo => "crates.io",
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
41pub struct ResolvedComponent {
42 pub name: String,
44 pub version: semver::Version,
46 pub core: semver::VersionReq,
48 pub provides: Provides,
50 pub requires: Vec<String>,
52 pub conflicts: Vec<String>,
54 pub source: SourceKind,
56 pub path: Option<PathBuf>,
58}
59
60impl ResolvedComponent {
61 fn from_registry(info: &crate::registry::ComponentInfo) -> Self {
62 Self {
63 name: info.name.clone(),
64 version: info.latest_version.clone(),
65 core: info.core_requirement.clone(),
66 provides: info.provides.clone(),
67 requires: Vec::new(),
68 conflicts: Vec::new(),
69 source: SourceKind::CratesIo,
70 path: None,
71 }
72 }
73}
74
75pub struct Catalog {
77 project_root: PathBuf,
78 workspace_root: Option<PathBuf>,
79 vendor_dir: Option<PathBuf>,
80 offline: bool,
81 registry: ComponentRegistry,
82}
83
84impl Catalog {
85 pub fn discover(project_root: &Path, offline: bool) -> Self {
87 let project_root = absolute(project_root);
88 let vendor = project_root.join("vendor");
89 Self {
90 workspace_root: find_workspace_root(&project_root),
91 vendor_dir: vendor.is_dir().then_some(vendor),
92 project_root,
93 offline,
94 registry: ComponentRegistry::builtin(),
95 }
96 }
97
98 pub fn registry(&self) -> &ComponentRegistry {
100 &self.registry
101 }
102
103 pub fn resolve(&self, name: &str) -> Result<ResolvedComponent> {
105 if let Some(component) = self.resolve_local(name)? {
106 return Ok(component);
107 }
108 self.resolve_remote(name)
109 }
110
111 pub fn resolve_local(&self, name: &str) -> Result<Option<ResolvedComponent>> {
113 if let Some(component) = self.resolve_workspace(name)? {
114 return Ok(Some(component));
115 }
116 self.resolve_vendor(name)
117 }
118
119 fn resolve_workspace(&self, name: &str) -> Result<Option<ResolvedComponent>> {
120 let Some(root) = &self.workspace_root else {
121 return Ok(None);
122 };
123 let deps = read_workspace_dependencies(root)?;
124 let dir = match deps.into_iter().find(|d| d.name == name) {
125 Some(dep) => match dep.path {
126 Some(p) => p,
127 None => root.join("crates").join(name),
128 },
129 None => {
132 let candidate = root.join("crates").join(name);
133 if candidate.join("Cargo.toml").is_file() {
134 candidate
135 } else {
136 return Ok(None);
137 }
138 }
139 };
140 let cargo = dir.join("Cargo.toml");
141 if !cargo.is_file() {
142 return Ok(None);
143 }
144 let text = read_cargo_toml(&cargo)?;
146 if read_package_name_from_text(&text)?.as_deref() != Some(name) {
147 return Ok(None);
148 }
149 if parse_manifest_from_text(&text)?.is_none() {
150 return Ok(None);
151 }
152 Ok(Some(self.component_from_dir(
153 name,
154 &dir,
155 SourceKind::Workspace,
156 )?))
157 }
158
159 fn resolve_vendor(&self, name: &str) -> Result<Option<ResolvedComponent>> {
160 let Some(vendor) = &self.vendor_dir else {
161 return Ok(None);
162 };
163 let dir = vendor.join(name);
164 if !dir.join("Cargo.toml").is_file() {
165 return Ok(None);
166 }
167 Ok(Some(self.component_from_dir(
168 name,
169 &dir,
170 SourceKind::Vendor,
171 )?))
172 }
173
174 fn component_from_dir(
175 &self,
176 name: &str,
177 dir: &Path,
178 source: SourceKind,
179 ) -> Result<ResolvedComponent> {
180 let text = read_cargo_toml(&dir.join("Cargo.toml"))?;
181 let manifest = parse_manifest_from_text(&text)?
182 .ok_or_else(|| Error::Manifest(format!("в {name} нет [package.metadata.light]")))?;
183 let version = read_package_version(dir)?
184 .or_else(|| {
185 self.registry
186 .by_name(name)
187 .map(|i| i.latest_version.clone())
188 })
189 .ok_or_else(|| Error::Manifest(format!("не удалось определить версию {name}")))?;
190 Ok(ResolvedComponent {
191 name: name.to_string(),
192 version,
193 core: manifest.core.parse()?,
194 provides: manifest.provides,
195 requires: manifest.requires.components,
196 conflicts: manifest.conflicts,
197 source,
198 path: Some(dir.to_path_buf()),
199 })
200 }
201
202 fn resolve_remote(&self, name: &str) -> Result<ResolvedComponent> {
203 let info = self.registry.by_name(name);
204 if self.offline {
205 return Err(Error::Offline(name.to_string()));
206 }
207 #[cfg(feature = "network")]
208 {
209 self.resolve_remote_network(name, info)
210 }
211 #[cfg(not(feature = "network"))]
212 {
213 match info {
214 Some(i) => Ok(ResolvedComponent::from_registry(i)),
215 None => Err(Error::ComponentNotFound(name.to_string())),
216 }
217 }
218 }
219
220 #[cfg(feature = "network")]
221 fn resolve_remote_network(
222 &self,
223 name: &str,
224 info: Option<&crate::registry::ComponentInfo>,
225 ) -> Result<ResolvedComponent> {
226 match fetch_crates_io(name)? {
227 Some(version) => {
228 let (core, provides) = match info {
229 Some(i) => (i.core_requirement.clone(), i.provides.clone()),
230 None => (semver::VersionReq::STAR, Provides::default()),
231 };
232 Ok(ResolvedComponent {
233 name: name.to_string(),
234 version,
235 core,
236 provides,
237 requires: Vec::new(),
238 conflicts: Vec::new(),
239 source: SourceKind::CratesIo,
240 path: None,
241 })
242 }
243 None => match info {
244 Some(i) => Ok(ResolvedComponent::from_registry(i)),
245 None => Err(Error::ComponentNotFound(name.to_string())),
246 },
247 }
248 }
249
250 pub fn dependency_entry(&self, component: &ResolvedComponent) -> Result<DependencyEntry> {
252 if matches!(component.source, SourceKind::Workspace)
254 && let Some(root) = &self.workspace_root
255 {
256 let deps = read_workspace_dependencies(root)?;
257 if deps.iter().any(|d| d.name == component.name) {
258 return Ok(DependencyEntry::Workspace);
259 }
260 }
261 if let Some(path) = &component.path {
262 return Ok(DependencyEntry::Path(relative_to(&self.project_root, path)));
263 }
264 Ok(DependencyEntry::Version(component.version.to_string()))
265 }
266
267 pub fn workspace_detail(&self, name: &str) -> Result<Option<DepDetail>> {
269 let Some(root) = &self.workspace_root else {
270 return Ok(None);
271 };
272 Ok(read_workspace_dependencies(root)?
273 .into_iter()
274 .find(|d| d.name == name))
275 }
276
277 pub fn project_root(&self) -> &Path {
279 &self.project_root
280 }
281
282 pub fn workspace_root(&self) -> Option<&Path> {
284 self.workspace_root.as_deref()
285 }
286
287 pub fn is_offline(&self) -> bool {
289 self.offline
290 }
291}
292
293fn relative_to(base: &Path, target: &Path) -> PathBuf {
298 let base: Vec<_> = base.components().collect();
299 let target: Vec<_> = target.components().collect();
300 let mut common = 0;
301 while common < base.len() && common < target.len() && base[common] == target[common] {
302 common += 1;
303 }
304 let mut result = PathBuf::new();
305 for _ in common..base.len() {
306 result.push("..");
307 }
308 for component in &target[common..] {
309 result.push(component.as_os_str());
310 }
311 result
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
316pub enum DependencyEntry {
317 Workspace,
319 Path(PathBuf),
321 Version(String),
323}
324
325#[cfg(feature = "network")]
327pub fn ping_crates_io() -> bool {
328 use std::time::Duration;
329 let client = match reqwest::blocking::Client::builder()
330 .timeout(Duration::from_secs(5))
331 .build()
332 {
333 Ok(c) => c,
334 Err(_) => return false,
335 };
336 client
337 .get("https://crates.io/api/v1/crates/light")
338 .header("User-Agent", "light-fw-cli (diagnostics)")
339 .send()
340 .is_ok()
341}
342
343#[cfg(feature = "network")]
347pub fn fetch_crates_io(name: &str) -> Result<Option<semver::Version>> {
348 use std::time::Duration;
349
350 #[derive(serde::Deserialize)]
351 struct CrateResponse {
352 #[serde(rename = "crate")]
353 krate: CrateInfo,
354 }
355 #[derive(serde::Deserialize)]
356 struct CrateInfo {
357 max_stable_version: Option<String>,
358 max_version: String,
359 }
360
361 let url = format!("https://crates.io/api/v1/crates/{name}");
362 let client = reqwest::blocking::Client::builder()
363 .timeout(Duration::from_secs(15))
364 .build()?;
365 let response = client
366 .get(&url)
367 .header("User-Agent", "light-fw-cli (component manager)")
368 .send()?;
369 if response.status() == reqwest::StatusCode::NOT_FOUND {
370 return Ok(None);
371 }
372 let response = response.error_for_status()?;
373 let parsed: CrateResponse = response.json()?;
374 let raw = parsed
375 .krate
376 .max_stable_version
377 .unwrap_or(parsed.krate.max_version);
378 let version = semver::Version::parse(&raw)?;
379 Ok(Some(version))
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use std::fs;
386
387 fn temp_dir(name: &str) -> PathBuf {
388 let dir = std::env::temp_dir().join(format!("light-fw-cli-fetch-{name}"));
389 let _ = fs::remove_dir_all(&dir);
390 fs::create_dir_all(&dir).expect("create temp dir");
391 dir
392 }
393
394 fn write_component(dir: &Path, name: &str) {
395 fs::create_dir_all(dir.join("src")).expect("src");
396 fs::write(dir.join("src/lib.rs"), "").expect("lib");
397 fs::write(
398 dir.join("Cargo.toml"),
399 format!(
400 "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
401 [package.metadata.light]\nkind = \"element\"\ndisplay-name = \"Test\"\ncore = \"^0.1\"\n\n\
402 [package.metadata.light.provides]\nelements = [\"checkbox\"]\n"
403 ),
404 )
405 .expect("write component");
406 }
407
408 #[test]
409 fn resolves_workspace_component() {
410 let root = temp_dir("workspace");
411 fs::write(
412 root.join("Cargo.toml"),
413 "[workspace]\nmembers = [\"app\", \"comp\"]\n\n\
414 [workspace.dependencies]\nlight-fw-element-form = { path = \"comp\", version = \"0.1.0\" }\n",
415 )
416 .expect("root manifest");
417 let app = root.join("app");
418 fs::create_dir_all(&app).expect("app");
419 fs::write(
420 app.join("Cargo.toml"),
421 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
422 )
423 .expect("app manifest");
424 write_component(&root.join("comp"), "light-fw-element-form");
425
426 let catalog = Catalog::discover(&app, false);
427 let resolved = catalog.resolve("light-fw-element-form").expect("resolve");
428 assert_eq!(resolved.source, SourceKind::Workspace);
429 assert_eq!(resolved.version, semver::Version::new(0, 1, 0));
430 assert_eq!(resolved.provides.elements, vec!["checkbox"]);
431 assert_eq!(
432 catalog.dependency_entry(&resolved).expect("entry"),
433 DependencyEntry::Workspace
434 );
435 }
436
437 #[test]
438 fn resolves_registry_fallback_without_network() {
439 let dir = temp_dir("registry-fallback");
440 fs::write(
441 dir.join("Cargo.toml"),
442 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
443 )
444 .expect("manifest");
445 let catalog = Catalog::discover(&dir, false);
446 let resolved = catalog
447 .resolve("light-fw-element-form")
448 .expect("registry fallback");
449 assert_eq!(resolved.source, SourceKind::CratesIo);
450 assert_eq!(resolved.version, semver::Version::new(0, 4, 0));
451 }
452
453 #[test]
454 fn offline_without_local_is_error() {
455 let dir = temp_dir("offline");
456 fs::write(
457 dir.join("Cargo.toml"),
458 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
459 )
460 .expect("manifest");
461 let catalog = Catalog::discover(&dir, true);
462 let err = catalog
463 .resolve("light-fw-element-form")
464 .expect_err("offline error");
465 assert!(matches!(err, Error::Offline(_)));
466 }
467
468 #[test]
469 fn unknown_component_is_not_found() {
470 let dir = temp_dir("unknown");
471 fs::write(
472 dir.join("Cargo.toml"),
473 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
474 )
475 .expect("manifest");
476 let catalog = Catalog::discover(&dir, false);
477 let err = catalog
478 .resolve("light-widget-datepicker")
479 .expect_err("not found");
480 assert!(matches!(err, Error::ComponentNotFound(_)));
481 }
482}