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