Skip to main content

light_fw_cli_core/
edit.rs

1//! Редактирование `Cargo.toml` с сохранением форматирования.
2//!
3//! Для зависимостей используется `cargo add`/`cargo remove`, но когда это
4//! невозможно (зависимость из `[workspace.dependencies]`, которую `cargo add`
5//! не умеет записывать как `workspace = true`), правка выполняется здесь через
6//! `toml_edit`. Секция `[features]` редактируется только здесь.
7
8use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value, value};
9
10use crate::error::{Error, Result};
11
12/// Разбирает текст `Cargo.toml`.
13pub fn parse(text: &str) -> Result<DocumentMut> {
14    text.parse::<DocumentMut>()
15        .map_err(|e| Error::Manifest(format!("не удалось разобрать Cargo.toml: {e}")))
16}
17
18fn dependencies_table(doc: &mut DocumentMut) -> Result<&mut Table> {
19    doc.entry("dependencies")
20        .or_insert_with(|| Item::Table(Table::new()));
21    doc.get_mut("dependencies")
22        .and_then(Item::as_table_mut)
23        .ok_or_else(|| Error::Manifest("секция [dependencies] не является таблицей".to_string()))
24}
25
26fn features_table(doc: &mut DocumentMut) -> Result<&mut Table> {
27    doc.entry("features")
28        .or_insert_with(|| Item::Table(Table::new()));
29    doc.get_mut("features")
30        .and_then(Item::as_table_mut)
31        .ok_or_else(|| Error::Manifest("секция [features] не является таблицей".to_string()))
32}
33
34fn optional_dep(kind: (&str, &str), optional: bool) -> Item {
35    let mut inline = InlineTable::new();
36    inline.insert(kind.0, Value::from(kind.1));
37    inline.insert("optional", Value::from(optional));
38    Item::Value(Value::InlineTable(inline))
39}
40
41/// Записывает `name = { workspace = true, optional = true }`.
42pub fn set_workspace_optional(doc: &mut DocumentMut, name: &str) -> Result<()> {
43    let mut inline = InlineTable::new();
44    inline.insert("workspace", Value::from(true));
45    inline.insert("optional", Value::from(true));
46    dependencies_table(doc)?.insert(name, Item::Value(Value::InlineTable(inline)));
47    Ok(())
48}
49
50/// Записывает `name = { path = "...", optional = true }`.
51pub fn set_path_optional(doc: &mut DocumentMut, name: &str, path: &str) -> Result<()> {
52    dependencies_table(doc)?.insert(name, optional_dep(("path", path), true));
53    Ok(())
54}
55
56/// Записывает `name = { version = "...", optional = true }`.
57pub fn set_version_optional(doc: &mut DocumentMut, name: &str, version: &str) -> Result<()> {
58    dependencies_table(doc)?.insert(name, optional_dep(("version", version), true));
59    Ok(())
60}
61
62/// Есть ли зависимость с таким именем в `[dependencies]`.
63pub fn has_dependency(doc: &DocumentMut, name: &str) -> bool {
64    doc.get("dependencies")
65        .and_then(Item::as_table_like)
66        .is_some_and(|table| table.contains_key(name))
67}
68
69/// Добавляет фичи в существующую зависимость, не дублируя их.
70///
71/// Строковая форма `name = "1.0"` превращается в
72/// `name = { version = "1.0", features = [...] }`.
73pub fn add_features_to_dependency(
74    doc: &mut DocumentMut,
75    name: &str,
76    features: &[String],
77) -> Result<()> {
78    if features.is_empty() {
79        return Ok(());
80    }
81    let entry = dependencies_table(doc)?
82        .get_mut(name)
83        .ok_or_else(|| Error::Manifest(format!("зависимость `{name}` не найдена")))?;
84
85    let table = if entry.is_str() {
86        let version = entry.as_str().unwrap_or_default().to_string();
87        let mut inline = InlineTable::new();
88        inline.insert("version", Value::from(version));
89        inline.insert("features", Value::Array(Array::new()));
90        *entry = Item::Value(Value::InlineTable(inline));
91        entry
92            .as_table_like_mut()
93            .expect("только что создали inline-таблицу")
94    } else {
95        entry.as_table_like_mut().ok_or_else(|| {
96            Error::Manifest(format!("не удалось добавить фичи зависимости `{name}`"))
97        })?
98    };
99
100    if table.get_mut("features").is_none() {
101        table.insert("features", Item::Value(Value::Array(Array::new())));
102    }
103    let array = table
104        .get_mut("features")
105        .and_then(Item::as_array_mut)
106        .ok_or_else(|| Error::Manifest(format!("features зависимости `{name}` не массив")))?;
107    for feature in features {
108        if !array
109            .iter()
110            .any(|value| value.as_str() == Some(feature.as_str()))
111        {
112            array.push(feature.clone());
113        }
114    }
115    if let Item::Value(Value::InlineTable(inline)) = entry {
116        inline.fmt();
117    }
118    Ok(())
119}
120
121/// Точка вставки нового `dep:`-элемента в **многострочном** массиве и его
122/// префикс (стиль соседа). Возвращает `None` для однострочных массивов.
123///
124/// Элемент вставляется **после последнего `dep:`**, чтобы группы зависимостей
125/// не разъезжались и повторный `remove`+`add` давал byte-perfect roundtrip.
126fn multiline_dep_insertion(arr: &Array) -> Option<(usize, String)> {
127    if !arr.to_string().contains('\n') {
128        return None;
129    }
130    let after = arr
131        .iter()
132        .enumerate()
133        .filter(|(_, v)| v.as_str().is_some_and(|s| s.starts_with("dep:")))
134        .map(|(i, _)| i + 1)
135        .last()
136        .unwrap_or_else(|| arr.iter().count());
137    let neighbor = arr
138        .iter()
139        .nth(after.saturating_sub(1))
140        .or_else(|| arr.iter().last());
141    let prefix = neighbor
142        .and_then(|v| v.decor().prefix().and_then(|p| p.as_str()))
143        .map(|prefix| match prefix.rfind('\n') {
144            // Отбрасываем комментарии соседа: берём только перевод строки и
145            // отступ, иначе при вставке комментарий продублируется.
146            Some(index) => &prefix[index..],
147            None => prefix,
148        })
149        .filter(|prefix| prefix.contains('\n'))
150        .unwrap_or("\n    ")
151        .to_string();
152    Some((after, prefix))
153}
154
155/// Добавляет `dep:name` в фичу. Возвращает `true`, если фича была создана.
156pub fn add_dep_to_feature(doc: &mut DocumentMut, feature: &str, name: &str) -> Result<bool> {
157    let table = features_table(doc)?;
158    let existed = table.contains_key(feature);
159    let entry = table.entry(feature).or_insert_with(|| value(Array::new()));
160    let arr = entry
161        .as_array_mut()
162        .ok_or_else(|| Error::Manifest(format!("фича `{feature}` не является массивом")))?;
163    let token = format!("dep:{name}");
164    if !arr.iter().any(|v| v.as_str() == Some(token.as_str())) {
165        match multiline_dep_insertion(arr) {
166            Some((index, prefix)) => {
167                let mut new_value = Value::from(token);
168                new_value.decor_mut().set_prefix(prefix);
169                // `Array::insert` перезаписывает декорации значения на `" "`/`""`,
170                // поэтому используем `insert_formatted`, сохраняющий префикс.
171                arr.insert_formatted(index, new_value);
172            }
173            None => arr.push(token),
174        }
175    }
176    Ok(!existed)
177}
178
179/// Меняет версию зависимости: строку `name = "0.1"` или поле `version`.
180pub fn set_dependency_version(doc: &mut DocumentMut, name: &str, version: &str) -> Result<()> {
181    let entry = dependencies_table(doc)?
182        .get_mut(name)
183        .ok_or_else(|| Error::Manifest(format!("зависимость `{name}` не найдена")))?;
184    if entry.is_str() {
185        *entry = Item::Value(Value::from(version));
186        return Ok(());
187    }
188    if let Some(table) = entry.as_table_like_mut() {
189        table.insert("version", Item::Value(Value::from(version)));
190        return Ok(());
191    }
192    Err(Error::Manifest(format!(
193        "не удалось обновить версию зависимости `{name}`"
194    )))
195}
196
197/// Есть ли фича с таким именем.
198pub fn has_feature(text: &str, feature: &str) -> Result<bool> {
199    let doc = parse(text)?;
200    Ok(doc
201        .get("features")
202        .and_then(Item::as_table_like)
203        .map(|t| t.contains_key(feature))
204        .unwrap_or(false))
205}
206
207/// Фичи, ссылающиеся на `dep:name`.
208pub fn features_containing(text: &str, name: &str) -> Result<Vec<String>> {
209    let doc = parse(text)?;
210    let token = format!("dep:{name}");
211    let mut out = Vec::new();
212    if let Some(features) = doc.get("features").and_then(Item::as_table_like) {
213        for (key, item) in features.iter() {
214            if let Some(arr) = item.as_array()
215                && arr.iter().any(|v| v.as_str() == Some(token.as_str()))
216            {
217                out.push(key.to_string());
218            }
219        }
220    }
221    out.sort();
222    Ok(out)
223}
224
225/// Фичи, включающие какой-либо компонент Light (`dep:light-*`).
226///
227/// Используется, чтобы подключать новый компонент в существующую
228/// агрегирующую фичу (например, `full`), а не плодить отдельные.
229pub fn features_with_light_component_dep(text: &str) -> Result<Vec<String>> {
230    let doc = parse(text)?;
231    let mut out = Vec::new();
232    if let Some(features) = doc.get("features").and_then(Item::as_table_like) {
233        for (key, item) in features.iter() {
234            if let Some(arr) = item.as_array()
235                && arr
236                    .iter()
237                    .any(|v| v.as_str().is_some_and(|s| s.starts_with("dep:light-")))
238            {
239                out.push(key.to_string());
240            }
241        }
242    }
243    out.sort();
244    Ok(out)
245}
246
247/// Удаляет зависимость `name` и её `dep:`-токены из всех фич.
248///
249/// Возвращает имена затронутых фич. Фичи, опустевшие в результате удаления
250/// (кроме `default`), также удаляются; изначально пустые фичи сохраняются.
251pub fn remove_dependency(doc: &mut DocumentMut, name: &str) -> Result<Vec<String>> {
252    if let Some(deps) = doc.get_mut("dependencies").and_then(Item::as_table_mut) {
253        deps.remove(name);
254    }
255    let mut touched = Vec::new();
256    let token = format!("dep:{name}");
257    let Some(features) = doc.get_mut("features").and_then(Item::as_table_mut) else {
258        return Ok(touched);
259    };
260    let keys: Vec<String> = features.iter().map(|(k, _)| k.to_string()).collect();
261    for key in keys {
262        let Some(arr) = features.get_mut(&key).and_then(Item::as_array_mut) else {
263            continue;
264        };
265        let before = arr.len();
266        let mut i = 0;
267        while i < arr.len() {
268            if arr.get(i).and_then(Value::as_str) == Some(token.as_str()) {
269                arr.remove(i);
270            } else {
271                i += 1;
272            }
273        }
274        if arr.len() != before {
275            touched.push(key.clone());
276        }
277        if arr.len() != before && arr.is_empty() && key != "default" {
278            features.remove(&key);
279        }
280    }
281    Ok(touched)
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn adds_workspace_optional_dependency() {
290        let mut doc =
291            parse("[package]\nname = \"p\"\n\n[dependencies]\nlight = \"0.1\"\n").unwrap();
292        set_workspace_optional(&mut doc, "light-fw-element-form").unwrap();
293        let out = doc.to_string();
294        assert!(out.contains("light-fw-element-form = { workspace = true, optional = true }"));
295        assert!(out.contains("light = \"0.1\""), "форматирование сохранено");
296    }
297
298    #[test]
299    fn adds_version_optional_dependency() {
300        let mut doc = parse("[package]\nname = \"p\"\n").unwrap();
301        set_version_optional(&mut doc, "light-fw-element-form", "0.1.0").unwrap();
302        assert!(
303            doc.to_string()
304                .contains("light-fw-element-form = { version = \"0.1.0\", optional = true }")
305        );
306    }
307
308    #[test]
309    fn adds_features_to_string_and_table_dependencies() {
310        // Фикстура после переименования обёртки: метакрейт теперь `light-fw`
311        // (старые проекты ещё могут использовать `light`). Тест проверяет, что
312        // фичи добавляются в оба существующих вида записи — строку и таблицу.
313        let mut doc = parse(
314            "[dependencies]\nlight-fw = \"0.6.1\"\nlight-fw-element-form = { version = \"0.6.1\", optional = true }\n",
315        )
316        .unwrap();
317        add_features_to_dependency(
318            &mut doc,
319            "light-fw",
320            &["elements-form".to_string(), "validation".to_string()],
321        )
322        .unwrap();
323        add_features_to_dependency(&mut doc, "light-fw-element-form", &["default".to_string()])
324            .unwrap();
325        // Повторная установка той же фичи не дублирует её.
326        add_features_to_dependency(&mut doc, "light-fw", &["validation".to_string()]).unwrap();
327        let out = doc.to_string();
328        assert!(
329            out.contains(
330                "light-fw = { version = \"0.6.1\", features = [\"elements-form\", \"validation\"] }"
331            ),
332            "{out}"
333        );
334        assert!(
335            out.contains("light-fw-element-form = { version = \"0.6.1\", optional = true, features = [\"default\"] }"),
336            "{out}"
337        );
338        assert_eq!(out.matches("\"validation\"").count(), 1, "{out}");
339    }
340
341    /// Регрессия: при добавлении фичи метакрейту `light add` **никогда** не
342    /// должен выключать дефолтные фичи (`default-features = false`). Иначе
343    /// `<window>` и прочие дефолтные элементы перестают работать. Поведение:
344    /// * строка `light-fw = "0.17"` → `{ version = "0.17", features = [...] }`
345    ///   **без** `default-features`;
346    /// * табличная запись, где пользователь сам прописал
347    ///   `default-features = false`, — сохранена как есть (только добавляются
348    ///   фичи), то есть мы не трогаем явный выбор пользователя.
349    #[test]
350    fn add_features_never_disables_default_features() {
351        // 1) Строковая зависимость без default-features → результат её не
352        //    содержит.
353        let mut doc = parse("[dependencies]\nlight-fw = \"0.17\"\n").unwrap();
354        add_features_to_dependency(&mut doc, "light-fw", &["elements-form".to_string()]).unwrap();
355        let out = doc.to_string();
356        assert!(
357            !out.contains("default-features"),
358            "string dep must not gain default-features: {out}"
359        );
360        assert!(
361            out.contains("light-fw = { version = \"0.17\", features = [\"elements-form\"] }"),
362            "{out}"
363        );
364
365        // 2) Табличная запись, где пользователь явно выключил дефолтные фичи —
366        //    это уважается; добавляется только фича.
367        let mut doc = parse(
368            "[dependencies]\nlight-fw = { version = \"0.17\", default-features = false, features = [\"a11y-winit\"] }\n",
369        )
370        .unwrap();
371        add_features_to_dependency(&mut doc, "light-fw", &["elements-form".to_string()]).unwrap();
372        let out = doc.to_string();
373        assert!(
374            out.contains("default-features = false"),
375            "user-written default-features = false must be preserved: {out}"
376        );
377        assert!(out.contains("\"elements-form\""), "feature appended: {out}");
378        assert!(
379            out.contains("\"a11y-winit\""),
380            "existing feature kept: {out}"
381        );
382    }
383
384    #[test]
385    fn adds_dep_to_existing_and_new_feature() {
386        let mut doc = parse("[features]\ndefault = [\"full\"]\nfull = [\"light/x\"]\n").unwrap();
387        assert!(!add_dep_to_feature(&mut doc, "full", "light-fw-element-form").unwrap());
388        assert!(add_dep_to_feature(&mut doc, "element-form", "light-fw-element-form").unwrap());
389        assert!(!add_dep_to_feature(&mut doc, "full", "light-fw-element-form").unwrap());
390        let out = doc.to_string();
391        assert!(out.contains("\"dep:light-fw-element-form\""));
392        assert!(out.contains("element-form = [\"dep:light-fw-element-form\"]"));
393    }
394
395    #[test]
396    fn adds_dep_to_multiline_feature_keeps_lines_and_comments() {
397        let text = "[features]\nfull = [\n    # формы\n    \"dep:light-fw-element-form\",\n]\n";
398        let mut doc = parse(text).unwrap();
399        add_dep_to_feature(&mut doc, "full", "light-widget-datepicker").unwrap();
400        assert_eq!(
401            doc.to_string(),
402            "[features]\nfull = [\n    # формы\n    \"dep:light-fw-element-form\",\n    \"dep:light-widget-datepicker\",\n]\n"
403        );
404    }
405
406    #[test]
407    fn add_then_remove_is_byte_perfect_in_multiline_feature() {
408        let text = "[features]\ndefault = [\"full\"]\nfull = [\n    \"dep:light-fw-element-form\",\n    \"dep:light-fw-behavior-validation\",\n]\n\
409                    minimal = [\n    \"std\",\n]\n";
410        let mut doc = parse(text).unwrap();
411        add_dep_to_feature(&mut doc, "full", "light-widget-datepicker").unwrap();
412        let added = doc.to_string();
413        assert!(
414            added.contains(
415                "    \"dep:light-fw-element-form\",\n    \"dep:light-fw-behavior-validation\",\n    \"dep:light-widget-datepicker\",\n]"
416            ),
417            "соседние dep: не склеиваются: {added}"
418        );
419        let mut doc = parse(&added).unwrap();
420        remove_dependency(&mut doc, "light-widget-datepicker").unwrap();
421        assert_eq!(doc.to_string(), text, "add+remove восстанавливает файл");
422    }
423
424    #[test]
425    fn removes_dependency_and_feature_tokens() {
426        let mut doc = parse(
427            "[dependencies]\nlight = \"0.1\"\nlight-fw-element-form = { workspace = true, optional = true }\n\n\
428             [features]\ndefault = [\"full\"]\nfull = [\"dep:light-fw-element-form\", \"light/x\"]\n\
429             element-form = [\"dep:light-fw-element-form\"]\n",
430        )
431        .unwrap();
432        let touched = remove_dependency(&mut doc, "light-fw-element-form").unwrap();
433        let mut sorted = touched.clone();
434        sorted.sort();
435        assert_eq!(sorted, vec!["element-form", "full"]);
436        let out = doc.to_string();
437        assert!(!out.contains("light-fw-element-form"));
438        assert!(!out.contains("dep:"));
439        assert!(out.contains("light/x"));
440        assert!(out.contains("default"));
441        assert!(out.contains("full"));
442    }
443
444    #[test]
445    fn sets_dependency_version_string_and_table() {
446        let mut doc = parse(
447            "[dependencies]\nlight-fw-element-form = \"0.0.1\"\nlight-fw = { version = \"0.1\", features = [\"x\"] }\n",
448        )
449        .unwrap();
450        set_dependency_version(&mut doc, "light-fw-element-form", "0.1.0").unwrap();
451        set_dependency_version(&mut doc, "light-fw", "0.2.0").unwrap();
452        let out = doc.to_string();
453        assert!(out.contains("light-fw-element-form = \"0.1.0\""));
454        assert!(out.contains("version = \"0.2.0\""));
455        assert!(
456            out.contains("features = [\"x\"]"),
457            "features сохранены: {out}"
458        );
459    }
460
461    #[test]
462    fn remove_keeps_preexisting_empty_features() {
463        let mut doc = parse(
464            "[dependencies]\nlight-fw-element-form = { workspace = true, optional = true }\n\n\
465             [features]\nminimal = []\nelement-form = [\"dep:light-fw-element-form\"]\n",
466        )
467        .unwrap();
468        let touched = remove_dependency(&mut doc, "light-fw-element-form").unwrap();
469        assert_eq!(touched, vec!["element-form"]);
470        let out = doc.to_string();
471        assert!(out.contains("minimal = []"), "пустая фича сохранена: {out}");
472        assert!(
473            !out.contains("element-form"),
474            "опустевшая из-за удаления фича удалена: {out}"
475        );
476    }
477
478    #[test]
479    fn features_containing_reports_matches() {
480        let text = "[features]\nfull = [\"dep:light-fw-element-form\"]\nminimal = []\n";
481        assert_eq!(
482            features_containing(text, "light-fw-element-form").unwrap(),
483            vec!["full"]
484        );
485        assert!(has_feature(text, "full").unwrap());
486        assert!(!has_feature(text, "nope").unwrap());
487    }
488}