knope_versioning/versioned_file/
mod.rs

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