cargo_wizard/workspace/
config.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use crate::{Template, TemplateItemId, TomlValue};
5use anyhow::Context;
6use toml_edit::{table, value, Array, Document, Formatted, Value};
7
8/// Config stored in `.cargo/config.toml` file.
9#[derive(Debug, Clone)]
10pub struct CargoConfig {
11    path: PathBuf,
12    document: Document,
13}
14
15impl CargoConfig {
16    pub fn empty_from_manifest(manifest_path: &Path) -> Self {
17        Self {
18            path: config_path_from_manifest_path(manifest_path),
19            document: Default::default(),
20        }
21    }
22
23    pub fn from_path(path: &Path) -> anyhow::Result<Self> {
24        let config = std::fs::read_to_string(path).context("Cannot read config.toml file")?;
25        let document = config
26            .parse::<Document>()
27            .context("Cannot parse config.toml file")?;
28
29        Ok(Self {
30            document,
31            path: path.to_path_buf(),
32        })
33    }
34
35    pub fn get_text(&self) -> String {
36        self.document.to_string()
37    }
38
39    pub fn apply_template(mut self, template: &Template) -> anyhow::Result<Self> {
40        let rustflags: Vec<String> = template
41            .iter_items()
42            .filter_map(|(id, value)| {
43                let value = match value {
44                    TomlValue::String(value) => value.clone(),
45                    TomlValue::Int(value) => value.to_string(),
46                    TomlValue::Bool(value) => value.to_string(),
47                };
48                match id {
49                    TemplateItemId::TargetCpuInstructionSet => {
50                        Some(format!("-Ctarget-cpu={value}"))
51                    }
52                    TemplateItemId::FrontendThreads => Some(format!("-Zthreads={value}")),
53                    TemplateItemId::Linker => Some(format!("-Clink-arg=-fuse-ld={value}")),
54                    TemplateItemId::DebugInfo
55                    | TemplateItemId::Strip
56                    | TemplateItemId::Lto
57                    | TemplateItemId::CodegenUnits
58                    | TemplateItemId::Panic
59                    | TemplateItemId::OptimizationLevel
60                    | TemplateItemId::CodegenBackend
61                    | TemplateItemId::Incremental
62                    | TemplateItemId::SplitDebugInfo => None,
63                }
64            })
65            .collect();
66        if rustflags.is_empty() {
67            return Ok(self);
68        }
69
70        let build = self
71            .document
72            .entry("build")
73            .or_insert(table())
74            .as_table_mut()
75            .ok_or_else(|| anyhow::anyhow!("The build item in config.toml is not a table"))?;
76        let flags = build.entry("rustflags").or_insert(value(Array::new()));
77
78        let flag_map: HashMap<_, _> = rustflags
79            .iter()
80            .filter_map(|rustflag| {
81                let Some((key, value)) = rustflag.split_once('=') else {
82                    return None;
83                };
84                Some((key.to_string(), value.to_string()))
85            })
86            .collect();
87
88        // build.rustflags can be either a string or an array of strings
89        if let Some(array) = flags.as_array_mut() {
90            // Find flags with the same key (e.g. -Ckey=val) and replace their values, to avoid
91            // duplicating the keys.
92            for item in array.iter_mut() {
93                if let Some(val) = item.as_str() {
94                    if let Some((key, _)) = val.split_once('=') {
95                        if let Some(new_value) = flag_map.get(key) {
96                            let decor = item.decor().clone();
97                            let mut new_value =
98                                Value::String(Formatted::new(format!("{key}={new_value}")));
99                            *new_value.decor_mut() = decor;
100                            *item = new_value;
101                        }
102                    }
103                }
104            }
105
106            let existing_flags: HashSet<String> = array
107                .iter()
108                .filter_map(|v| v.as_str())
109                .map(|s| s.to_string())
110                .collect();
111            for arg in rustflags {
112                if !existing_flags.contains(&arg) {
113                    array.push(Value::String(Formatted::new(arg)));
114                }
115            }
116        } else if let Some(val) = flags.as_value_mut().filter(|v| v.is_str()) {
117            let flattened_flags = rustflags.join(" ");
118            let mut original_value = val.as_str().unwrap_or_default().to_string();
119            if !original_value.ends_with(' ') && !original_value.is_empty() {
120                original_value.push(' ');
121            }
122            original_value.push_str(&flattened_flags);
123            let decor = val.decor().clone();
124            *val = Value::String(Formatted::new(original_value));
125            *val.decor_mut() = decor;
126        } else {
127            return Err(anyhow::anyhow!(
128                "build.rustflags in config.toml is not a string or an array"
129            ));
130        }
131
132        Ok(self)
133    }
134
135    pub fn write(self) -> anyhow::Result<()> {
136        std::fs::create_dir_all(self.path.parent().expect("Missing config.toml parent"))
137            .context("Cannot create config.toml parent directory")?;
138        std::fs::write(&self.path, self.document.to_string())
139            .context("Cannot write config.toml manifest")?;
140        Ok(())
141    }
142}
143
144pub fn config_path_from_manifest_path(manifest_path: &Path) -> PathBuf {
145    manifest_path
146        .parent()
147        .map(|p| p.join(".cargo").join("config.toml"))
148        .expect("Manifest path has no parent")
149}
150
151#[cfg(test)]
152mod tests {
153    use std::str::FromStr;
154
155    use toml_edit::Document;
156
157    use crate::template::TemplateBuilder;
158    use crate::workspace::manifest::BuiltinProfile;
159    use crate::{CargoConfig, Template, TemplateItemId, TomlValue};
160
161    #[test]
162    fn create_rustflags() {
163        let template = create_template(&[(TemplateItemId::TargetCpuInstructionSet, "native")]);
164        let config = create_empty_config().apply_template(&template).unwrap();
165        insta::assert_snapshot!(config.get_text(), @r###"
166        [build]
167        rustflags = ["-Ctarget-cpu=native"]
168        "###);
169    }
170
171    #[test]
172    fn append_to_array_rustflags() {
173        let template = create_template(&[(TemplateItemId::TargetCpuInstructionSet, "native")]);
174        let config = create_config(
175            r#"
176[build]
177rustflags = ["-Cbar=foo"]
178"#,
179        );
180        let config = config.apply_template(&template).unwrap();
181        insta::assert_snapshot!(config.get_text(), @r###"
182    [build]
183    rustflags = ["-Cbar=foo", "-Ctarget-cpu=native"]
184    "###);
185    }
186
187    #[test]
188    fn ignore_existing_entry() {
189        let template = create_template(&[(TemplateItemId::TargetCpuInstructionSet, "foo")]);
190        let config = create_config(
191            r#"
192[build]
193rustflags = ["-Ctarget-cpu=foo"]
194"#,
195        );
196        let config = config.apply_template(&template).unwrap();
197        insta::assert_snapshot!(config.get_text(), @r###"
198        [build]
199        rustflags = ["-Ctarget-cpu=foo"]
200        "###);
201    }
202
203    #[test]
204    fn append_to_empty_string_rustflags() {
205        let template = create_template(&[(TemplateItemId::TargetCpuInstructionSet, "native")]);
206        let config = create_config(
207            r#"
208[build]
209rustflags = ""
210"#,
211        );
212        let config = config.apply_template(&template).unwrap();
213        insta::assert_snapshot!(config.get_text(), @r###"
214            [build]
215            rustflags = "-Ctarget-cpu=native"
216            "###);
217    }
218
219    #[test]
220    fn append_to_string_rustflags() {
221        let template = create_template(&[(TemplateItemId::TargetCpuInstructionSet, "native")]);
222        let config = create_config(
223            r#"
224[build]
225rustflags = "-Cfoo=bar"
226"#,
227        );
228        let config = config.apply_template(&template).unwrap();
229        insta::assert_snapshot!(config.get_text(), @r###"
230        [build]
231        rustflags = "-Cfoo=bar -Ctarget-cpu=native"
232        "###);
233    }
234
235    #[test]
236    fn append_to_string_rustflags_keep_formatting() {
237        let template = create_template(&[(TemplateItemId::TargetCpuInstructionSet, "native")]);
238        let config = create_config(
239            r#"
240[build]
241rustflags = "-Cfoo=bar" # Foo
242"#,
243        );
244        let config = config.apply_template(&template).unwrap();
245        insta::assert_snapshot!(config.get_text(), @r###"
246        [build]
247        rustflags = "-Cfoo=bar -Ctarget-cpu=native" # Foo
248        "###);
249    }
250
251    #[test]
252    fn replace_rustflag_value() {
253        let template = create_template(&[(TemplateItemId::TargetCpuInstructionSet, "native")]);
254        let config = create_config(
255            r#"
256[build]
257rustflags = [
258    # Foo
259    "-Ctarget-cpu=foo", # Foo
260    "-Cbar=baz", # Foo
261]
262"#,
263        );
264        let config = config.apply_template(&template).unwrap();
265        insta::assert_snapshot!(config.get_text(), @r###"
266
267        [build]
268        rustflags = [
269            # Foo
270            "-Ctarget-cpu=native", # Foo
271            "-Cbar=baz", # Foo
272        ]
273        "###);
274    }
275
276    fn create_template(items: &[(TemplateItemId, &str)]) -> Template {
277        let mut builder = TemplateBuilder::new(BuiltinProfile::Release);
278        for (id, value) in items {
279            builder = builder.item(*id, TomlValue::String(value.to_string()));
280        }
281        builder.build()
282    }
283
284    fn create_config(text: &str) -> CargoConfig {
285        CargoConfig {
286            path: Default::default(),
287            document: Document::from_str(text).unwrap(),
288        }
289    }
290
291    fn create_empty_config() -> CargoConfig {
292        CargoConfig {
293            path: Default::default(),
294            document: Default::default(),
295        }
296    }
297}