1use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11
12use crate::error::Result;
13use crate::manifest::{
14 has_package_section, read_cargo_toml, resolve_core_version, resolve_dependency_version,
15};
16use crate::process::{CommandRunner, SystemRunner, parse_program_version};
17use crate::project::analyze_project;
18use crate::registry::ComponentRegistry;
19
20pub const MIN_RUST_VERSION: (u64, u64, u64) = (1, 75, 0);
22
23pub trait EnvironmentProbe: Send + Sync {
25 fn rustc_version(&self) -> Option<semver::Version>;
27 fn cargo_version(&self) -> Option<semver::Version>;
29 fn rustup_version(&self) -> Option<semver::Version>;
31 fn network_reachable(&self) -> bool;
33}
34
35pub struct SystemProbe {
37 runner: SystemRunner,
38 assume_offline: bool,
39}
40
41impl Default for SystemProbe {
42 fn default() -> Self {
43 Self::new()
44 }
45}
46
47impl SystemProbe {
48 pub fn new() -> Self {
50 Self {
51 runner: SystemRunner,
52 assume_offline: false,
53 }
54 }
55
56 pub fn offline() -> Self {
58 Self {
59 runner: SystemRunner,
60 assume_offline: true,
61 }
62 }
63
64 fn probe_version(&self, program: &str) -> Option<semver::Version> {
65 let output = self
66 .runner
67 .run(program, &["--version".to_string()], None)
68 .ok()?;
69 if !output.success() {
70 return None;
71 }
72 parse_program_version(&output.stdout)
73 }
74}
75
76impl EnvironmentProbe for SystemProbe {
77 fn rustc_version(&self) -> Option<semver::Version> {
78 self.probe_version("rustc")
79 }
80
81 fn cargo_version(&self) -> Option<semver::Version> {
82 self.probe_version("cargo")
83 }
84
85 fn rustup_version(&self) -> Option<semver::Version> {
86 self.probe_version("rustup")
87 }
88
89 fn network_reachable(&self) -> bool {
90 if self.assume_offline {
91 return false;
92 }
93 #[cfg(feature = "network")]
94 {
95 crate::fetch::ping_crates_io()
96 }
97 #[cfg(not(feature = "network"))]
98 {
99 false
100 }
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
106#[serde(rename_all = "lowercase")]
107pub enum Severity {
108 Error,
110 Warning,
112}
113
114#[derive(Debug, Clone, Serialize)]
116pub struct DoctorIssue {
117 pub severity: Severity,
119 pub message: String,
121}
122
123#[derive(Debug, Clone, Serialize)]
125pub struct EnvironmentSection {
126 pub rustc: Option<String>,
128 pub cargo: Option<String>,
130 pub rustup: Option<String>,
132 pub network: bool,
134 pub cli: String,
136}
137
138#[derive(Debug, Clone, Serialize)]
140pub struct ProjectSection {
141 pub cargo_toml: bool,
143 pub package_section: bool,
145 pub templates_found: usize,
147 pub lockfile_present: bool,
149 pub lockfile_actual: bool,
151 pub core_version: Option<String>,
153}
154
155#[derive(Debug, Clone, Serialize)]
157pub struct ComponentStatus {
158 pub name: String,
160 pub version: Option<String>,
162 pub compatible: bool,
164}
165
166#[derive(Debug, Clone, Serialize)]
168pub struct DoctorReport {
169 pub environment: EnvironmentSection,
171 pub project: ProjectSection,
173 pub components: Vec<ComponentStatus>,
175 pub issues: Vec<DoctorIssue>,
177}
178
179impl DoctorReport {
180 pub fn has_errors(&self) -> bool {
182 self.issues.iter().any(|i| i.severity == Severity::Error)
183 }
184
185 pub fn to_text(&self) -> String {
187 let mut out = String::new();
188 out.push_str("Light doctor\n");
189 out.push_str("────────────────────────────────────────\n\n");
190
191 out.push_str("Окружение:\n");
192 push_check(&mut out, self.environment.rustc.as_deref(), "rustc", true);
193 push_check(&mut out, self.environment.cargo.as_deref(), "cargo", true);
194 match &self.environment.rustup {
195 Some(v) => out.push_str(&format!(" ✓ rustup {v}\n")),
196 None => out.push_str(" ⚠ rustup не обнаружен (не обязательно)\n"),
197 }
198 if self.environment.network {
199 out.push_str(" ✓ сеть crates.io доступна\n");
200 } else {
201 out.push_str(" ⚠ сеть crates.io недоступна (offline)\n");
202 }
203 out.push_str(&format!(" · light {}\n", self.environment.cli));
204
205 out.push_str("\nПроект:\n");
206 if self.project.cargo_toml {
207 out.push_str(" ✓ Cargo.toml найден\n");
208 } else {
209 out.push_str(" ✗ Cargo.toml не найден\n");
210 }
211 out.push_str(&format!(
212 " {} templates/ — {} файлов\n",
213 if self.project.templates_found > 0 {
214 "✓"
215 } else {
216 "⚠"
217 },
218 self.project.templates_found
219 ));
220 if self.project.lockfile_present {
221 if self.project.lockfile_actual {
222 out.push_str(" ✓ light.lock актуален\n");
223 } else {
224 out.push_str(" ⚠ light.lock устарел (запустите `light lock`)\n");
225 }
226 } else {
227 out.push_str(" ⚠ light.lock отсутствует (воспроизводимость не гарантирована)\n");
228 }
229 if let Some(core) = &self.project.core_version {
230 out.push_str(&format!(" · light-fw-core {core}\n"));
231 }
232
233 out.push_str(&format!("\nКомпоненты ({}):\n", self.components.len()));
234 if self.components.is_empty() {
235 out.push_str(" (нет)\n");
236 }
237 for c in &self.components {
238 let mark = if c.compatible { "✓" } else { "✗" };
239 let version = c.version.as_deref().unwrap_or("?");
240 let compat = if c.compatible {
241 "совместим"
242 } else {
243 "несовместим"
244 };
245 out.push_str(&format!(" {mark} {} {version} ({compat})\n", c.name));
246 }
247
248 out.push_str("\nЗамечания:\n");
249 if self.issues.is_empty() {
250 out.push_str(" нет\n");
251 }
252 for issue in &self.issues {
253 let mark = match issue.severity {
254 Severity::Error => "✗",
255 Severity::Warning => "⚠",
256 };
257 out.push_str(&format!(" {mark} {}\n", issue.message));
258 }
259
260 out.push_str("\n────────────────────────────────────────\n");
261 if self.has_errors() {
262 out.push_str("Итог: обнаружены проблемы.\n");
263 } else {
264 out.push_str("Итог: всё в порядке.\n");
265 }
266 out
267 }
268
269 pub fn to_json(&self) -> serde_json::Value {
271 serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
272 }
273}
274
275fn push_check(out: &mut String, version: Option<&str>, program: &str, required: bool) {
276 match version {
277 Some(v) => out.push_str(&format!(" ✓ {program} {v}\n")),
278 None if required => out.push_str(&format!(" ✗ {program} не найден\n")),
279 None => out.push_str(&format!(" ⚠ {program} не найден (не обязательно)\n")),
280 }
281}
282
283pub fn doctor(project_root: &Path, probe: &dyn EnvironmentProbe) -> Result<DoctorReport> {
285 let mut issues = Vec::new();
286
287 let environment = check_environment(probe, &mut issues);
288 let (project, components) = check_project(project_root, &mut issues)?;
289
290 Ok(DoctorReport {
291 environment,
292 project,
293 components,
294 issues,
295 })
296}
297
298fn check_environment(
299 probe: &dyn EnvironmentProbe,
300 issues: &mut Vec<DoctorIssue>,
301) -> EnvironmentSection {
302 let rustc = probe.rustc_version();
303 let cargo = probe.cargo_version();
304 let rustup = probe.rustup_version();
305 let network = probe.network_reachable();
306
307 match &rustc {
308 Some(v) => {
309 let min =
310 semver::Version::new(MIN_RUST_VERSION.0, MIN_RUST_VERSION.1, MIN_RUST_VERSION.2);
311 if *v < min {
312 issues.push(DoctorIssue {
313 severity: Severity::Error,
314 message: format!(
315 "rustc {v} старше требуемой {}.{}.{}",
316 MIN_RUST_VERSION.0, MIN_RUST_VERSION.1, MIN_RUST_VERSION.2
317 ),
318 });
319 }
320 }
321 None => issues.push(DoctorIssue {
322 severity: Severity::Error,
323 message: "rustc не найден в PATH".to_string(),
324 }),
325 }
326 if cargo.is_none() {
327 issues.push(DoctorIssue {
328 severity: Severity::Error,
329 message: "cargo не найден в PATH".to_string(),
330 });
331 }
332 if rustup.is_none() {
333 issues.push(DoctorIssue {
334 severity: Severity::Warning,
335 message: "rustup не обнаружен (не обязательно)".to_string(),
336 });
337 }
338 if !network {
339 issues.push(DoctorIssue {
340 severity: Severity::Warning,
341 message: "сеть crates.io недоступна — доступен только vendor/workspace".to_string(),
342 });
343 }
344
345 EnvironmentSection {
346 rustc: rustc.map(|v| v.to_string()),
347 cargo: cargo.map(|v| v.to_string()),
348 rustup: rustup.map(|v| v.to_string()),
349 network,
350 cli: env!("CARGO_PKG_VERSION").to_string(),
351 }
352}
353
354fn check_project(
355 project_root: &Path,
356 issues: &mut Vec<DoctorIssue>,
357) -> Result<(ProjectSection, Vec<ComponentStatus>)> {
358 let cargo_path = project_root.join("Cargo.toml");
359 let cargo_toml = cargo_path.is_file();
360 let mut package_section = false;
361 if !cargo_toml {
362 issues.push(DoctorIssue {
363 severity: Severity::Error,
364 message: "Cargo.toml не найден в указанном каталоге".to_string(),
365 });
366 } else {
367 let text = read_cargo_toml(&cargo_path)?;
368 package_section = has_package_section(&text)?;
369 if !package_section {
370 issues.push(DoctorIssue {
371 severity: Severity::Warning,
372 message: "в Cargo.toml нет секции [package]".to_string(),
373 });
374 }
375 }
376
377 let (installed, templates_found) = if cargo_toml {
378 let report = analyze_project(project_root)?;
379 (report.installed, report.scan.files.len())
380 } else {
381 (
382 Vec::new(),
383 crate::scan::scan_templates(project_root)?.files.len(),
384 )
385 };
386 if templates_found == 0 {
387 issues.push(DoctorIssue {
388 severity: Severity::Warning,
389 message: "не найдено ни одного *.html-шаблона (templates/)".to_string(),
390 });
391 }
392
393 let core_version = if cargo_toml {
394 resolve_core_version(project_root)?
395 } else {
396 None
397 };
398 if core_version.is_none() && cargo_toml {
399 issues.push(DoctorIssue {
400 severity: Severity::Warning,
401 message: "не удалось определить версию light-fw-core".to_string(),
402 });
403 }
404
405 let registry = ComponentRegistry::builtin();
406 let mut components = Vec::new();
407 for dep in &installed {
408 let info = registry.by_name(&dep.name);
409 let dep_version = resolve_dependency_version(project_root, &dep.name)?
410 .or_else(|| info.map(|i| i.latest_version.clone()));
411 let compatible = match (info, &core_version) {
412 (Some(info), Some(core)) => info.core_requirement.matches(core),
413 _ => true,
414 };
415 if !compatible {
416 let reason = info
417 .map(|i| i.core_requirement.to_string())
418 .unwrap_or_default();
419 issues.push(DoctorIssue {
420 severity: Severity::Error,
421 message: format!(
422 "{} несовместим с light-fw-core {} (требуется {})",
423 dep.name,
424 core_version
425 .as_ref()
426 .map(|v| v.to_string())
427 .unwrap_or_default(),
428 reason
429 ),
430 });
431 }
432 components.push(ComponentStatus {
433 name: dep.name.clone(),
434 version: dep_version.map(|v| v.to_string()),
435 compatible,
436 });
437 }
438
439 let lock_path = project_root.join(crate::lockfile::LOCKFILE_NAME);
440 let lockfile_present = lock_path.is_file();
441 let lockfile_actual = if lockfile_present && cargo_toml {
442 crate::lockfile::is_actual(project_root)?
443 } else {
444 false
445 };
446
447 let project = ProjectSection {
448 cargo_toml,
449 package_section,
450 templates_found,
451 lockfile_present,
452 lockfile_actual,
453 core_version: core_version.map(|v| v.to_string()),
454 };
455 Ok((project, components))
456}
457
458pub fn default_project_dir() -> PathBuf {
460 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use std::fs;
467
468 struct MockProbe {
469 rustc: Option<semver::Version>,
470 cargo: Option<semver::Version>,
471 rustup: Option<semver::Version>,
472 network: bool,
473 }
474
475 impl EnvironmentProbe for MockProbe {
476 fn rustc_version(&self) -> Option<semver::Version> {
477 self.rustc.clone()
478 }
479 fn cargo_version(&self) -> Option<semver::Version> {
480 self.cargo.clone()
481 }
482 fn rustup_version(&self) -> Option<semver::Version> {
483 self.rustup.clone()
484 }
485 fn network_reachable(&self) -> bool {
486 self.network
487 }
488 }
489
490 fn ok_probe() -> MockProbe {
491 MockProbe {
492 rustc: Some(semver::Version::new(1, 80, 0)),
493 cargo: Some(semver::Version::new(1, 80, 0)),
494 rustup: Some(semver::Version::new(1, 27, 0)),
495 network: true,
496 }
497 }
498
499 fn temp_dir(name: &str) -> PathBuf {
500 let dir = std::env::temp_dir().join(format!("light-fw-cli-doctor-{name}"));
501 let _ = fs::remove_dir_all(&dir);
502 fs::create_dir_all(&dir).expect("create temp dir");
503 dir
504 }
505
506 fn write_project(dir: &Path, cargo: &str, templates: &[(&str, &str)]) {
507 fs::write(dir.join("Cargo.toml"), cargo).expect("write Cargo.toml");
508 if !templates.is_empty() {
509 let tdir = dir.join("templates");
510 fs::create_dir_all(&tdir).expect("create templates");
511 for (name, content) in templates {
512 fs::write(tdir.join(name), content).expect("write template");
513 }
514 }
515 }
516
517 #[test]
518 fn detects_core_version_in_workspace() {
519 let root = temp_dir("workspace-core");
520 fs::write(
521 root.join("Cargo.toml"),
522 "[workspace]\nmembers = [\"app\", \"light\"]\nresolver = \"2\"\n\n\
523 [workspace.package]\nversion = \"0.2.3\"\n\n\
524 [workspace.dependencies]\nlight = { path = \"light\" }\n",
525 )
526 .expect("root manifest");
527 let app = root.join("app");
528 fs::create_dir_all(&app).expect("app");
529 fs::write(
530 app.join("Cargo.toml"),
531 "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
532 [dependencies]\nlight = { workspace = true }\n",
533 )
534 .expect("app manifest");
535 let light = root.join("light");
536 fs::create_dir_all(light.join("src")).expect("light src");
537 fs::write(light.join("src/lib.rs"), "").expect("light lib");
538 fs::write(
539 light.join("Cargo.toml"),
540 "[package]\nname = \"light\"\nversion.workspace = true\nedition = \"2021\"\n",
541 )
542 .expect("light manifest");
543
544 let report = doctor(&app, &ok_probe()).expect("doctor");
545 assert_eq!(report.project.core_version.as_deref(), Some("0.2.3"));
546 assert!(
547 !report.issues.iter().any(|i| i
548 .message
549 .contains("не удалось определить версию light-fw-core")),
550 "ядро определено: {:?}",
551 report.issues
552 );
553 }
554
555 #[test]
556 fn healthy_project_has_no_errors() {
557 let dir = temp_dir("healthy");
558 write_project(
559 &dir,
560 "[package]\nname = \"p\"\nversion = \"0.1.0\"\n[workspace]\n\n[dependencies]\nlight = \"0.1\"\n",
561 &[("app.html", "<div></div>")],
562 );
563 let report = doctor(&dir, &ok_probe()).expect("doctor");
564 assert!(!report.has_errors(), "issues: {:?}", report.issues);
565 assert_eq!(report.project.templates_found, 1);
566 assert!(!report.project.lockfile_present);
567 assert!(report.components.is_empty());
568 }
569
570 #[test]
571 fn old_rustc_is_an_error() {
572 let dir = temp_dir("old-rustc");
573 write_project(
574 &dir,
575 "[package]\nname = \"p\"\nversion = \"0.1.0\"\n[workspace]\n\n[dependencies]\nlight = \"0.1\"\n",
576 &[("app.html", "<div></div>")],
577 );
578 let mut probe = ok_probe();
579 probe.rustc = Some(semver::Version::new(1, 60, 0));
580 let report = doctor(&dir, &probe).expect("doctor");
581 assert!(report.has_errors());
582 }
583
584 #[test]
585 fn missing_templates_is_warning_not_error() {
586 let dir = temp_dir("no-templates");
587 write_project(
588 &dir,
589 "[package]\nname = \"p\"\nversion = \"0.1.0\"\n[workspace]\n\n[dependencies]\nlight = \"0.1\"\n",
590 &[],
591 );
592 let report = doctor(&dir, &ok_probe()).expect("doctor");
593 assert!(!report.has_errors());
594 assert_eq!(report.project.templates_found, 0);
595 assert!(
596 report
597 .issues
598 .iter()
599 .any(|i| i.severity == Severity::Warning && i.message.contains("шаблон"))
600 );
601 }
602
603 #[test]
604 fn incompatible_component_is_error() {
605 let dir = temp_dir("incompatible");
606 write_project(
607 &dir,
608 "[package]\nname = \"p\"\nversion = \"0.1.0\"\n[workspace]\n\n[dependencies]\nlight = \"0.1\"\nlight-fw-element-form = \"0.1\"\n",
609 &[("app.html", "<checkbox></checkbox>")],
610 );
611 let report = doctor(&dir, &ok_probe()).expect("doctor");
613 assert!(
614 report
615 .components
616 .iter()
617 .any(|c| c.name == "light-fw-element-form")
618 );
619 assert!(report.has_errors(), "issues: {:?}", report.issues);
620 }
621
622 #[test]
623 fn missing_cargo_toml_is_error() {
624 let dir = temp_dir("no-manifest");
625 let report = doctor(&dir, &ok_probe()).expect("doctor");
626 assert!(report.has_errors());
627 }
628
629 #[test]
630 fn json_serializes_and_round_trips() {
631 let dir = temp_dir("json");
632 write_project(
633 &dir,
634 "[package]\nname = \"p\"\nversion = \"0.1.0\"\n[workspace]\n\n[dependencies]\nlight = \"0.1\"\n",
635 &[("app.html", "<div></div>")],
636 );
637 let report = doctor(&dir, &ok_probe()).expect("doctor");
638 let value = report.to_json();
639 assert_eq!(value["project"]["templates_found"], 1);
640 assert_eq!(value["environment"]["cli"], env!("CARGO_PKG_VERSION"));
641 assert!(value["issues"].is_array());
642 }
643}