Skip to main content

knope_versioning/versioned_file/
cargo.rs

1#[cfg(feature = "miette")]
2use miette::Diagnostic;
3use relative_path::RelativePathBuf;
4use thiserror::Error;
5use toml_edit::{DocumentMut, TomlError, value};
6
7use crate::{Action, semver::Version};
8
9#[derive(Clone, Debug)]
10pub struct Cargo {
11    pub(super) path: RelativePathBuf,
12    pub(crate) document: DocumentMut,
13    diff: Vec<String>,
14}
15
16impl Cargo {
17    /// Parses the raw TOML to determine the package version.
18    ///
19    /// # Errors
20    ///
21    /// If the TOML is invalid or missing a required property.
22    pub fn new(path: RelativePathBuf, toml: &str) -> Result<Self, Error> {
23        let document: DocumentMut = toml.parse().map_err(|source| Error::Toml {
24            source: Box::new(source),
25            path: path.clone(),
26        })?;
27        Ok(Self {
28            path,
29            document,
30            diff: Vec::new(),
31        })
32    }
33
34    pub(super) fn get_version(&self) -> Result<Version, Error> {
35        self.document
36            .get("package")
37            .and_then(|package| package.get("version")?.as_str())
38            .or_else(|| {
39                self.document
40                    .get("workspace")?
41                    .get("package")?
42                    .get("version")?
43                    .as_str()
44            })
45            .ok_or_else(|| Error::MissingRequiredProperties {
46                property: "`package.version` or `workspace.package.version`",
47                path: self.path.clone(),
48            })?
49            .parse()
50            .map_err(Error::Semver)
51    }
52
53    #[must_use]
54    pub(super) fn set_version(mut self, new_version: &Version, dependency: Option<&str>) -> Self {
55        let diff = if let Some(dependency) = dependency {
56            if let Some(dep) = self
57                .document
58                .get_mut("dependencies")
59                .and_then(|deps| deps.get_mut(dependency))
60            {
61                write_version_to_dep(dep, new_version);
62            }
63            if let Some(dep) = self
64                .document
65                .get_mut("dev-dependencies")
66                .and_then(|deps| deps.get_mut(dependency))
67            {
68                write_version_to_dep(dep, new_version);
69            }
70            if let Some(dep) = self
71                .document
72                .get_mut("workspace")
73                .and_then(|workspace| workspace.get_mut("dependencies")?.get_mut(dependency))
74            {
75                write_version_to_dep(dep, new_version);
76            }
77            format!("{dependency}.version = {new_version}")
78        } else {
79            if let Some(version) = self
80                .document
81                .get_mut("package")
82                .and_then(|package| package.get_mut("version"))
83                && version.is_str()
84            // Skip package.version.workspace = true
85            {
86                *version = value(new_version.to_string());
87            } else if let Some(version) = self
88                .document
89                .get_mut("workspace")
90                .and_then(|ws| ws.get_mut("package")?.get_mut("version"))
91            {
92                *version = value(new_version.to_string());
93            }
94            format!("version = {new_version}")
95        };
96        self.diff.push(diff);
97        self
98    }
99
100    pub(super) fn write(self) -> Option<Action> {
101        if self.diff.is_empty() {
102            return None;
103        }
104        Some(Action::WriteToFile {
105            path: self.path,
106            content: self.document.to_string(),
107            diff: self.diff.join(", "),
108        })
109    }
110}
111
112#[must_use]
113pub fn name_from_document(document: &DocumentMut) -> Option<&str> {
114    document
115        .get("package")
116        .and_then(|package| package.get("name")?.as_str())
117}
118
119#[must_use]
120pub fn contains_dependency(document: &DocumentMut, dependency: &str) -> bool {
121    document
122        .get("dependencies")
123        .and_then(|deps| deps.get(dependency))
124        .is_some()
125        || document
126            .get("dev-dependencies")
127            .and_then(|deps| deps.get(dependency))
128            .is_some()
129        || document
130            .get("workspace")
131            .and_then(|workspace| workspace.get("dependencies")?.get(dependency))
132            .is_some()
133}
134
135#[cfg(test)]
136mod test_contains_dependency {
137    use super::*;
138    #[test]
139    fn basic_dependency() {
140        let content = r#"
141        [package]
142        name = "tester"
143        version = "1.2.3-rc.0"
144
145        [dependencies]
146        knope-versioning = "0.1.0"
147        "#;
148
149        let document: DocumentMut = content.parse().expect("valid toml");
150        assert!(contains_dependency(&document, "knope-versioning"));
151    }
152
153    #[test]
154    fn inline_table_dependency() {
155        let content = r#"
156        [package]
157        name = "tester"
158        version = "1.2.3-rc.0"
159
160        [dependencies]
161        knope-versioning = { version = "0.1.0" }
162        "#;
163
164        let document: DocumentMut = content.parse().expect("valid toml");
165        assert!(contains_dependency(&document, "knope-versioning"));
166    }
167
168    #[test]
169    fn table_dependency() {
170        let content = r#"
171        [package]
172        name = "tester"
173        version = "1.2.3-rc.0"
174
175        [dependencies.knope-versioning]
176        path = "../knope-versioning"
177        version = "0.1.0"
178        "#;
179
180        let document: DocumentMut = content.parse().expect("valid toml");
181        assert!(contains_dependency(&document, "knope-versioning"));
182    }
183
184    #[test]
185    fn dev_dependency() {
186        let content = r#"
187        [package]
188        name = "tester"
189        version = "1.2.3-rc.0"
190
191        [dev-dependencies]
192        knope-versioning = "0.1.0"
193        "#;
194
195        let document: DocumentMut = content.parse().expect("valid toml");
196        assert!(contains_dependency(&document, "knope-versioning"));
197    }
198
199    #[test]
200    fn workspace_dependency() {
201        let content = r#"
202        [package]
203        name = "tester"
204        version = "1.2.3-rc.0"
205
206        [workspace.dependencies]
207        knope-versioning = "0.1.0"
208        "#;
209
210        let document: DocumentMut = content.parse().expect("valid toml");
211        assert!(contains_dependency(&document, "knope-versioning"));
212    }
213}
214
215fn write_version_to_dep(dep: &mut toml_edit::Item, version: &Version) {
216    if let Some(table) = dep.as_table_mut() {
217        table.insert("version", value(version.to_string()));
218    } else if let Some(table) = dep.as_inline_table_mut() {
219        table.insert("version", version.to_string().into());
220    } else if let Some(value) = dep.as_value_mut() {
221        *value = version.to_string().into();
222    }
223}
224
225#[derive(Debug, Error)]
226#[cfg_attr(feature = "miette", derive(Diagnostic))]
227pub enum Error {
228    #[error("Invalid TOML in {path}: {source}")]
229    #[cfg_attr(feature = "miette", diagnostic(code(knope_versioning::cargo::toml),))]
230    Toml {
231        path: RelativePathBuf,
232        #[source]
233        source: Box<TomlError>,
234    },
235    #[error("{path} was missing required property {property}")]
236    #[cfg_attr(
237        feature = "miette",
238        diagnostic(
239            code(knope_versioning::cargo::missing_property),
240            url("https://knope.tech/reference/config-file/packages/#cargotoml")
241        )
242    )]
243    MissingRequiredProperties {
244        path: RelativePathBuf,
245        property: &'static str,
246    },
247    #[error("{path} does not contain dependency {dependency}")]
248    #[cfg_attr(
249        feature = "miette",
250        diagnostic(
251            code(knope_versioning::cargo::missing_dependency),
252            url("https://knope.tech/reference/config-file/packages/#cargotoml")
253        )
254    )]
255    MissingDependency {
256        path: RelativePathBuf,
257        dependency: String,
258    },
259    #[error(transparent)]
260    #[cfg_attr(feature = "miette", diagnostic(transparent))]
261    Semver(#[from] crate::semver::Error),
262}
263
264#[derive(Clone, Debug, Eq, PartialEq)]
265pub struct Toml {
266    package_name: String,
267    version: Version,
268    version_path: String,
269}
270
271#[cfg(test)]
272#[allow(clippy::unwrap_used)]
273mod tests {
274    use std::str::FromStr;
275
276    use pretty_assertions::assert_eq;
277
278    use super::*;
279    use crate::Action;
280
281    #[test]
282    fn set_package_version() {
283        let content = r#"
284        [package]
285        name = "tester"
286        version = "0.1.0-rc.0"
287        
288        [dependencies]
289        knope-versioning = "0.1.0"
290        "#;
291
292        let new = Cargo::new(RelativePathBuf::from("beep/Cargo.toml"), content).unwrap();
293
294        let new_version = "1.2.3-rc.4";
295        let expected = content.replace("0.1.0-rc.0", new_version);
296        let new = new.set_version(&Version::from_str(new_version).unwrap(), None);
297
298        assert_eq!(new.document.to_string(), expected);
299    }
300
301    #[test]
302    fn dependencies() {
303        let content = r#"
304        [package]
305        name = "tester"
306        version = "1.2.3-rc.0"
307        
308        [dependencies]
309        knope-versioning = "0.1.0"
310        other = {path = "../other"}
311        complex-requirement = "3.*"
312        complex-requirement-in-object = { version = "1.2.*" }
313        
314        [dev-dependencies]
315        knope-versioning = {path = "../blah", version = "0.1.0" }
316        
317        [workspace.dependencies]
318        knope-versioning = "0.1.0"
319        "#;
320
321        let new = Cargo::new(RelativePathBuf::from("beep/Cargo.toml"), content).unwrap();
322
323        let new = new.set_version(
324            &Version::from_str("0.2.0").unwrap(),
325            Some("knope-versioning"),
326        );
327        let expected = content.replace("0.1.0", "0.2.0");
328        let new = new.set_version(
329            &Version::from_str("2.0.0").unwrap(),
330            Some("complex-requirement-in-object"),
331        );
332        let expected = expected.replace("1.2.*", "2.0.0");
333
334        let expected = Action::WriteToFile {
335            path: RelativePathBuf::from("beep/Cargo.toml"),
336            content: expected,
337            diff: "knope-versioning.version = 0.2.0, complex-requirement-in-object.version = 2.0.0"
338                .to_string(),
339        };
340
341        assert_eq!(new.write().expect("diff to write"), expected);
342    }
343}