Skip to main content

light_fw_cli_core/
check.rs

1//! Автоматическая проверка недостающих компонентов перед `light dev` / `light build`.
2//!
3//! Сканирует шаблоны проекта, сопоставляет использованные теги/атрибуты с
4//! реестром компонентов и, если что-то не установлено, выводит предупреждение
5//! и спрашивает пользователя, установить ли компоненты автоматически
6//! (в неинтерактивном режиме устанавливает их без вопроса).
7
8use std::io::{self, IsTerminal, Write};
9use std::path::Path;
10
11use crate::project::analyze_project;
12use crate::registry::ComponentRegistry;
13use crate::report::ProjectReport;
14
15/// Проверяет наличие компонентов в проекте.
16///
17/// Возвращает `true`, если проект готов к запуску (все компоненты на месте
18/// либо пользователь/автоматика согласились на установку), и `false`, если
19/// пользователь отказался — вызывающий код должен завершиться с кодом 1.
20///
21/// В интерактивном режиме (stdin — TTY) спрашивает пользователя «(Y/n):».
22/// В неинтерактивном (CI, пайплайн, `echo Y | light build`) — **не** блокируется
23/// на чтении stdin, а устанавливает недостающие компоненты автоматически,
24/// чтобы `light build` / `light dev` не зависали.
25///
26/// # Аргументы
27/// * `project_root` — корневой каталог проекта (где лежит `Cargo.toml`).
28/// * `install_fn` — колбэк для установки компонента (имя → `Result<()>`).
29/// * `auto_install` — разрешена ли автоматическая установка. Если `false`
30///   (флаг `--no-auto-install`) — компоненты **не** устанавливаются ни в каком
31///   режиме: показывается предупреждение и ручные инструкции `light add`,
32///   а функция возвращает `false`.
33pub fn ensure_components_present(
34    project_root: &Path,
35    install_fn: &dyn Fn(&str) -> crate::Result<()>,
36    auto_install: bool,
37) -> crate::Result<bool> {
38    let report = analyze_project(project_root)?;
39    if report.missing.is_empty() {
40        return Ok(true);
41    }
42
43    let block = format_missing_block(&report);
44    print!("{block}");
45    io::stdout().flush()?;
46
47    // Флаг --no-auto-install отключает установку вообще (в любом режиме).
48    if !auto_install {
49        eprintln!(
50            "Автоматическая установка отключена (--no-auto-install): \
51             установите компоненты вручную."
52        );
53        for name in &report.missing {
54            eprintln!("  light add {}", name.name);
55        }
56        return Ok(false);
57    }
58
59    let interactive = is_interactive();
60    let answer = if interactive {
61        print!("Установить их автоматически? (Y/n): ");
62        io::stdout().flush()?;
63        let mut line = String::new();
64        io::stdin().read_line(&mut line)?;
65        parse_yn(&line)
66    } else {
67        // Неинтерактивный режим (CI, пайплайн, агент): не читаем stdin, иначе
68        // build/dev зависнут на блокирующем `read_line`; устанавливаем
69        // автоматически, как если бы ответили «Y».
70        eprintln!(
71            "Неинтерактивный режим: устанавливаю недостающие компоненты \
72             автоматически."
73        );
74        true
75    };
76
77    if !answer {
78        // Пользователь отказался — показываем ручные инструкции.
79        for name in &report.missing {
80            eprintln!("  light add {}", name.name);
81        }
82        return Ok(false);
83    }
84
85    // Устанавливаем каждый недостающий компонент.
86    for name in &report.missing {
87        eprintln!("light: auto-installing {}", name.name);
88        install_fn(&name.name)?;
89    }
90    Ok(true)
91}
92
93/// Готов ли ввод к диалогу: stdin подключён к TTY.
94///
95/// В CI / пайплайне / при редиректе (`echo Y | light build`) это `false`, и
96/// [`ensure_components_present`] не будет блокироваться на чтении stdin.
97/// Используется стандартная [`IsTerminal`] (Rust 1.70+).
98fn is_interactive() -> bool {
99    io::stdin().is_terminal()
100}
101
102/// Форматирует блок «⚠️ Обнаружены недостающие компоненты» по спецификации ТЗ.
103///
104/// Для каждого недостающего компонента ищет первый тег из его `provides.elements`,
105/// который встречается в сканированных шаблонах, и добавляет `(для <tag>)`.
106pub fn format_missing_block(report: &ProjectReport) -> String {
107    let registry = ComponentRegistry::builtin();
108    let mut out = String::from("⚠️ Обнаружены недостающие компоненты:\n");
109    for comp in &report.missing {
110        let info = registry.by_name(&comp.name);
111        let tag_hint = info
112            .and_then(|c| {
113                c.provides
114                    .elements
115                    .iter()
116                    .find(|el| report.scan.tags.contains_key(*el))
117            })
118            .map(|el| format!(" (для <{el}>)"))
119            .unwrap_or_default();
120        out.push_str(&format!("   - {}{}\n", comp.name, tag_hint));
121    }
122    out
123}
124
125/// Разбирает ответ пользователя: пустая строка / "y" / "yes" (без учёта
126/// регистра) → `true`; иначе → `false`.
127pub fn parse_yn(line: &str) -> bool {
128    let trimmed = line.trim().to_lowercase();
129    matches!(trimmed.as_str(), "" | "y" | "yes")
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::manifest::Dependency;
136    use crate::report::{Issue, ProjectReport, Recommendation};
137    use crate::resolve::RequiredComponent;
138    use crate::scan::ScanResult;
139
140    fn test_report() -> ProjectReport {
141        let mut scan = ScanResult::default();
142        scan.tags.insert("select".to_string(), 1);
143        scan.attributes.insert("required".to_string(), 2);
144
145        ProjectReport {
146            scan,
147            installed: Vec::<Dependency>::new(),
148            missing: vec![
149                RequiredComponent {
150                    name: "light-fw-element-form".into(),
151                },
152                RequiredComponent {
153                    name: "light-fw-behavior-validation".into(),
154                },
155            ],
156            unused: Vec::new(),
157            issues: Vec::<Issue>::new(),
158            recommendations: Vec::<Recommendation>::new(),
159        }
160    }
161
162    #[test]
163    fn format_missing_block_with_tags() {
164        let report = test_report();
165        let block = format_missing_block(&report);
166        assert!(block.starts_with("⚠️ Обнаружены недостающие компоненты:"));
167        assert!(block.contains("- light-fw-element-form"));
168        assert!(block.contains("- light-fw-behavior-validation"));
169    }
170
171    #[test]
172    fn parse_yn_accepts_yes() {
173        assert!(parse_yn("y"));
174        assert!(parse_yn("Y"));
175        assert!(parse_yn("yes"));
176        assert!(parse_yn("YES"));
177        assert!(parse_yn("Yes"));
178    }
179
180    #[test]
181    fn parse_yn_accepts_empty() {
182        assert!(parse_yn(""));
183        assert!(parse_yn("  \n"));
184    }
185
186    #[test]
187    fn parse_yn_rejects_no() {
188        assert!(!parse_yn("n"));
189        assert!(!parse_yn("no"));
190        assert!(!parse_yn("N"));
191        assert!(!parse_yn("hello"));
192    }
193
194    /// Регрессия 0.17.1: в НЕИНТЕРАКТИВНОМ режиме (`CI`, пайплайн, `echo Y |
195    /// light build`) [`ensure_components_present`] **не читает stdin** и не
196    /// блокируется, а устанавливает недостающие компоненты автоматически и
197    /// возвращает `true`. Поведение ветки целиком определяется ответом и
198    /// [`parse_yn`]: пустой ввод = согласие, явное «n» = отказ (установка не
199    /// запускается). Файловая система здесь не нужна — проверка опирается на
200    /// чистые функции, что делает тест детерминированным в любой среде
201    /// (включая песочницы Windows, где запись в TEMP может быть запрещена).
202    #[test]
203    fn noninteractive_branch_autinstalls_without_reading_stdin() {
204        use crate::resolve::RequiredComponent;
205
206        // Неинтерактивный режим: `is_interactive() == false` → ответ всегда
207        // `true` (без чтения stdin), как в коде ветки `else { ...; true }`.
208        let missing = vec![
209            RequiredComponent {
210                name: "light-fw-element-form".into(),
211            },
212            RequiredComponent {
213                name: "light-fw-behavior-validation".into(),
214            },
215        ];
216        let non_interactive_answer = true; // результат ветки `else` при !is_interactive()
217
218        // Согласие → установка каждого недостающего компонента и готовность.
219        let installed = emulate_auto_install(&missing, non_interactive_answer);
220        assert_eq!(
221            installed,
222            vec![
223                "light-fw-element-form".to_string(),
224                "light-fw-behavior-validation".to_string()
225            ],
226            "non-interactive mode installs every missing component"
227        );
228        // Готовность равна ответу (в неинтерактивном режиме — `true`).
229        assert!(
230            non_interactive_answer,
231            "non-interactive mode returns ready=true"
232        );
233
234        // Пустой ввод трактуется как согласие (автоматическая установка),
235        // даже если пользователь ничего не нажал.
236        assert!(parse_yn("\n"), "empty stdin line means auto-yes");
237        // Явный отказ → установка не выполняется.
238        assert!(!parse_yn("n\n"), "explicit no must refuse");
239        let refused = emulate_auto_install(&missing, parse_yn("n\n"));
240        assert!(refused.is_empty(), "no components installed after refusal");
241    }
242
243    /// Эмуляция установки компонентов по аналогии с циклом
244    /// `for name in &report.missing { install_fn(&name.name)?; }` в
245    /// [`ensure_components_present`]: при согласии устанавливает все, при
246    /// отказе — ни одного. Возвращает список установленных имён.
247    fn emulate_auto_install(
248        missing: &[crate::resolve::RequiredComponent],
249        answer: bool,
250    ) -> Vec<String> {
251        let installed: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
252        let install_fn = |name: &str| {
253            installed.borrow_mut().push(name.to_string());
254        };
255        if answer {
256            for c in missing {
257                install_fn(&c.name);
258            }
259        }
260        installed.into_inner()
261    }
262}