Skip to main content

light_fw_cli_core/
doctor.rs

1//! Команда `light doctor`: диагностика окружения и проекта.
2//!
3//! Модуль только читает данные (файлы, версии инструментов, сеть) и ничего не
4//! меняет на диске. Внешние зависимости (запуск `rustc`/`cargo`/`rustup` и
5//! доступность сети) инжектируются через [`EnvironmentProbe`], поэтому логика
6//! тестируется без реальных процессов.
7
8use 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
20/// Минимальная поддерживаемая версия Rust.
21pub const MIN_RUST_VERSION: (u64, u64, u64) = (1, 75, 0);
22
23/// Источник данных об окружении.
24pub trait EnvironmentProbe: Send + Sync {
25    /// Версия `rustc`.
26    fn rustc_version(&self) -> Option<semver::Version>;
27    /// Версия `cargo`.
28    fn cargo_version(&self) -> Option<semver::Version>;
29    /// Версия `rustup` (опционально).
30    fn rustup_version(&self) -> Option<semver::Version>;
31    /// Доступна ли сеть (crates.io).
32    fn network_reachable(&self) -> bool;
33}
34
35/// Реальный опрос окружения через запуск процессов.
36pub 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    /// Создаёт пробник на базе системного раннера.
49    pub fn new() -> Self {
50        Self {
51            runner: SystemRunner,
52            assume_offline: false,
53        }
54    }
55
56    /// Пробник, который не проверяет сеть (режим `--offline`).
57    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/// Уровень серьёзности замечания.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
106#[serde(rename_all = "lowercase")]
107pub enum Severity {
108    /// Ошибка: ненулевой код возврата.
109    Error,
110    /// Предупреждение: не блокирует работу.
111    Warning,
112}
113
114/// Замечание `light doctor`.
115#[derive(Debug, Clone, Serialize)]
116pub struct DoctorIssue {
117    /// Уровень.
118    pub severity: Severity,
119    /// Описание.
120    pub message: String,
121}
122
123/// Данные об окружении.
124#[derive(Debug, Clone, Serialize)]
125pub struct EnvironmentSection {
126    /// Версия `rustc` (`null`, если не найден).
127    pub rustc: Option<String>,
128    /// Версия `cargo`.
129    pub cargo: Option<String>,
130    /// Версия `rustup` (опционально).
131    pub rustup: Option<String>,
132    /// Доступна ли сеть.
133    pub network: bool,
134    /// Версия самого CLI.
135    pub cli: String,
136}
137
138/// Данные о проекте.
139#[derive(Debug, Clone, Serialize)]
140pub struct ProjectSection {
141    /// Найден ли `Cargo.toml`.
142    pub cargo_toml: bool,
143    /// Есть ли секция `[package]`.
144    pub package_section: bool,
145    /// Найдено ли шаблонов.
146    pub templates_found: usize,
147    /// Есть ли `light.lock`.
148    pub lockfile_present: bool,
149    /// Актуален ли `light.lock` (если он есть).
150    pub lockfile_actual: bool,
151    /// Версия ядра проекта (если определена).
152    pub core_version: Option<String>,
153}
154
155/// Статус одного установленного компонента.
156#[derive(Debug, Clone, Serialize)]
157pub struct ComponentStatus {
158    /// Имя крейта.
159    pub name: String,
160    /// Установленная версия.
161    pub version: Option<String>,
162    /// Совместим ли с ядром.
163    pub compatible: bool,
164}
165
166/// Полный отчёт `light doctor`.
167#[derive(Debug, Clone, Serialize)]
168pub struct DoctorReport {
169    /// Окружение.
170    pub environment: EnvironmentSection,
171    /// Проект.
172    pub project: ProjectSection,
173    /// Компоненты.
174    pub components: Vec<ComponentStatus>,
175    /// Замечания.
176    pub issues: Vec<DoctorIssue>,
177}
178
179impl DoctorReport {
180    /// Есть ли ошибки (ненулевой код возврата).
181    pub fn has_errors(&self) -> bool {
182        self.issues.iter().any(|i| i.severity == Severity::Error)
183    }
184
185    /// Текстовый отчёт.
186    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    /// Отчёт в формате JSON (совпадает со структурой [`DoctorReport`]).
270    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
283/// Запускает диагностику проекта `project_root`.
284pub 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
458/// Каталог проекта по умолчанию.
459pub 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        // Registry требует core ^0.4; проект на light 0.1 — несовместимо.
612        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}