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