knope_versioning/versioned_file/
mod.rs

1use std::{fmt::Debug, path::PathBuf};
2
3use cargo::Cargo;
4pub use go_mod::{GoMod, GoVersioning};
5use package_json::PackageJson;
6use pubspec::PubSpec;
7use pyproject::PyProject;
8use relative_path::RelativePathBuf;
9use serde::{Serialize, Serializer};
10
11use crate::{
12    action::ActionSet::{Single, Two},
13    semver::Version,
14    versioned_file::cargo_lock::CargoLock,
15    Action,
16};
17
18pub mod cargo;
19mod cargo_lock;
20mod go_mod;
21mod package_json;
22mod pubspec;
23mod pyproject;
24
25#[derive(Clone, Debug)]
26pub enum VersionedFile {
27    Cargo(Cargo),
28    CargoLock(CargoLock),
29    PubSpec(PubSpec),
30    GoMod(GoMod),
31    PackageJson(PackageJson),
32    PyProject(PyProject),
33}
34
35impl VersionedFile {
36    /// Create a new `VersionedFile`
37    ///
38    /// # Errors
39    ///
40    /// Depends on the format.
41    /// If the content doesn't match the expected format, an error is returned.
42    pub fn new<S: AsRef<str> + Debug>(
43        config: &Config,
44        content: String,
45        git_tags: &[S],
46    ) -> Result<Self, Error> {
47        match config.format {
48            Format::Cargo => Cargo::new(config.as_path(), &content)
49                .map(VersionedFile::Cargo)
50                .map_err(Error::Cargo),
51            Format::CargoLock => CargoLock::new(config.as_path(), &content)
52                .map(VersionedFile::CargoLock)
53                .map_err(Error::CargoLock),
54            Format::PyProject => PyProject::new(config.as_path(), content)
55                .map(VersionedFile::PyProject)
56                .map_err(Error::PyProject),
57            Format::PubSpec => PubSpec::new(config.as_path(), content)
58                .map(VersionedFile::PubSpec)
59                .map_err(Error::PubSpec),
60            Format::GoMod => GoMod::new(config.as_path(), content, git_tags)
61                .map(VersionedFile::GoMod)
62                .map_err(Error::GoMod),
63            Format::PackageJson => PackageJson::new(config.as_path(), content)
64                .map(VersionedFile::PackageJson)
65                .map_err(Error::PackageJson),
66        }
67    }
68
69    #[must_use]
70    pub fn path(&self) -> &RelativePathBuf {
71        match self {
72            VersionedFile::Cargo(cargo) => &cargo.path,
73            VersionedFile::CargoLock(cargo_lock) => &cargo_lock.path,
74            VersionedFile::PyProject(pyproject) => &pyproject.path,
75            VersionedFile::PubSpec(pubspec) => pubspec.get_path(),
76            VersionedFile::GoMod(gomod) => gomod.get_path(),
77            VersionedFile::PackageJson(package_json) => package_json.get_path(),
78        }
79    }
80
81    /// Get the package version from the file.
82    ///
83    /// # Errors
84    ///
85    /// If there's no package version for this type of file (e.g., lock file, dependency file).
86    pub fn version(&self) -> Result<Version, Error> {
87        match self {
88            VersionedFile::Cargo(cargo) => cargo.get_version().map_err(Error::Cargo),
89            VersionedFile::CargoLock(_) => Err(Error::NoVersion),
90            VersionedFile::PyProject(pyproject) => Ok(pyproject.version.clone()),
91            VersionedFile::PubSpec(pubspec) => Ok(pubspec.get_version().clone()),
92            VersionedFile::GoMod(gomod) => Ok(gomod.get_version().clone()),
93            VersionedFile::PackageJson(package_json) => Ok(package_json.get_version().clone()),
94        }
95    }
96
97    /// Set the version in the file.
98    ///
99    /// # Errors
100    ///
101    /// 1. If the file is `go.mod`, there are rules about what versions are allowed.
102    pub(crate) fn set_version(
103        self,
104        new_version: &Version,
105        dependency: Option<&str>,
106        go_versioning: GoVersioning,
107    ) -> Result<Self, SetError> {
108        match self {
109            Self::Cargo(cargo) => Ok(Self::Cargo(cargo.set_version(new_version, dependency))),
110            Self::CargoLock(cargo_lock) => cargo_lock
111                .set_version(new_version, dependency)
112                .map(Self::CargoLock)
113                .map_err(SetError::CargoLock),
114            Self::PyProject(pyproject) => Ok(Self::PyProject(pyproject.set_version(new_version))),
115            Self::PubSpec(pubspec) => pubspec
116                .set_version(new_version)
117                .map_err(SetError::Yaml)
118                .map(Self::PubSpec),
119            Self::GoMod(gomod) => gomod
120                .set_version(new_version.clone(), go_versioning)
121                .map_err(SetError::GoMod)
122                .map(Self::GoMod),
123            Self::PackageJson(package_json) => package_json
124                .set_version(new_version)
125                .map_err(SetError::Json)
126                .map(Self::PackageJson),
127        }
128    }
129
130    pub fn write(self) -> Option<impl IntoIterator<Item = Action>> {
131        match self {
132            Self::Cargo(cargo) => cargo.write().map(Single),
133            Self::CargoLock(cargo_lock) => cargo_lock.write().map(Single),
134            Self::PyProject(pyproject) => pyproject.write().map(Single),
135            Self::PubSpec(pubspec) => pubspec.write().map(Single),
136            Self::GoMod(gomod) => gomod.write().map(Two),
137            Self::PackageJson(package_json) => package_json.write().map(Single),
138        }
139    }
140}
141
142#[derive(Debug, thiserror::Error)]
143#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
144pub enum SetError {
145    #[error("Error serializing JSON, this is a bug: {0}")]
146    #[cfg_attr(
147        feature = "miette",
148        diagnostic(
149            code(knope_versioning::versioned_file::json_serialize),
150            help("This is a bug in knope, please report it."),
151            url("https://github.com/knope-dev/knope/issues")
152        )
153    )]
154    Json(#[from] serde_json::Error),
155    #[error("Error serializing YAML, this is a bug: {0}")]
156    #[cfg_attr(
157        feature = "miette",
158        diagnostic(
159            code(knope_versioning::versioned_file::yaml_serialize),
160            help("This is a bug in knope, please report it."),
161            url("https://github.com/knope-dev/knope/issues"),
162        )
163    )]
164    Yaml(#[from] serde_yaml::Error),
165    #[error(transparent)]
166    #[cfg_attr(feature = "miette", diagnostic(transparent))]
167    GoMod(#[from] go_mod::SetError),
168    #[error(transparent)]
169    #[cfg_attr(feature = "miette", diagnostic(transparent))]
170    CargoLock(#[from] cargo_lock::SetError),
171}
172
173#[derive(Debug, thiserror::Error)]
174#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
175pub enum Error {
176    #[error("This file can't contain a version")]
177    #[cfg_attr(
178        feature = "miette",
179        diagnostic(
180            code(knope_versioning::versioned_file::no_version),
181            help("This is likely a bug, please report it."),
182            url("https://github.com/knope-dev/knope/issues")
183        )
184    )]
185    NoVersion,
186    #[error(transparent)]
187    #[cfg_attr(feature = "miette", diagnostic(transparent))]
188    Cargo(#[from] cargo::Error),
189    #[error(transparent)]
190    #[cfg_attr(feature = "miette", diagnostic(transparent))]
191    CargoLock(#[from] cargo_lock::Error),
192    #[error(transparent)]
193    #[cfg_attr(feature = "miette", diagnostic(transparent))]
194    PyProject(#[from] pyproject::Error),
195    #[error(transparent)]
196    #[cfg_attr(feature = "miette", diagnostic(transparent))]
197    PubSpec(#[from] pubspec::Error),
198    #[error(transparent)]
199    #[cfg_attr(feature = "miette", diagnostic(transparent))]
200    GoMod(#[from] go_mod::Error),
201    #[error(transparent)]
202    #[cfg_attr(feature = "miette", diagnostic(transparent))]
203    PackageJson(#[from] package_json::Error),
204}
205
206#[derive(Clone, Copy, Debug, Eq, PartialEq)]
207pub(crate) enum Format {
208    Cargo,
209    CargoLock,
210    PyProject,
211    PubSpec,
212    GoMod,
213    PackageJson,
214}
215
216impl Format {
217    pub(crate) const fn file_name(self) -> &'static str {
218        match self {
219            Format::Cargo => "Cargo.toml",
220            Format::CargoLock => "Cargo.lock",
221            Format::PyProject => "pyproject.toml",
222            Format::PubSpec => "pubspec.yaml",
223            Format::GoMod => "go.mod",
224            Format::PackageJson => "package.json",
225        }
226    }
227
228    fn try_from(file_name: &str) -> Option<Self> {
229        match file_name {
230            "Cargo.toml" => Some(Format::Cargo),
231            "Cargo.lock" => Some(Format::CargoLock),
232            "pyproject.toml" => Some(Format::PyProject),
233            "pubspec.yaml" => Some(Format::PubSpec),
234            "go.mod" => Some(Format::GoMod),
235            "package.json" => Some(Format::PackageJson),
236            _ => None,
237        }
238    }
239}
240
241#[derive(Debug, thiserror::Error)]
242#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
243#[error("Unknown file: {path}")]
244#[cfg_attr(
245    feature = "miette",
246    diagnostic(
247        code(knope_versioning::versioned_file::unknown_file),
248        help("Knope identities the type of file based on its name."),
249        url("https://knope.tech/reference/config-file/packages#versioned_files")
250    )
251)]
252pub struct UnknownFile {
253    pub path: RelativePathBuf,
254}
255
256/// The configuration of a versioned file.
257#[derive(Clone, Debug, Eq, PartialEq)]
258pub struct Config {
259    /// The directory that the file is in
260    parent: Option<RelativePathBuf>,
261    /// The type of file
262    pub(crate) format: Format,
263    /// If, within the file, we're versioning a dependency (not the entire package)
264    pub dependency: Option<String>,
265}
266
267impl Config {
268    /// Create a verified `Config` from a `RelativePathBuf`.
269    ///
270    /// # Errors
271    ///
272    /// If the file name does not match a supported format
273    pub fn new(path: RelativePathBuf, dependency: Option<String>) -> Result<Self, UnknownFile> {
274        let Some(file_name) = path.file_name() else {
275            return Err(UnknownFile { path });
276        };
277        let parent = path.parent().map(RelativePathBuf::from);
278        let format = Format::try_from(file_name).ok_or(UnknownFile { path })?;
279        Ok(Config {
280            parent,
281            format,
282            dependency,
283        })
284    }
285
286    #[must_use]
287    pub fn as_path(&self) -> RelativePathBuf {
288        self.parent.as_ref().map_or_else(
289            || RelativePathBuf::from(self.format.file_name()),
290            |parent| parent.join(self.format.file_name()),
291        )
292    }
293
294    #[must_use]
295    pub fn to_pathbuf(&self) -> PathBuf {
296        self.as_path().to_path("")
297    }
298
299    #[must_use]
300    pub const fn defaults() -> [Self; 5] {
301        [
302            Config {
303                format: Format::Cargo,
304                parent: None,
305                dependency: None,
306            },
307            Config {
308                parent: None,
309                format: Format::GoMod,
310                dependency: None,
311            },
312            Config {
313                parent: None,
314                format: Format::PackageJson,
315                dependency: None,
316            },
317            Config {
318                parent: None,
319                format: Format::PubSpec,
320                dependency: None,
321            },
322            Config {
323                parent: None,
324                format: Format::PyProject,
325                dependency: None,
326            },
327        ]
328    }
329}
330
331impl Serialize for Config {
332    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
333    where
334        S: Serializer,
335    {
336        self.as_path().serialize(serializer)
337    }
338}
339
340impl From<&Config> for PathBuf {
341    fn from(path: &Config) -> Self {
342        path.as_path().to_path("")
343    }
344}
345
346impl PartialEq<RelativePathBuf> for Config {
347    fn eq(&self, other: &RelativePathBuf) -> bool {
348        let other_parent = other.parent();
349        let parent = self.parent.as_deref();
350
351        let parents_match = match (parent, other_parent) {
352            (Some(parent), Some(other_parent)) => parent == other_parent,
353            (None, None) => true,
354            (Some(parent), None) if parent == "" => true,
355            (None, Some(other_parent)) if other_parent == "" => true,
356            _ => false,
357        };
358
359        parents_match
360            && other
361                .file_name()
362                .is_some_and(|file_name| file_name == self.format.file_name())
363    }
364}
365
366impl PartialEq<Config> for RelativePathBuf {
367    fn eq(&self, other: &Config) -> bool {
368        other == self
369    }
370}