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/// Точка вставки нового `dep:`-элемента в **многострочном** массиве и его
63/// префикс (стиль соседа). Возвращает `None` для однострочных массивов.
64///
65/// Элемент вставляется **после последнего `dep:`**, чтобы группы зависимостей
66/// не разъезжались и повторный `remove`+`add` давал byte-perfect roundtrip.
67fn multiline_dep_insertion(arr: &Array) -> Option<(usize, String)> {
68    if !arr.to_string().contains('\n') {
69        return None;
70    }
71    let after = arr
72        .iter()
73        .enumerate()
74        .filter(|(_, v)| v.as_str().is_some_and(|s| s.starts_with("dep:")))
75        .map(|(i, _)| i + 1)
76        .last()
77        .unwrap_or_else(|| arr.iter().count());
78    let neighbor = arr
79        .iter()
80        .nth(after.saturating_sub(1))
81        .or_else(|| arr.iter().last());
82    let prefix = neighbor
83        .and_then(|v| v.decor().prefix().and_then(|p| p.as_str()))
84        .map(|prefix| match prefix.rfind('\n') {
85            // Отбрасываем комментарии соседа: берём только перевод строки и
86            // отступ, иначе при вставке комментарий продублируется.
87            Some(index) => &prefix[index..],
88            None => prefix,
89        })
90        .filter(|prefix| prefix.contains('\n'))
91        .unwrap_or("\n    ")
92        .to_string();
93    Some((after, prefix))
94}
95
96/// Добавляет `dep:name` в фичу. Возвращает `true`, если фича была создана.
97pub fn add_dep_to_feature(doc: &mut DocumentMut, feature: &str, name: &str) -> Result<bool> {
98    let table = features_table(doc)?;
99    let existed = table.contains_key(feature);
100    let entry = table.entry(feature).or_insert_with(|| value(Array::new()));
101    let arr = entry
102        .as_array_mut()
103        .ok_or_else(|| Error::Manifest(format!("фича `{feature}` не является массивом")))?;
104    let token = format!("dep:{name}");
105    if !arr.iter().any(|v| v.as_str() == Some(token.as_str())) {
106        match multiline_dep_insertion(arr) {
107            Some((index, prefix)) => {
108                let mut new_value = Value::from(token);
109                new_value.decor_mut().set_prefix(prefix);
110                // `Array::insert` перезаписывает декорации значения на `" "`/`""`,
111                // поэтому используем `insert_formatted`, сохраняющий префикс.
112                arr.insert_formatted(index, new_value);
113            }
114            None => arr.push(token),
115        }
116    }
117    Ok(!existed)
118}
119
120/// Меняет версию зависимости: строку `name = "0.1"` или поле `version`.
121pub fn set_dependency_version(doc: &mut DocumentMut, name: &str, version: &str) -> Result<()> {
122    let entry = dependencies_table(doc)?
123        .get_mut(name)
124        .ok_or_else(|| Error::Manifest(format!("зависимость `{name}` не найдена")))?;
125    if entry.is_str() {
126        *entry = Item::Value(Value::from(version));
127        return Ok(());
128    }
129    if let Some(table) = entry.as_table_like_mut() {
130        table.insert("version", Item::Value(Value::from(version)));
131        return Ok(());
132    }
133    Err(Error::Manifest(format!(
134        "не удалось обновить версию зависимости `{name}`"
135    )))
136}
137
138/// Есть ли фича с таким именем.
139pub fn has_feature(text: &str, feature: &str) -> Result<bool> {
140    let doc = parse(text)?;
141    Ok(doc
142        .get("features")
143        .and_then(Item::as_table_like)
144        .map(|t| t.contains_key(feature))
145        .unwrap_or(false))
146}
147
148/// Фичи, ссылающиеся на `dep:name`.
149pub fn features_containing(text: &str, name: &str) -> Result<Vec<String>> {
150    let doc = parse(text)?;
151    let token = format!("dep:{name}");
152    let mut out = Vec::new();
153    if let Some(features) = doc.get("features").and_then(Item::as_table_like) {
154        for (key, item) in features.iter() {
155            if let Some(arr) = item.as_array()
156                && arr.iter().any(|v| v.as_str() == Some(token.as_str()))
157            {
158                out.push(key.to_string());
159            }
160        }
161    }
162    out.sort();
163    Ok(out)
164}
165
166/// Фичи, включающие какой-либо компонент Light (`dep:light-*`).
167///
168/// Используется, чтобы подключать новый компонент в существующую
169/// агрегирующую фичу (например, `full`), а не плодить отдельные.
170pub fn features_with_light_component_dep(text: &str) -> Result<Vec<String>> {
171    let doc = parse(text)?;
172    let mut out = Vec::new();
173    if let Some(features) = doc.get("features").and_then(Item::as_table_like) {
174        for (key, item) in features.iter() {
175            if let Some(arr) = item.as_array()
176                && arr
177                    .iter()
178                    .any(|v| v.as_str().is_some_and(|s| s.starts_with("dep:light-")))
179            {
180                out.push(key.to_string());
181            }
182        }
183    }
184    out.sort();
185    Ok(out)
186}
187
188/// Удаляет зависимость `name` и её `dep:`-токены из всех фич.
189///
190/// Возвращает имена затронутых фич. Фичи, опустевшие в результате удаления
191/// (кроме `default`), также удаляются; изначально пустые фичи сохраняются.
192pub fn remove_dependency(doc: &mut DocumentMut, name: &str) -> Result<Vec<String>> {
193    if let Some(deps) = doc.get_mut("dependencies").and_then(Item::as_table_mut) {
194        deps.remove(name);
195    }
196    let mut touched = Vec::new();
197    let token = format!("dep:{name}");
198    let Some(features) = doc.get_mut("features").and_then(Item::as_table_mut) else {
199        return Ok(touched);
200    };
201    let keys: Vec<String> = features.iter().map(|(k, _)| k.to_string()).collect();
202    for key in keys {
203        let Some(arr) = features.get_mut(&key).and_then(Item::as_array_mut) else {
204            continue;
205        };
206        let before = arr.len();
207        let mut i = 0;
208        while i < arr.len() {
209            if arr.get(i).and_then(Value::as_str) == Some(token.as_str()) {
210                arr.remove(i);
211            } else {
212                i += 1;
213            }
214        }
215        if arr.len() != before {
216            touched.push(key.clone());
217        }
218        if arr.len() != before && arr.is_empty() && key != "default" {
219            features.remove(&key);
220        }
221    }
222    Ok(touched)
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn adds_workspace_optional_dependency() {
231        let mut doc =
232            parse("[package]\nname = \"p\"\n\n[dependencies]\nlight = \"0.1\"\n").unwrap();
233        set_workspace_optional(&mut doc, "light-fw-element-form").unwrap();
234        let out = doc.to_string();
235        assert!(out.contains("light-fw-element-form = { workspace = true, optional = true }"));
236        assert!(out.contains("light = \"0.1\""), "форматирование сохранено");
237    }
238
239    #[test]
240    fn adds_version_optional_dependency() {
241        let mut doc = parse("[package]\nname = \"p\"\n").unwrap();
242        set_version_optional(&mut doc, "light-fw-element-form", "0.1.0").unwrap();
243        assert!(
244            doc.to_string()
245                .contains("light-fw-element-form = { version = \"0.1.0\", optional = true }")
246        );
247    }
248
249    #[test]
250    fn adds_dep_to_existing_and_new_feature() {
251        let mut doc = parse("[features]\ndefault = [\"full\"]\nfull = [\"light/x\"]\n").unwrap();
252        assert!(!add_dep_to_feature(&mut doc, "full", "light-fw-element-form").unwrap());
253        assert!(add_dep_to_feature(&mut doc, "element-form", "light-fw-element-form").unwrap());
254        assert!(!add_dep_to_feature(&mut doc, "full", "light-fw-element-form").unwrap());
255        let out = doc.to_string();
256        assert!(out.contains("\"dep:light-fw-element-form\""));
257        assert!(out.contains("element-form = [\"dep:light-fw-element-form\"]"));
258    }
259
260    #[test]
261    fn adds_dep_to_multiline_feature_keeps_lines_and_comments() {
262        let text = "[features]\nfull = [\n    # формы\n    \"dep:light-fw-element-form\",\n]\n";
263        let mut doc = parse(text).unwrap();
264        add_dep_to_feature(&mut doc, "full", "light-widget-datepicker").unwrap();
265        assert_eq!(
266            doc.to_string(),
267            "[features]\nfull = [\n    # формы\n    \"dep:light-fw-element-form\",\n    \"dep:light-widget-datepicker\",\n]\n"
268        );
269    }
270
271    #[test]
272    fn add_then_remove_is_byte_perfect_in_multiline_feature() {
273        let text = "[features]\ndefault = [\"full\"]\nfull = [\n    \"dep:light-fw-element-form\",\n    \"dep:light-fw-behavior-validation\",\n]\n\
274                    minimal = [\n    \"std\",\n]\n";
275        let mut doc = parse(text).unwrap();
276        add_dep_to_feature(&mut doc, "full", "light-widget-datepicker").unwrap();
277        let added = doc.to_string();
278        assert!(
279            added.contains(
280                "    \"dep:light-fw-element-form\",\n    \"dep:light-fw-behavior-validation\",\n    \"dep:light-widget-datepicker\",\n]"
281            ),
282            "соседние dep: не склеиваются: {added}"
283        );
284        let mut doc = parse(&added).unwrap();
285        remove_dependency(&mut doc, "light-widget-datepicker").unwrap();
286        assert_eq!(doc.to_string(), text, "add+remove восстанавливает файл");
287    }
288
289    #[test]
290    fn removes_dependency_and_feature_tokens() {
291        let mut doc = parse(
292            "[dependencies]\nlight = \"0.1\"\nlight-fw-element-form = { workspace = true, optional = true }\n\n\
293             [features]\ndefault = [\"full\"]\nfull = [\"dep:light-fw-element-form\", \"light/x\"]\n\
294             element-form = [\"dep:light-fw-element-form\"]\n",
295        )
296        .unwrap();
297        let touched = remove_dependency(&mut doc, "light-fw-element-form").unwrap();
298        let mut sorted = touched.clone();
299        sorted.sort();
300        assert_eq!(sorted, vec!["element-form", "full"]);
301        let out = doc.to_string();
302        assert!(!out.contains("light-fw-element-form"));
303        assert!(!out.contains("dep:"));
304        assert!(out.contains("light/x"));
305        assert!(out.contains("default"));
306        assert!(out.contains("full"));
307    }
308
309    #[test]
310    fn sets_dependency_version_string_and_table() {
311        let mut doc = parse(
312            "[dependencies]\nlight-fw-element-form = \"0.0.1\"\nlight = { version = \"0.1\", features = [\"x\"] }\n",
313        )
314        .unwrap();
315        set_dependency_version(&mut doc, "light-fw-element-form", "0.1.0").unwrap();
316        set_dependency_version(&mut doc, "light", "0.2.0").unwrap();
317        let out = doc.to_string();
318        assert!(out.contains("light-fw-element-form = \"0.1.0\""));
319        assert!(out.contains("version = \"0.2.0\""));
320        assert!(
321            out.contains("features = [\"x\"]"),
322            "features сохранены: {out}"
323        );
324    }
325
326    #[test]
327    fn remove_keeps_preexisting_empty_features() {
328        let mut doc = parse(
329            "[dependencies]\nlight-fw-element-form = { workspace = true, optional = true }\n\n\
330             [features]\nminimal = []\nelement-form = [\"dep:light-fw-element-form\"]\n",
331        )
332        .unwrap();
333        let touched = remove_dependency(&mut doc, "light-fw-element-form").unwrap();
334        assert_eq!(touched, vec!["element-form"]);
335        let out = doc.to_string();
336        assert!(out.contains("minimal = []"), "пустая фича сохранена: {out}");
337        assert!(
338            !out.contains("element-form"),
339            "опустевшая из-за удаления фича удалена: {out}"
340        );
341    }
342
343    #[test]
344    fn features_containing_reports_matches() {
345        let text = "[features]\nfull = [\"dep:light-fw-element-form\"]\nminimal = []\n";
346        assert_eq!(
347            features_containing(text, "light-fw-element-form").unwrap(),
348            vec!["full"]
349        );
350        assert!(has_feature(text, "full").unwrap());
351        assert!(!has_feature(text, "nope").unwrap());
352    }
353}