Skip to main content

sofie_cli/
feature_command.rs

1use std::fmt;
2use std::fs;
3use std::ops::Range;
4use std::path::{Path, PathBuf};
5use std::process::Stdio;
6
7use serde_json::Value;
8
9use crate::cargo;
10use crate::gen_command::{GenError, io_error};
11
12const FACADE_PACKAGE: &str = "sofie-framework";
13
14#[derive(Clone, Copy, PartialEq, Eq, Debug)]
15pub enum FeatureChange {
16    Add,
17    Remove,
18}
19
20pub struct FeatureEdit {
21    pub manifest: PathBuf,
22    pub feature: String,
23    pub change: FeatureChange,
24    /// False when the manifest already said so.
25    pub changed: bool,
26    pub features: Vec<String>,
27}
28
29impl fmt::Display for FeatureEdit {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        let verb = match (self.change, self.changed) {
32            (FeatureChange::Add, true) => "added",
33            (FeatureChange::Remove, true) => "removed",
34            (FeatureChange::Add, false) => "already had",
35            (FeatureChange::Remove, false) => "did not have",
36        };
37        write!(
38            formatter,
39            "{}: the `sofie` dependency {verb} the feature `{}`; its features are now [{}]",
40            self.manifest.display(),
41            self.feature,
42            self.features.join(", ")
43        )
44    }
45}
46
47/// Edits only the features array of the `sofie` dependency in `app/Cargo.toml`, as text, so
48/// the manifest keeps its comments and layout.
49pub fn change_feature(
50    root: &Path,
51    feature: &str,
52    change: FeatureChange,
53) -> Result<FeatureEdit, GenError> {
54    let manifest = cargo::app_manifest(root);
55    let known = facade_features(&manifest)?;
56    if !known.iter().any(|known| known == feature) {
57        return Err(GenError::Usage(format!(
58            "`sofie` has no feature `{feature}`; its features are {}",
59            known.join(", ")
60        )));
61    }
62    let text = fs::read_to_string(&manifest).map_err(|source| io_error(&manifest, source))?;
63    let refused = |message: String| GenError::Usage(format!("{}: {message}", manifest.display()));
64    let edited = edited_manifest(&text, feature, change).map_err(refused)?;
65    if edited.changed {
66        fs::write(&manifest, &edited.text).map_err(|source| io_error(&manifest, source))?;
67    }
68    Ok(FeatureEdit {
69        manifest,
70        feature: feature.to_owned(),
71        change,
72        changed: edited.changed,
73        features: edited.features,
74    })
75}
76
77/// Every feature the facade declares, sorted; `default` is no feature to add.
78fn facade_features(manifest: &Path) -> Result<Vec<String>, GenError> {
79    let output = cargo::command()
80        .args(["metadata", "--format-version", "1", "--manifest-path"])
81        .arg(manifest)
82        .stdin(Stdio::null())
83        .output()
84        .map_err(|source| io_error(manifest, source))?;
85    if !output.status.success() {
86        return Err(GenError::App(format!(
87            "`cargo metadata` for {} failed: {}",
88            manifest.display(),
89            String::from_utf8_lossy(&output.stderr).trim()
90        )));
91    }
92    let metadata: Value = serde_json::from_slice(&output.stdout)
93        .map_err(|error| GenError::App(format!("`cargo metadata` answered no JSON: {error}")))?;
94    let facade = metadata["packages"]
95        .as_array()
96        .into_iter()
97        .flatten()
98        .find(|package| package["name"] == FACADE_PACKAGE)
99        .ok_or_else(|| {
100            GenError::Usage(format!(
101                "{} does not depend on `{FACADE_PACKAGE}`, so it has no `sofie` features",
102                manifest.display()
103            ))
104        })?;
105    let mut features: Vec<String> = facade["features"]
106        .as_object()
107        .into_iter()
108        .flat_map(|features| features.keys())
109        .filter(|name| *name != "default")
110        .cloned()
111        .collect();
112    features.sort();
113    Ok(features)
114}
115
116#[derive(Debug, PartialEq, Eq)]
117struct EditedManifest {
118    text: String,
119    changed: bool,
120    features: Vec<String>,
121}
122
123/// Where the `sofie` dependency of `[dependencies]` lists its features, or where a list goes.
124#[derive(Debug, PartialEq, Eq)]
125enum FeatureList {
126    /// From `[` through `]`.
127    Array(Range<usize>),
128    /// Into an inline table before its closing `}`.
129    InlineEntry(usize),
130    /// A line of its own after this offset, with this key.
131    Line(usize, &'static str),
132}
133
134fn edited_manifest(
135    text: &str,
136    feature: &str,
137    change: FeatureChange,
138) -> Result<EditedManifest, String> {
139    let list = feature_list(text)?;
140    let listed = match &list {
141        FeatureList::Array(range) => array_features(&text[range.clone()])?,
142        _ => Vec::new(),
143    };
144    let present = listed.iter().any(|listed| listed == feature);
145    let features: Vec<String> = match change {
146        FeatureChange::Add if present => listed,
147        FeatureChange::Add => listed.into_iter().chain([feature.to_owned()]).collect(),
148        FeatureChange::Remove if !present => {
149            return Err(format!(
150                "the `sofie` dependency does not list the feature `{feature}`; one on by default \
151                 goes with `default-features = false` and the defaults it keeps listed"
152            ));
153        }
154        FeatureChange::Remove => listed.into_iter().filter(|name| name != feature).collect(),
155    };
156    let changed = present != (change == FeatureChange::Add);
157    let mut edited = text.to_owned();
158    match list {
159        FeatureList::Array(range) => {
160            let array = rendered_array(&text[range.clone()], &features);
161            edited.replace_range(range, &array);
162        }
163        FeatureList::InlineEntry(at) => {
164            let entry = format!(", features = {}", rendered_array("[]", &features));
165            edited.insert_str(at, &entry);
166        }
167        FeatureList::Line(at, key) => {
168            let line = format!("\n{key} = {}", rendered_array("[]", &features));
169            edited.insert_str(at, &line);
170        }
171    }
172    Ok(EditedManifest {
173        text: edited,
174        changed,
175        features,
176    })
177}
178
179fn feature_list(text: &str) -> Result<FeatureList, String> {
180    let mut section = "";
181    let mut dotted_end = None;
182    let mut offset = 0;
183    for line in text.split_inclusive('\n') {
184        let start = offset;
185        offset += line.len();
186        let content = without_comment(line).trim();
187        if content.starts_with('[') {
188            if section == "dependencies.sofie" {
189                break;
190            }
191            section = content.trim_matches(['[', ']']).trim();
192            if section == "dependencies.sofie" {
193                dotted_end = Some((start + line.trim_end().len(), "features"));
194            }
195            continue;
196        }
197        if section == "dependencies.sofie" && key_of(content) == Some("features") {
198            return array_from(text, start + line.find('[').unwrap_or(line.len()));
199        }
200        if section != "dependencies" {
201            continue;
202        }
203        match key_of(content) {
204            Some("sofie.features") => {
205                return array_from(text, start + line.find('[').unwrap_or(line.len()));
206            }
207            Some(key) if key.starts_with("sofie.") => {
208                dotted_end = Some((start + line.trim_end().len(), "sofie.features"));
209            }
210            Some("sofie") => return inline_list(line, start),
211            _ => {}
212        }
213    }
214    dotted_end
215        .map(|(at, key)| FeatureList::Line(at, key))
216        .ok_or_else(|| "its `[dependencies]` has no `sofie` dependency".to_owned())
217}
218
219fn key_of(content: &str) -> Option<&str> {
220    let (key, _) = content.split_once('=')?;
221    Some(key.trim())
222}
223
224/// Feature names and paths hold no `#`, so the first one starts a comment.
225fn without_comment(line: &str) -> &str {
226    line.split_once('#').map_or(line, |(before, _)| before)
227}
228
229fn inline_list(line: &str, start: usize) -> Result<FeatureList, String> {
230    let content = without_comment(line);
231    let Some(close) = content.rfind('}') else {
232        return Err(
233            "the `sofie` dependency is a version string; write it as an inline table, \
234             `sofie = { version = \"…\" }`, to give it features"
235                .to_owned(),
236        );
237    };
238    let Some(key) = features_key(content) else {
239        return Ok(FeatureList::InlineEntry(
240            start + content[..close].trim_end().len(),
241        ));
242    };
243    let open = content[key..].find('[').map(|found| key + found);
244    let close = open.and_then(|open| content[open..].find(']').map(|found| open + found));
245    match (open, close) {
246        (Some(open), Some(close)) => Ok(FeatureList::Array(start + open..start + close + 1)),
247        _ => Err("the `sofie` dependency's `features` is no array on one line".to_owned()),
248    }
249}
250
251/// Not the `features` of `default-features`.
252fn features_key(content: &str) -> Option<usize> {
253    content
254        .match_indices("features")
255        .map(|(at, _)| at)
256        .find(|at| {
257            let before = content[..*at].trim_end();
258            let after = content[at + "features".len()..].trim_start();
259            (before.ends_with('{') || before.ends_with(',')) && after.starts_with('=')
260        })
261}
262
263fn array_from(text: &str, open: usize) -> Result<FeatureList, String> {
264    let close = text
265        .get(open..)
266        .filter(|rest| rest.starts_with('['))
267        .and_then(|rest| rest.find(']'))
268        .ok_or("the `sofie` dependency's `features` is no array")?;
269    Ok(FeatureList::Array(open..open + close + 1))
270}
271
272fn array_features(array: &str) -> Result<Vec<String>, String> {
273    let inner = &array[1..array.len() - 1];
274    if inner.contains('#') {
275        return Err(
276            "a comment sits inside the `sofie` dependency's features; edit that list by hand"
277                .to_owned(),
278        );
279    }
280    inner
281        .split(',')
282        .map(str::trim)
283        .filter(|item| !item.is_empty())
284        .map(|item| {
285            item.strip_prefix('"')
286                .and_then(|item| item.strip_suffix('"'))
287                .map(str::to_owned)
288                .ok_or_else(|| format!("the feature `{item}` is no quoted string"))
289        })
290        .collect()
291}
292
293/// An array written over several lines stays so, one feature a line.
294fn rendered_array(original: &str, features: &[String]) -> String {
295    let quoted: Vec<String> = features
296        .iter()
297        .map(|feature| format!("\"{feature}\""))
298        .collect();
299    if quoted.is_empty() || !original.contains('\n') {
300        return format!("[{}]", quoted.join(", "));
301    }
302    let inner = &original[1..original.len() - 1];
303    let indent = inner
304        .lines()
305        .find(|line| !line.trim().is_empty())
306        .map_or("    ", |line| &line[..line.len() - line.trim_start().len()]);
307    let closing = inner.rsplit_once('\n').map_or("", |(_, last)| last);
308    let separator = format!(",\n{indent}");
309    format!("[\n{indent}{},\n{closing}]", quoted.join(&separator))
310}
311
312#[cfg(test)]
313mod tests {
314    use super::{FeatureChange, edited_manifest};
315
316    fn edited(text: &str, feature: &str, change: FeatureChange) -> String {
317        edited_manifest(text, feature, change).unwrap().text
318    }
319
320    #[test]
321    fn an_inline_dependency_gains_and_loses_a_feature_and_keeps_its_comments() {
322        let manifest = "[dependencies]\n# The framework.\nsofie = { workspace = true, features = \
323                        [\"engine\", \"postgres\"] } # pinned\n\n[dev-dependencies]\nsofie = { \
324                        workspace = true, features = [\"capture\"] }\n";
325
326        let added = edited(manifest, "i18n", FeatureChange::Add);
327        let removed = edited(&added, "engine", FeatureChange::Remove);
328
329        assert_eq!(
330            added,
331            manifest.replace("\"postgres\"]", "\"postgres\", \"i18n\"]")
332        );
333        assert_eq!(
334            removed,
335            manifest.replace("[\"engine\", \"postgres\"]", "[\"postgres\", \"i18n\"]")
336        );
337        assert!(
338            !edited_manifest(manifest, "engine", FeatureChange::Add)
339                .unwrap()
340                .changed
341        );
342    }
343
344    #[test]
345    fn a_dependency_without_features_gets_a_list_in_its_own_form() {
346        let inline = "[dependencies]\nsofie = { workspace = true }\n";
347        let dotted = "[dependencies]\nsofie.workspace = true\nserde = \"1\"\n";
348        let table = "[dependencies.sofie]\nworkspace = true\n";
349
350        assert_eq!(
351            edited(inline, "i18n", FeatureChange::Add),
352            "[dependencies]\nsofie = { workspace = true, features = [\"i18n\"] }\n"
353        );
354        assert_eq!(
355            edited(dotted, "i18n", FeatureChange::Add),
356            "[dependencies]\nsofie.workspace = true\nsofie.features = [\"i18n\"]\nserde = \"1\"\n"
357        );
358        assert_eq!(
359            edited(table, "i18n", FeatureChange::Add),
360            "[dependencies.sofie]\nfeatures = [\"i18n\"]\nworkspace = true\n"
361        );
362    }
363
364    #[test]
365    fn a_list_over_several_lines_stays_one_feature_a_line() {
366        let manifest = "[dependencies.sofie]\nworkspace = true\nfeatures = [\n    \"engine\",\n    \
367                        \"postgres\"\n]\n";
368
369        assert_eq!(
370            edited(manifest, "i18n", FeatureChange::Add),
371            "[dependencies.sofie]\nworkspace = true\nfeatures = [\n    \"engine\",\n    \
372             \"postgres\",\n    \"i18n\",\n]\n"
373        );
374    }
375
376    #[test]
377    fn what_cannot_be_edited_safely_is_refused() {
378        let commented = "[dependencies]\nsofie.features = [\n  \"engine\", # the screens\n]\n";
379        let missing = "[dependencies]\nserde = \"1\"\n";
380        let version = "[dependencies]\nsofie = \"0.1\"\n";
381        let listed =
382            "[dependencies]\nsofie = { default-features = false, features = [\"engine\"] }\n";
383
384        let refusal = |text, change| edited_manifest(text, "i18n", change).unwrap_err();
385
386        assert!(refusal(commented, FeatureChange::Add).contains("by hand"));
387        assert!(refusal(missing, FeatureChange::Add).contains("no `sofie` dependency"));
388        assert!(refusal(version, FeatureChange::Add).contains("inline table"));
389        assert!(refusal(listed, FeatureChange::Remove).contains("does not list"));
390    }
391}