Skip to main content

light_fw_cli_core/
lockfile.rs

1//! Формат и управление `light.lock`.
2//!
3//! `light.lock` фиксирует установленные компоненты и их версии (по аналогии с
4//! `Cargo.lock`), обеспечивая воспроизводимость сборки. Файл генерируется
5//! автоматически командами `light add`/`remove`/`update`/`lock` — вручную
6//! редактировать его не нужно.
7
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::Result;
13use crate::manifest::{
14    collect_dependency_details, find_workspace_root, read_workspace_dependencies,
15    resolve_core_version as detect_core_version, resolve_dependency_version,
16};
17use crate::registry::ComponentRegistry;
18use crate::scan::scan_templates;
19
20/// Имя lock-файла в корне проекта.
21pub const LOCKFILE_NAME: &str = "light.lock";
22
23/// Заголовок, записываемый в начало файла.
24const HEADER: &str = "# light.lock — генерируется автоматически, не редактировать вручную.\n";
25
26/// Содержимое `light.lock`.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Lockfile {
29    /// Версия формата.
30    pub version: u32,
31    /// Версия ядра, под которую зафиксированы компоненты.
32    pub core: String,
33    /// Зафиксированные компоненты.
34    #[serde(rename = "component", default)]
35    pub components: Vec<LockComponent>,
36}
37
38/// Один зафиксированный компонент.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct LockComponent {
41    /// Имя крейта.
42    pub name: String,
43    /// Версия.
44    pub version: String,
45    /// Источник: `workspace` | `crates.io` | `vendor`.
46    pub source: String,
47    /// Путь к локальному компоненту (для `workspace`).
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub path: Option<String>,
50    /// Зависимости компонента.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub dependencies: Vec<LockDependency>,
53}
54
55/// Зависимость компонента.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct LockDependency {
58    /// Имя крейта.
59    pub name: String,
60    /// Требование/версия.
61    pub version: String,
62}
63
64impl Lockfile {
65    /// Путь к lock-файлу в проекте.
66    pub fn path_in(project_root: &Path) -> PathBuf {
67        project_root.join(LOCKFILE_NAME)
68    }
69
70    /// Читает lock-файл с диска.
71    pub fn read(project_root: &Path) -> Result<Lockfile> {
72        let path = Self::path_in(project_root);
73        let text = std::fs::read_to_string(&path)?;
74        Self::from_toml(&text)
75    }
76
77    /// Разбирает lock-файл из TOML-текста.
78    pub fn from_toml(text: &str) -> Result<Lockfile> {
79        toml_edit::de::from_str(text).map_err(|e| crate::Error::Lockfile(e.to_string()))
80    }
81
82    /// Сериализует lock-файл в TOML-текст (с заголовком-комментарием).
83    pub fn to_toml(&self) -> Result<String> {
84        let body = toml_edit::ser::to_string_pretty(self)?;
85        Ok(format!("{HEADER}{body}"))
86    }
87
88    /// Записывает lock-файл на диск.
89    pub fn write(&self, project_root: &Path) -> Result<()> {
90        let path = Self::path_in(project_root);
91        std::fs::write(path, self.to_toml()?)?;
92        Ok(())
93    }
94
95    /// Строит lock-файл из текущего `Cargo.toml` проекта.
96    pub fn from_project(project_root: &Path) -> Result<Lockfile> {
97        let registry = ComponentRegistry::builtin();
98        let core = resolve_core_version(project_root)?;
99        let details = collect_project_components(project_root)?;
100
101        let mut components = Vec::new();
102        for dep in details {
103            let info = registry.by_name(&dep.name);
104            let version = resolve_dependency_version(project_root, &dep.name)?
105                .or_else(|| info.map(|i| i.latest_version.clone()))
106                .map(|v| v.to_string())
107                .unwrap_or_else(|| "0.0.0".to_string());
108            let source = if dep.workspace || dep.path.is_some() {
109                "workspace"
110            } else {
111                "crates.io"
112            };
113            components.push(LockComponent {
114                name: dep.name.clone(),
115                version,
116                source: source.to_string(),
117                path: dep.path.as_ref().map(|p| relative_path(project_root, p)),
118                dependencies: vec![LockDependency {
119                    name: "light-fw-core".to_string(),
120                    version: core.clone(),
121                }],
122            });
123        }
124        components.sort_by(|a, b| a.name.cmp(&b.name));
125        Ok(Lockfile {
126            version: 1,
127            core,
128            components,
129        })
130    }
131
132    /// Сравнивает set компонентов (имя+версия) с другим lock-файлом.
133    pub fn same_components(&self, other: &Lockfile) -> bool {
134        let key = |l: &Lockfile| {
135            let mut v: Vec<(String, String)> = l
136                .components
137                .iter()
138                .map(|c| (c.name.clone(), c.version.clone()))
139                .collect();
140            v.sort();
141            v
142        };
143        key(self) == key(other)
144    }
145}
146
147/// Разница между двумя `light.lock`.
148#[derive(Debug, Clone, PartialEq, Eq, Default)]
149pub struct LockDiff {
150    /// Компоненты, появившиеся в новом lock.
151    pub added: Vec<String>,
152    /// Компоненты, исчезнувшие из нового lock.
153    pub removed: Vec<String>,
154    /// Компоненты с изменившейся версией.
155    pub changed: Vec<String>,
156}
157
158impl LockDiff {
159    /// Нет ли изменений.
160    pub fn is_empty(&self) -> bool {
161        self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
162    }
163
164    /// Человекочитаемое описание.
165    pub fn to_text(&self) -> String {
166        let mut out = String::new();
167        for name in &self.added {
168            out.push_str(&format!("  + {name}\n"));
169        }
170        for name in &self.removed {
171            out.push_str(&format!("  - {name}\n"));
172        }
173        for name in &self.changed {
174            out.push_str(&format!("  ~ {name}\n"));
175        }
176        out
177    }
178}
179
180/// Сравнивает два lock-файла по составу компонентов и их версиям.
181pub fn diff(old: &Lockfile, new: &Lockfile) -> LockDiff {
182    use std::collections::BTreeMap;
183
184    let old_map: BTreeMap<&str, &str> = old
185        .components
186        .iter()
187        .map(|c| (c.name.as_str(), c.version.as_str()))
188        .collect();
189    let new_map: BTreeMap<&str, &str> = new
190        .components
191        .iter()
192        .map(|c| (c.name.as_str(), c.version.as_str()))
193        .collect();
194
195    let mut diff = LockDiff::default();
196    for (name, version) in &new_map {
197        match old_map.get(name) {
198            None => diff.added.push((*name).to_string()),
199            Some(old_version) if old_version != version => {
200                diff.changed.push((*name).to_string());
201            }
202            Some(_) => {}
203        }
204    }
205    for name in old_map.keys() {
206        if !new_map.contains_key(name) {
207            diff.removed.push((*name).to_string());
208        }
209    }
210    diff
211}
212
213/// Актуален ли существующий `light.lock` относительно `Cargo.toml`.
214pub fn is_actual(project_root: &Path) -> Result<bool> {
215    let path = Lockfile::path_in(project_root);
216    if !path.is_file() {
217        return Ok(false);
218    }
219    let current = Lockfile::read(project_root)?;
220    let expected = Lockfile::from_project(project_root)?;
221    Ok(current.same_components(&expected) && current.core == expected.core)
222}
223/// Читает установленные компоненты проекта (только известные реестру).
224fn collect_project_components(project_root: &Path) -> Result<Vec<crate::manifest::DepDetail>> {
225    let cargo = project_root.join("Cargo.toml");
226    let text = std::fs::read_to_string(&cargo)?;
227    let details = collect_dependency_details(&text, project_root)?;
228    let registry = ComponentRegistry::builtin();
229    Ok(details
230        .into_iter()
231        .filter(|d| registry.by_name(&d.name).is_some())
232        .collect())
233}
234
235fn resolve_core_version(project_root: &Path) -> Result<String> {
236    Ok(detect_core_version(project_root)?
237        .map(|v| v.to_string())
238        .unwrap_or_else(|| "0.0.0".to_string()))
239}
240
241fn relative_path(_base: &Path, path: &Path) -> String {
242    path.to_string_lossy().replace('\\', "/")
243}
244
245/// Определяет источник компонента (workspace / crates.io), не читая сеть.
246pub fn detect_source(project_root: &Path, name: &str) -> Result<Option<String>> {
247    if let Some(root) = find_workspace_root(project_root) {
248        let ws = read_workspace_dependencies(&root)?;
249        if ws.iter().any(|d| d.name == name) {
250            return Ok(Some("workspace".to_string()));
251        }
252    }
253    Ok(None)
254}
255
256/// Полный анализ проекта (переиспользуется в `lock`), возвращает число шаблонов.
257pub fn template_count(project_root: &Path) -> Result<usize> {
258    Ok(scan_templates(project_root)?.files.len())
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::fs;
265
266    fn temp_dir(name: &str) -> PathBuf {
267        let dir = std::env::temp_dir().join(format!("light-fw-cli-lock-{name}"));
268        let _ = fs::remove_dir_all(&dir);
269        fs::create_dir_all(&dir).expect("create temp dir");
270        dir
271    }
272
273    #[test]
274    fn toml_round_trip() {
275        let lock = Lockfile {
276            version: 1,
277            core: "0.1.0".to_string(),
278            components: vec![LockComponent {
279                name: "light-fw-element-form".to_string(),
280                version: "0.1.0".to_string(),
281                source: "workspace".to_string(),
282                path: Some("crates/light-element-form".to_string()),
283                dependencies: vec![LockDependency {
284                    name: "light-fw-core".to_string(),
285                    version: "0.1.0".to_string(),
286                }],
287            }],
288        };
289        let text = lock.to_toml().expect("to_toml");
290        assert!(text.contains("not edit") || text.contains("не редактировать"));
291        let parsed = Lockfile::from_toml(&text).expect("from_toml");
292        assert_eq!(parsed, lock);
293    }
294
295    #[test]
296    fn resolves_core_from_workspace_meta_crate() {
297        let root = temp_dir("core-workspace");
298        fs::write(
299            root.join("Cargo.toml"),
300            "[workspace]\nmembers = [\"app\", \"light\"]\nresolver = \"2\"\n\n\
301             [workspace.package]\nversion = \"0.2.3\"\n\n\
302             [workspace.dependencies]\nlight = { path = \"light\" }\n",
303        )
304        .expect("root manifest");
305        let app = root.join("app");
306        fs::create_dir_all(&app).expect("app");
307        fs::write(
308            app.join("Cargo.toml"),
309            "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
310             [dependencies]\nlight = { workspace = true }\n",
311        )
312        .expect("app manifest");
313        let light = root.join("light");
314        fs::create_dir_all(light.join("src")).expect("light src");
315        fs::write(light.join("src/lib.rs"), "").expect("light lib");
316        fs::write(
317            light.join("Cargo.toml"),
318            "[package]\nname = \"light\"\nversion.workspace = true\nedition = \"2021\"\n",
319        )
320        .expect("light manifest");
321
322        let lock = Lockfile::from_project(&app).expect("from_project");
323        assert_eq!(lock.core, "0.2.3", "версия ядра из workspace");
324        assert!(lock.components.is_empty());
325    }
326
327    #[test]
328    fn from_project_reads_components() {
329        let dir = temp_dir("from-project");
330        fs::write(
331            dir.join("Cargo.toml"),
332            "[package]\nname=\"p\"\nversion=\"0.1.0\"\n\n[dependencies]\nlight = \"0.1\"\nlight-fw-element-form = \"0.1\"\n",
333        )
334        .expect("write");
335        let lock = Lockfile::from_project(&dir).expect("from_project");
336        assert_eq!(lock.version, 1);
337        assert_eq!(lock.components.len(), 1);
338        assert_eq!(lock.components[0].name, "light-fw-element-form");
339        assert_eq!(lock.core, "0.1.0");
340    }
341
342    #[test]
343    fn is_actual_detects_drift() {
344        let dir = temp_dir("actual");
345        fs::write(
346            dir.join("Cargo.toml"),
347            "[package]\nname=\"p\"\nversion=\"0.1.0\"\n\n[dependencies]\nlight = \"0.1\"\nlight-fw-element-form = \"0.1\"\n",
348        )
349        .expect("write");
350        // Нет файла → не актуален.
351        assert!(!is_actual(&dir).expect("is_actual"));
352        // Записываем корректный lock → актуален.
353        Lockfile::from_project(&dir)
354            .expect("build")
355            .write(&dir)
356            .expect("write lock");
357        assert!(is_actual(&dir).expect("is_actual"));
358        // Меняем Cargo.toml → снова не актуален.
359        fs::write(
360            dir.join("Cargo.toml"),
361            "[package]\nname=\"p\"\nversion=\"0.1.0\"\n\n[dependencies]\nlight = \"0.1\"\n",
362        )
363        .expect("write");
364        assert!(!is_actual(&dir).expect("is_actual"));
365    }
366
367    #[test]
368    fn diff_reports_added_removed_changed() {
369        let base = Lockfile {
370            version: 1,
371            core: "0.1.0".to_string(),
372            components: vec![
373                component("light-fw-element-form", "0.1.0"),
374                component("light-fw-behavior-validation", "0.1.0"),
375            ],
376        };
377        let updated = Lockfile {
378            version: 1,
379            core: "0.1.0".to_string(),
380            components: vec![
381                component("light-fw-element-form", "0.2.0"),
382                component("light-widget-datepicker", "0.3.0"),
383            ],
384        };
385        let changes = diff(&base, &updated);
386        assert_eq!(changes.changed, vec!["light-fw-element-form"]);
387        assert_eq!(changes.added, vec!["light-widget-datepicker"]);
388        assert_eq!(changes.removed, vec!["light-fw-behavior-validation"]);
389        assert!(!changes.is_empty());
390        assert!(changes.to_text().contains("~ light-fw-element-form"));
391        assert!(diff(&base, &base).is_empty());
392    }
393
394    fn component(name: &str, version: &str) -> LockComponent {
395        LockComponent {
396            name: name.to_string(),
397            version: version.to_string(),
398            source: "workspace".to_string(),
399            path: None,
400            dependencies: Vec::new(),
401        }
402    }
403
404    #[test]
405    fn template_count_works() {
406        let dir = temp_dir("count");
407        fs::write(
408            dir.join("Cargo.toml"),
409            "[package]\nname=\"p\"\nversion=\"0.1.0\"\n",
410        )
411        .expect("write");
412        assert_eq!(template_count(&dir).expect("count"), 0);
413    }
414}