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/// Реальный опрос окружения через запуск процессов.
36#[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    /// Создаёт пробник на базе системного раннера.
50    pub fn new() -> Self {
51        Self {
52            runner: SystemRunner,
53            assume_offline: false,
54        }
55    }
56
57    /// Пробник, который не проверяет сеть (режим `--offline`).
58    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/// Уровень серьёзности замечания.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
107#[serde(rename_all = "lowercase")]
108pub enum Severity {
109    /// Ошибка: ненулевой код возврата.
110    Error,
111    /// Предупреждение: не блокирует работу.
112    Warning,
113}
114
115/// Замечание `light doctor`.
116#[derive(Debug, Clone, Serialize)]
117pub struct DoctorIssue {
118    /// Уровень.
119    pub severity: Severity,
120    /// Описание.
121    pub message: String,
122}
123
124/// Данные об окружении.
125#[derive(Debug, Clone, Serialize)]
126pub struct EnvironmentSection {
127    /// Версия `rustc` (`null`, если не найден).
128    pub rustc: Option<String>,
129    /// Версия `cargo`.
130    pub cargo: Option<String>,
131    /// Версия `rustup` (опционально).
132    pub rustup: Option<String>,
133    /// Доступна ли сеть.
134    pub network: bool,
135    /// Версия самого CLI.
136    pub cli: String,
137}
138
139/// Данные о проекте.
140#[derive(Debug, Clone, Serialize)]
141pub struct ProjectSection {
142    /// Найден ли `Cargo.toml`.
143    pub cargo_toml: bool,
144    /// Есть ли секция `[package]`.
145    pub package_section: bool,
146    /// Найдено ли шаблонов.
147    pub templates_found: usize,
148    /// Есть ли `light.lock`.
149    pub lockfile_present: bool,
150    /// Актуален ли `light.lock` (если он есть).
151    pub lockfile_actual: bool,
152    /// Версия ядра проекта (если определена).
153    pub core_version: Option<String>,
154}
155
156/// Статус одного установленного компонента.
157#[derive(Debug, Clone, Serialize)]
158pub struct ComponentStatus {
159    /// Имя крейта.
160    pub name: String,
161    /// Установленная версия.
162    pub version: Option<String>,
163    /// Совместим ли с ядром.
164    pub compatible: bool,
165}
166
167/// Полный отчёт `light doctor`.
168#[derive(Debug, Clone, Serialize)]
169pub struct DoctorReport {
170    /// Окружение.
171    pub environment: EnvironmentSection,
172    /// Проект.
173    pub project: ProjectSection,
174    /// Компоненты.
175    pub components: Vec<ComponentStatus>,
176    /// Замечания.
177    pub issues: Vec<DoctorIssue>,
178}
179
180impl DoctorReport {
181    /// Есть ли ошибки (ненулевой код возврата).
182    pub fn has_errors(&self) -> bool {
183        self.issues.iter().any(|i| i.severity == Severity::Error)
184    }
185
186    /// Текстовый отчёт.
187    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    /// Отчёт в формате JSON (совпадает со структурой [`DoctorReport`]).
271    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
284/// Запускает диагностику проекта `project_root`.
285pub 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
459/// Каталог проекта по умолчанию.
460pub 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        // Registry требует core ^0.6; проект на light 0.1 — несовместимо.
613        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}