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 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
47pub 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
77fn 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#[derive(Debug, PartialEq, Eq)]
125enum FeatureList {
126 Array(Range<usize>),
128 InlineEntry(usize),
130 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
224fn 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
251fn 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
293fn 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}