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