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 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
48pub 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
78fn 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#[derive(Debug, PartialEq, Eq)]
126enum FeatureList {
127 Array(Range<usize>),
129 InlineEntry(usize),
131 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
277fn 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}