knope_versioning/versioned_file/
mod.rs

1use std::{fmt::Debug, path::PathBuf};
2
3use relative_path::RelativePathBuf;
4use serde::{Serialize, Serializer};
5
6pub use self::go_mod::GoVersioning;
7use self::{
8    cargo::Cargo, cargo_lock::CargoLock, deno_json::DenoJson, deno_lock::DenoLock, gleam::Gleam,
9    go_mod::GoMod, maven_pom::MavenPom, package_json::PackageJson,
10    package_lock_json::PackageLockJson, pubspec::PubSpec, pyproject::PyProject,
11    regex_file::RegexFile, tauri_conf_json::TauriConfJson,
12};
13use crate::{
14    Action,
15    action::ActionSet::{Single, Two},
16    semver::Version,
17};
18
19pub mod cargo;
20mod cargo_lock;
21mod deno_json;
22mod deno_lock;
23mod gleam;
24mod go_mod;
25mod maven_pom;
26mod package_json;
27mod package_lock_json;
28mod pubspec;
29mod pyproject;
30mod regex_file;
31mod tauri_conf_json;
32
33#[derive(Clone, Debug)]
34pub enum VersionedFile {
35    Cargo(Cargo),
36    CargoLock(CargoLock),
37    DenoJson(DenoJson),
38    DenoLock(DenoLock),
39    PubSpec(PubSpec),
40    Gleam(Gleam),
41    GoMod(GoMod),
42    PackageJson(PackageJson),
43    PackageLockJson(PackageLockJson),
44    PyProject(PyProject),
45    MavenPom(MavenPom),
46    TauriConf(TauriConfJson),
47    TauriMacosConf(TauriConfJson),
48    TauriWindowsConf(TauriConfJson),
49    TauriLinuxConf(TauriConfJson),
50    RegexFile(RegexFile),
51}
52
53impl VersionedFile {
54    /// Create a new `VersionedFile`
55    ///
56    /// # Errors
57    ///
58    /// Depends on the format.
59    /// If the content doesn't match the expected format, an error is returned.
60    pub fn new<S: AsRef<str> + Debug>(
61        config: &Config,
62        content: String,
63        git_tags: &[S],
64    ) -> Result<Self, Error> {
65        match config.format {
66            Format::Cargo => Cargo::new(config.as_path(), &content)
67                .map(VersionedFile::Cargo)
68                .map_err(Error::Cargo),
69            Format::CargoLock => CargoLock::new(config.as_path(), &content)
70                .map(VersionedFile::CargoLock)
71                .map_err(Error::CargoLock),
72            Format::DenoJson => DenoJson::new(config.as_path(), content)
73                .map(VersionedFile::DenoJson)
74                .map_err(Error::DenoJson),
75            Format::DenoLock => DenoLock::new(config.as_path(), &content)
76                .map(VersionedFile::DenoLock)
77                .map_err(Error::DenoLock),
78            Format::PyProject => PyProject::new(config.as_path(), content)
79                .map(VersionedFile::PyProject)
80                .map_err(Error::PyProject),
81            Format::PubSpec => PubSpec::new(config.as_path(), content)
82                .map(VersionedFile::PubSpec)
83                .map_err(Error::PubSpec),
84            Format::Gleam => Gleam::new(config.as_path(), &content)
85                .map(VersionedFile::Gleam)
86                .map_err(Error::Gleam),
87            Format::GoMod => GoMod::new(config.as_path(), content, git_tags)
88                .map(VersionedFile::GoMod)
89                .map_err(Error::GoMod),
90            Format::PackageJson => PackageJson::new(config.as_path(), content)
91                .map(VersionedFile::PackageJson)
92                .map_err(Error::PackageJson),
93            Format::PackageLockJson => PackageLockJson::new(config.as_path(), &content)
94                .map(VersionedFile::PackageLockJson)
95                .map_err(Error::PackageLockJson),
96            Format::MavenPom => MavenPom::new(config.as_path(), content)
97                .map(VersionedFile::MavenPom)
98                .map_err(Error::MavenPom),
99            Format::TauriConf => TauriConfJson::new(config.as_path(), content)
100                .map(VersionedFile::TauriConf)
101                .map_err(Error::TauriConfJson),
102            Format::RegexFile => {
103                let regex = config
104                    .regex
105                    .as_ref()
106                    .ok_or_else(|| {
107                        Error::RegexFile(regex_file::Error::NoMatch {
108                            regex: String::new(),
109                            path: config.as_path(),
110                        })
111                    })?
112                    .clone();
113                RegexFile::new(config.as_path(), content, regex)
114                    .map(VersionedFile::RegexFile)
115                    .map_err(Error::RegexFile)
116            }
117        }
118    }
119
120    #[must_use]
121    pub fn path(&self) -> &RelativePathBuf {
122        match self {
123            VersionedFile::Cargo(cargo) => &cargo.path,
124            VersionedFile::CargoLock(cargo_lock) => &cargo_lock.path,
125            VersionedFile::DenoJson(deno_json) => deno_json.get_path(),
126            VersionedFile::DenoLock(deno_lock) => deno_lock.get_path(),
127            VersionedFile::PyProject(pyproject) => &pyproject.path,
128            VersionedFile::PubSpec(pubspec) => pubspec.get_path(),
129            VersionedFile::Gleam(gleam) => &gleam.path,
130            VersionedFile::GoMod(gomod) => gomod.get_path(),
131            VersionedFile::PackageJson(package_json) => package_json.get_path(),
132            VersionedFile::PackageLockJson(package_lock_json) => package_lock_json.get_path(),
133            VersionedFile::MavenPom(maven_pom) => &maven_pom.path,
134            VersionedFile::TauriConf(tauri_conf)
135            | VersionedFile::TauriMacosConf(tauri_conf)
136            | VersionedFile::TauriWindowsConf(tauri_conf)
137            | VersionedFile::TauriLinuxConf(tauri_conf) => tauri_conf.get_path(),
138            VersionedFile::RegexFile(regex_file) => &regex_file.path,
139        }
140    }
141
142    /// Get the package version from the file.
143    ///
144    /// # Errors
145    ///
146    /// If there's no package version for this type of file (e.g., lock file, dependency file).
147    pub fn version(&self) -> Result<Version, Error> {
148        match self {
149            VersionedFile::Cargo(cargo) => cargo.get_version().map_err(Error::Cargo),
150            VersionedFile::CargoLock(_) | VersionedFile::DenoLock(_) => Err(Error::NoVersion),
151            VersionedFile::DenoJson(deno_json) => deno_json.get_version().ok_or(Error::NoVersion),
152            VersionedFile::PyProject(pyproject) => Ok(pyproject.version.clone()),
153            VersionedFile::PubSpec(pubspec) => Ok(pubspec.get_version().clone()),
154            VersionedFile::Gleam(gleam) => Ok(gleam.get_version().map_err(Error::Gleam)?),
155            VersionedFile::GoMod(gomod) => Ok(gomod.get_version().clone()),
156            VersionedFile::PackageJson(package_json) => Ok(package_json.get_version().clone()),
157            VersionedFile::PackageLockJson(package_lock_json) => package_lock_json
158                .get_version()
159                .map_err(Error::PackageLockJson),
160            VersionedFile::MavenPom(maven_pom) => maven_pom.get_version().map_err(Error::MavenPom),
161            VersionedFile::TauriConf(tauri_conf)
162            | VersionedFile::TauriMacosConf(tauri_conf)
163            | VersionedFile::TauriWindowsConf(tauri_conf)
164            | VersionedFile::TauriLinuxConf(tauri_conf) => Ok(tauri_conf.get_version().clone()),
165            VersionedFile::RegexFile(regex_file) => {
166                regex_file.get_version().map_err(Error::RegexFile)
167            }
168        }
169    }
170
171    /// Set the version in the file.
172    ///
173    /// # Errors
174    ///
175    /// 1. If the file is `go.mod`, there are rules about what versions are allowed.
176    pub(crate) fn set_version(
177        self,
178        new_version: &Version,
179        dependency: Option<&str>,
180        go_versioning: GoVersioning,
181    ) -> Result<Self, SetError> {
182        match self {
183            Self::Cargo(cargo) => Ok(Self::Cargo(cargo.set_version(new_version, dependency))),
184            Self::CargoLock(cargo_lock) => cargo_lock
185                .set_version(new_version, dependency)
186                .map(Self::CargoLock)
187                .map_err(SetError::CargoLock),
188            Self::DenoJson(deno_json) => deno_json
189                .set_version(new_version, dependency)
190                .map_err(SetError::Json)
191                .map(Self::DenoJson),
192            Self::DenoLock(deno_lock) => deno_lock
193                .set_version(new_version, dependency)
194                .map_err(SetError::DenoLock)
195                .map(Self::DenoLock),
196            Self::PyProject(pyproject) => Ok(Self::PyProject(pyproject.set_version(new_version))),
197            Self::PubSpec(pubspec) => pubspec
198                .set_version(new_version)
199                .map_err(SetError::Yaml)
200                .map(Self::PubSpec),
201            Self::Gleam(gleam) => Ok(Self::Gleam(gleam.set_version(new_version))),
202            Self::GoMod(gomod) => gomod
203                .set_version(new_version.clone(), go_versioning)
204                .map_err(SetError::GoMod)
205                .map(Self::GoMod),
206            Self::PackageJson(package_json) => package_json
207                .set_version(new_version, dependency)
208                .map_err(SetError::Json)
209                .map(Self::PackageJson),
210            Self::PackageLockJson(package_lock_json) => Ok(Self::PackageLockJson(
211                package_lock_json.set_version(new_version, dependency),
212            )),
213            Self::MavenPom(maven_pom) => maven_pom
214                .set_version(new_version)
215                .map_err(SetError::MavenPom)
216                .map(Self::MavenPom),
217            Self::TauriConf(tauri_conf) => tauri_conf
218                .set_version(new_version)
219                .map_err(SetError::Json)
220                .map(Self::TauriConf),
221            Self::TauriMacosConf(tauri_conf) => tauri_conf
222                .set_version(new_version)
223                .map_err(SetError::Json)
224                .map(Self::TauriMacosConf),
225            Self::TauriWindowsConf(tauri_conf) => tauri_conf
226                .set_version(new_version)
227                .map_err(SetError::Json)
228                .map(Self::TauriWindowsConf),
229            Self::TauriLinuxConf(tauri_conf) => tauri_conf
230                .set_version(new_version)
231                .map_err(SetError::Json)
232                .map(Self::TauriLinuxConf),
233            Self::RegexFile(regex_file) => Ok(Self::RegexFile(regex_file.set_version(new_version))),
234        }
235    }
236
237    pub fn write(self) -> Option<impl IntoIterator<Item = Action>> {
238        match self {
239            Self::Cargo(cargo) => cargo.write().map(Single),
240            Self::CargoLock(cargo_lock) => cargo_lock.write().map(Single),
241            Self::DenoJson(deno_json) => deno_json.write().map(Single),
242            Self::PyProject(pyproject) => pyproject.write().map(Single),
243            Self::PubSpec(pubspec) => pubspec.write().map(Single),
244            Self::Gleam(gleam) => gleam.write().map(Single),
245            Self::GoMod(gomod) => gomod.write().map(Two),
246            Self::PackageJson(package_json) => package_json.write().map(Single),
247            Self::PackageLockJson(package_lock_json) => package_lock_json.write().map(Single),
248            Self::DenoLock(deno_lock) => deno_lock.write().map(Single),
249            Self::MavenPom(maven_pom) => maven_pom.write().map(Single),
250            Self::TauriConf(tauri_conf)
251            | Self::TauriMacosConf(tauri_conf)
252            | Self::TauriWindowsConf(tauri_conf)
253            | Self::TauriLinuxConf(tauri_conf) => tauri_conf.write().map(Single),
254            Self::RegexFile(regex_file) => regex_file.write().map(Single),
255        }
256    }
257}
258
259#[derive(Debug, thiserror::Error)]
260#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
261pub enum SetError {
262    #[error("Error serializing JSON, this is a bug: {0}")]
263    #[cfg_attr(
264        feature = "miette",
265        diagnostic(
266            code(knope_versioning::versioned_file::json_serialize),
267            help("This is a bug in knope, please report it."),
268            url("https://github.com/knope-dev/knope/issues")
269        )
270    )]
271    Json(#[from] serde_json::Error),
272    #[error("Error serializing YAML, this is a bug: {0}")]
273    #[cfg_attr(
274        feature = "miette",
275        diagnostic(
276            code(knope_versioning::versioned_file::yaml_serialize),
277            help("This is a bug in knope, please report it."),
278            url("https://github.com/knope-dev/knope/issues"),
279        )
280    )]
281    Yaml(#[from] serde_yaml::Error),
282    #[error(transparent)]
283    #[cfg_attr(feature = "miette", diagnostic(transparent))]
284    GoMod(#[from] go_mod::SetError),
285    #[error(transparent)]
286    #[cfg_attr(feature = "miette", diagnostic(transparent))]
287    CargoLock(#[from] cargo_lock::SetError),
288    #[error(transparent)]
289    #[cfg_attr(feature = "miette", diagnostic(transparent))]
290    MavenPom(#[from] maven_pom::Error),
291    #[error(transparent)]
292    #[cfg_attr(feature = "miette", diagnostic(transparent))]
293    DenoLock(#[from] deno_lock::Error),
294}
295
296#[derive(Debug, thiserror::Error)]
297#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
298pub enum Error {
299    #[error("This file can't contain a version")]
300    #[cfg_attr(
301        feature = "miette",
302        diagnostic(
303            code(knope_versioning::versioned_file::no_version),
304            help("This is likely a bug, please report it."),
305            url("https://github.com/knope-dev/knope/issues")
306        )
307    )]
308    NoVersion,
309    #[error(transparent)]
310    #[cfg_attr(feature = "miette", diagnostic(transparent))]
311    Cargo(#[from] cargo::Error),
312    #[error(transparent)]
313    #[cfg_attr(feature = "miette", diagnostic(transparent))]
314    CargoLock(#[from] cargo_lock::Error),
315    #[error(transparent)]
316    #[cfg_attr(feature = "miette", diagnostic(transparent))]
317    DenoJson(#[from] deno_json::Error),
318    #[error(transparent)]
319    #[cfg_attr(feature = "miette", diagnostic(transparent))]
320    PyProject(#[from] pyproject::Error),
321    #[error(transparent)]
322    #[cfg_attr(feature = "miette", diagnostic(transparent))]
323    PubSpec(#[from] pubspec::Error),
324    #[error(transparent)]
325    #[cfg_attr(feature = "miette", diagnostic(transparent))]
326    Gleam(#[from] gleam::Error),
327    #[error(transparent)]
328    #[cfg_attr(feature = "miette", diagnostic(transparent))]
329    GoMod(#[from] go_mod::Error),
330    #[error(transparent)]
331    #[cfg_attr(feature = "miette", diagnostic(transparent))]
332    PackageJson(#[from] package_json::Error),
333    #[error(transparent)]
334    #[cfg_attr(feature = "miette", diagnostic(transparent))]
335    PackageLockJson(#[from] package_lock_json::Error),
336    #[error(transparent)]
337    #[cfg_attr(feature = "miette", diagnostic(transparent))]
338    MavenPom(#[from] maven_pom::Error),
339    #[error(transparent)]
340    #[cfg_attr(feature = "miette", diagnostic(transparent))]
341    TauriConfJson(#[from] tauri_conf_json::Error),
342    #[error(transparent)]
343    #[cfg_attr(feature = "miette", diagnostic(transparent))]
344    DenoLock(#[from] deno_lock::Error),
345    #[error(transparent)]
346    #[cfg_attr(feature = "miette", diagnostic(transparent))]
347    RegexFile(#[from] regex_file::Error),
348}
349
350/// All the file types supported for versioning.
351///
352/// Be sure to add new variants to [`Format::FILE_NAMES`]
353#[derive(Clone, Copy, Debug, Eq, PartialEq)]
354pub(crate) enum Format {
355    Cargo,
356    CargoLock,
357    #[allow(dead_code)]
358    DenoJson,
359    PyProject,
360    PubSpec,
361    Gleam,
362    GoMod,
363    PackageJson,
364    PackageLockJson,
365    DenoLock,
366    MavenPom,
367    TauriConf,
368    RegexFile,
369}
370
371impl Format {
372    /// This is how Knope automatically detects a file type based on its name.
373    const FILE_NAMES: &'static [(&'static str, Self)] = &[
374        ("Cargo.toml", Format::Cargo),
375        ("Cargo.lock", Format::CargoLock),
376        ("deno.json", Format::DenoJson),
377        ("deno.lock", Format::DenoLock),
378        ("gleam.toml", Format::Gleam),
379        ("go.mod", Format::GoMod),
380        ("package.json", Format::PackageJson),
381        ("package-lock.json", Format::PackageLockJson),
382        ("pom.xml", Format::MavenPom),
383        ("pubspec.yaml", Format::PubSpec),
384        ("pyproject.toml", Format::PyProject),
385        ("tauri.conf.json", Format::TauriConf),
386        ("tauri.macos.conf.json", Format::TauriConf),
387        ("tauri.windows.conf.json", Format::TauriConf),
388        ("tauri.linux.conf.json", Format::TauriConf),
389    ];
390
391    fn try_from(file_name: &str) -> Option<Self> {
392        Self::FILE_NAMES
393            .iter()
394            .find(|(name, _)| file_name == *name)
395            .map(|(_, format)| *format)
396    }
397}
398
399#[derive(Debug, thiserror::Error)]
400#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
401pub enum ConfigError {
402    #[error(transparent)]
403    #[cfg_attr(feature = "miette", diagnostic(transparent))]
404    UnknownFile(#[from] UnknownFile),
405    #[error(transparent)]
406    #[cfg_attr(feature = "miette", diagnostic(transparent))]
407    ConflictingOptions(#[from] ConflictingOptions),
408}
409
410#[derive(Debug, thiserror::Error)]
411#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
412#[error("Unknown file: {path}")]
413#[cfg_attr(
414    feature = "miette",
415    diagnostic(
416        code(knope_versioning::versioned_file::unknown_file),
417        help("Knope identities the type of file based on its name."),
418        url("https://knope.tech/reference/config-file/packages#versioned_files")
419    )
420)]
421pub struct UnknownFile {
422    pub path: RelativePathBuf,
423}
424
425#[derive(Debug, thiserror::Error)]
426#[cfg_attr(feature = "miette", derive(miette::Diagnostic))]
427#[error("Cannot specify both 'dependency' and 'regex' for the same file: {path}")]
428#[cfg_attr(
429    feature = "miette",
430    diagnostic(
431        code(knope_versioning::versioned_file::conflicting_options),
432        help(
433            "Use 'dependency' to update a dependency version in a known file format, or 'regex' to match version strings in arbitrary text files, but not both."
434        ),
435        url("https://knope.tech/reference/config-file/packages#versioned_files")
436    )
437)]
438pub struct ConflictingOptions {
439    pub path: RelativePathBuf,
440}
441
442/// The configuration of a versioned file.
443#[derive(Clone, Debug, Eq, PartialEq)]
444pub struct Config {
445    /// The location of the file
446    pub(crate) path: RelativePathBuf,
447    /// The type of file
448    pub(crate) format: Format,
449    /// If, within the file, we're versioning a dependency (not the entire package)
450    pub dependency: Option<String>,
451    /// If set, use regex pattern matching to find and replace the version
452    pub regex: Option<String>,
453}
454
455impl Config {
456    /// Create a verified `Config` from a `RelativePathBuf`.
457    ///
458    /// # Errors
459    ///
460    /// If the file name does not match a supported format and no regex is provided
461    /// If both dependency and regex are provided
462    pub fn new(
463        path: RelativePathBuf,
464        dependency: Option<String>,
465        regex: Option<String>,
466    ) -> Result<Self, ConfigError> {
467        if dependency.is_some() && regex.is_some() {
468            return Err(ConflictingOptions { path }.into());
469        }
470
471        if regex.is_some() {
472            return Ok(Config {
473                path,
474                format: Format::RegexFile,
475                dependency,
476                regex,
477            });
478        }
479
480        let Some(file_name) = path.file_name() else {
481            return Err(UnknownFile { path }.into());
482        };
483        let Some(format) = Format::try_from(file_name) else {
484            return Err(UnknownFile { path }.into());
485        };
486        Ok(Config {
487            path,
488            format,
489            dependency,
490            regex: None,
491        })
492    }
493
494    #[must_use]
495    pub fn as_path(&self) -> RelativePathBuf {
496        self.path.clone()
497    }
498
499    #[must_use]
500    pub fn to_pathbuf(&self) -> PathBuf {
501        self.as_path().to_path("")
502    }
503
504    pub fn defaults() -> impl Iterator<Item = Self> {
505        Format::FILE_NAMES
506            .iter()
507            .copied()
508            .map(|(name, format)| Self {
509                format,
510                path: RelativePathBuf::from(name),
511                dependency: None,
512                regex: None,
513            })
514    }
515}
516
517impl Serialize for Config {
518    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
519    where
520        S: Serializer,
521    {
522        self.as_path().serialize(serializer)
523    }
524}
525
526impl From<&Config> for PathBuf {
527    fn from(path: &Config) -> Self {
528        path.as_path().to_path("")
529    }
530}
531
532impl PartialEq<RelativePathBuf> for Config {
533    fn eq(&self, other: &RelativePathBuf) -> bool {
534        self.path == *other
535    }
536}
537
538impl PartialEq<Config> for RelativePathBuf {
539    fn eq(&self, other: &Config) -> bool {
540        other == self
541    }
542}