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        let mut doc = parse(
311            "[dependencies]\nlight = \"0.5.0\"\nlight-fw-element-form = { version = \"0.5.0\", optional = true }\n",
312        )
313        .unwrap();
314        add_features_to_dependency(
315            &mut doc,
316            "light",
317            &["elements-form".to_string(), "validation".to_string()],
318        )
319        .unwrap();
320        add_features_to_dependency(&mut doc, "light-fw-element-form", &["default".to_string()])
321            .unwrap();
322        add_features_to_dependency(&mut doc, "light", &["validation".to_string()]).unwrap();
323        let out = doc.to_string();
324        assert!(
325            out.contains(
326                "light = { version = \"0.5.0\", features = [\"elements-form\", \"validation\"] }"
327            ),
328            "{out}"
329        );
330        assert!(
331            out.contains("light-fw-element-form = { version = \"0.5.0\", optional = true, features = [\"default\"] }"),
332            "{out}"
333        );
334        assert_eq!(out.matches("\"validation\"").count(), 1, "{out}");
335    }
336
337    #[test]
338    fn adds_dep_to_existing_and_new_feature() {
339        let mut doc = parse("[features]\ndefault = [\"full\"]\nfull = [\"light/x\"]\n").unwrap();
340        assert!(!add_dep_to_feature(&mut doc, "full", "light-fw-element-form").unwrap());
341        assert!(add_dep_to_feature(&mut doc, "element-form", "light-fw-element-form").unwrap());
342        assert!(!add_dep_to_feature(&mut doc, "full", "light-fw-element-form").unwrap());
343        let out = doc.to_string();
344        assert!(out.contains("\"dep:light-fw-element-form\""));
345        assert!(out.contains("element-form = [\"dep:light-fw-element-form\"]"));
346    }
347
348    #[test]
349    fn adds_dep_to_multiline_feature_keeps_lines_and_comments() {
350        let text = "[features]\nfull = [\n    # формы\n    \"dep:light-fw-element-form\",\n]\n";
351        let mut doc = parse(text).unwrap();
352        add_dep_to_feature(&mut doc, "full", "light-widget-datepicker").unwrap();
353        assert_eq!(
354            doc.to_string(),
355            "[features]\nfull = [\n    # формы\n    \"dep:light-fw-element-form\",\n    \"dep:light-widget-datepicker\",\n]\n"
356        );
357    }
358
359    #[test]
360    fn add_then_remove_is_byte_perfect_in_multiline_feature() {
361        let text = "[features]\ndefault = [\"full\"]\nfull = [\n    \"dep:light-fw-element-form\",\n    \"dep:light-fw-behavior-validation\",\n]\n\
362                    minimal = [\n    \"std\",\n]\n";
363        let mut doc = parse(text).unwrap();
364        add_dep_to_feature(&mut doc, "full", "light-widget-datepicker").unwrap();
365        let added = doc.to_string();
366        assert!(
367            added.contains(
368                "    \"dep:light-fw-element-form\",\n    \"dep:light-fw-behavior-validation\",\n    \"dep:light-widget-datepicker\",\n]"
369            ),
370            "соседние dep: не склеиваются: {added}"
371        );
372        let mut doc = parse(&added).unwrap();
373        remove_dependency(&mut doc, "light-widget-datepicker").unwrap();
374        assert_eq!(doc.to_string(), text, "add+remove восстанавливает файл");
375    }
376
377    #[test]
378    fn removes_dependency_and_feature_tokens() {
379        let mut doc = parse(
380            "[dependencies]\nlight = \"0.1\"\nlight-fw-element-form = { workspace = true, optional = true }\n\n\
381             [features]\ndefault = [\"full\"]\nfull = [\"dep:light-fw-element-form\", \"light/x\"]\n\
382             element-form = [\"dep:light-fw-element-form\"]\n",
383        )
384        .unwrap();
385        let touched = remove_dependency(&mut doc, "light-fw-element-form").unwrap();
386        let mut sorted = touched.clone();
387        sorted.sort();
388        assert_eq!(sorted, vec!["element-form", "full"]);
389        let out = doc.to_string();
390        assert!(!out.contains("light-fw-element-form"));
391        assert!(!out.contains("dep:"));
392        assert!(out.contains("light/x"));
393        assert!(out.contains("default"));
394        assert!(out.contains("full"));
395    }
396
397    #[test]
398    fn sets_dependency_version_string_and_table() {
399        let mut doc = parse(
400            "[dependencies]\nlight-fw-element-form = \"0.0.1\"\nlight = { version = \"0.1\", features = [\"x\"] }\n",
401        )
402        .unwrap();
403        set_dependency_version(&mut doc, "light-fw-element-form", "0.1.0").unwrap();
404        set_dependency_version(&mut doc, "light", "0.2.0").unwrap();
405        let out = doc.to_string();
406        assert!(out.contains("light-fw-element-form = \"0.1.0\""));
407        assert!(out.contains("version = \"0.2.0\""));
408        assert!(
409            out.contains("features = [\"x\"]"),
410            "features сохранены: {out}"
411        );
412    }
413
414    #[test]
415    fn remove_keeps_preexisting_empty_features() {
416        let mut doc = parse(
417            "[dependencies]\nlight-fw-element-form = { workspace = true, optional = true }\n\n\
418             [features]\nminimal = []\nelement-form = [\"dep:light-fw-element-form\"]\n",
419        )
420        .unwrap();
421        let touched = remove_dependency(&mut doc, "light-fw-element-form").unwrap();
422        assert_eq!(touched, vec!["element-form"]);
423        let out = doc.to_string();
424        assert!(out.contains("minimal = []"), "пустая фича сохранена: {out}");
425        assert!(
426            !out.contains("element-form"),
427            "опустевшая из-за удаления фича удалена: {out}"
428        );
429    }
430
431    #[test]
432    fn features_containing_reports_matches() {
433        let text = "[features]\nfull = [\"dep:light-fw-element-form\"]\nminimal = []\n";
434        assert_eq!(
435            features_containing(text, "light-fw-element-form").unwrap(),
436            vec!["full"]
437        );
438        assert!(has_feature(text, "full").unwrap());
439        assert!(!has_feature(text, "nope").unwrap());
440    }
441}