knope_versioning/versioned_file/
go_mod.rs

1use std::{
2    fmt::{Debug, Display},
3    str::FromStr,
4};
5
6#[cfg(feature = "miette")]
7use miette::Diagnostic;
8use relative_path::{RelativePath, RelativePathBuf};
9use thiserror::Error;
10
11use crate::{action::Action, semver::Version};
12
13#[derive(Clone, Debug, Eq, PartialEq)]
14pub struct GoMod {
15    path: RelativePathBuf,
16    raw: String,
17    module_line: ModuleLine,
18    version: Version,
19    new_version: Option<Version>,
20}
21
22impl GoMod {
23    pub(crate) fn new<S: AsRef<str>>(
24        path: RelativePathBuf,
25        raw: String,
26        git_tags: &[S],
27    ) -> Result<Self, Error> {
28        let module_line = raw
29            .lines()
30            .find(|line| line.starts_with("module "))
31            .map(ModuleLine::from_str)
32            .ok_or(Error::MissingModuleLine)??;
33
34        if let Some(comment_version) = &module_line.version {
35            return Ok(Self {
36                path,
37                raw,
38                version: comment_version.clone(),
39                module_line,
40                new_version: None,
41            });
42        }
43
44        let mut parent = path.parent();
45        let major_filter = if let Some(major) = module_line.major_version {
46            let major_dir = format!("v{major}");
47            if parent.is_some_and(|parent| parent.ends_with(&major_dir)) {
48                // Major version directories are not tag prefixes!
49                parent = parent.and_then(RelativePath::parent);
50            }
51            vec![major]
52        } else {
53            vec![0, 1]
54        };
55        let prefix = match parent.map(RelativePath::to_string) {
56            Some(submodule) if !submodule.is_empty() => format!("{submodule}/"),
57            _ => String::new(),
58        };
59
60        let Some(version_from_tag) = git_tags
61            .iter()
62            .filter_map(|tag| tag.as_ref().strip_prefix(&prefix)?.strip_prefix('v'))
63            .find_map(|tag| {
64                let version = Version::from_str(tag).ok()?;
65                if major_filter.contains(&version.stable_component().major) {
66                    Some(version)
67                } else {
68                    None
69                }
70            })
71        else {
72            return Err(Error::NoMatchingTag {
73                prefix,
74                major_filter,
75            });
76        };
77
78        Ok(GoMod {
79            path,
80            raw,
81            module_line,
82            version: version_from_tag,
83            new_version: None,
84        })
85    }
86
87    pub(crate) fn get_version(&self) -> &Version {
88        &self.version
89    }
90
91    pub(crate) fn get_path(&self) -> &RelativePathBuf {
92        &self.path
93    }
94
95    #[allow(clippy::expect_used)]
96    pub(crate) fn set_version(
97        mut self,
98        new_version: Version,
99        versioning: GoVersioning,
100    ) -> Result<Self, SetError> {
101        let original_module_line = self
102            .raw
103            .lines()
104            .find(|line| line.starts_with("module "))
105            .expect("module line was found in `new`");
106        self.module_line.version = Some(new_version.clone());
107
108        let new_major = new_version.stable_component().major;
109        let module_line_needs_updating = new_major > 1
110            && new_major != self.module_line.major_version.unwrap_or(0)
111            && versioning != GoVersioning::IgnoreMajorRules;
112
113        if module_line_needs_updating {
114            if self.module_line.major_version.is_none() && versioning != GoVersioning::BumpMajor {
115                return Err(SetError::BumpingToV2);
116            }
117            let using_major_version_directories =
118                self.module_line.major_version.is_some_and(|old_major| {
119                    self.path
120                        .parent()
121                        .is_some_and(|parent| parent.ends_with(format!("v{old_major}")))
122                });
123            if using_major_version_directories {
124                return Err(SetError::MajorVersionDirectoryBased);
125            }
126            self.module_line.major_version = Some(new_version.stable_component().major);
127        }
128
129        self.raw = self
130            .raw
131            .replace(original_module_line, &self.module_line.to_string());
132        self.new_version = Some(new_version);
133        Ok(self)
134    }
135
136    pub(crate) fn write(self) -> Option<[Action; 2]> {
137        let new_version = self.new_version?;
138
139        let tag = self
140            .path
141            .parent()
142            .and_then(|parent| {
143                let parent_str = parent.to_string();
144                let major = new_version.stable_component().major;
145                let prefix = parent_str
146                    .strip_suffix(&format!("v{major}"))
147                    .unwrap_or(&parent_str);
148                let prefix = prefix.strip_suffix('/').unwrap_or(prefix);
149                if prefix.is_empty() {
150                    None
151                } else {
152                    Some(prefix.to_string())
153                }
154            })
155            .map_or_else(
156                || format!("v{new_version}"),
157                |prefix| format!("{prefix}/v{new_version}"),
158            );
159
160        Some([
161            Action::WriteToFile {
162                path: self.path,
163                content: self.raw,
164                diff: new_version.to_string(),
165            },
166            Action::AddTag { tag },
167        ])
168    }
169}
170
171#[derive(Debug, Error)]
172#[cfg_attr(feature = "miette", derive(Diagnostic))]
173pub enum SetError {
174    #[error("Will not bump Go modules to 2.0.0")]
175    #[cfg_attr(
176        feature = "miette",
177        diagnostic(
178            code(go::cannot_increase_major_version),
179            help(
180                "Go recommends a directory-based versioning strategy for major versions above v1. See the docs for more details."
181            ),
182            url("https://knope.tech/recipes/multiple-major-go-versions/"),
183        )
184    )]
185    BumpingToV2,
186    #[error("Cannot bump major versions of directory-based go modules")]
187    #[cfg_attr(
188        feature = "miette",
189        diagnostic(
190            code(go::major_version_directory_based),
191            help(
192                "You are using directory-based major versions—Knope cannot create a new major version directory for you. \
193                    Create the new directory manually and add it as a new package in knope.toml."
194            ),
195            url("https://knope.tech/recipes/multiple-major-go-versions/"),
196        )
197    )]
198    MajorVersionDirectoryBased,
199}
200
201#[derive(Debug, Error)]
202#[cfg_attr(feature = "miette", derive(Diagnostic))]
203pub enum Error {
204    #[error("No module line found in go.mod file")]
205    #[cfg_attr(
206        feature = "miette",
207        diagnostic(
208            code(go::no_module_line),
209            help(
210                "The go.mod file does not contain a module line. This is required for the step to work."
211            ),
212            url("https://knope.tech/reference/config-file/packages/#gomod")
213        )
214    )]
215    MissingModuleLine,
216    #[error(transparent)]
217    #[cfg_attr(feature = "miette", diagnostic(transparent))]
218    ModuleLine(#[from] ModuleLineError),
219    #[error(
220        "No matching tag found for the go.mod file. Searched for a tag with the prefix {prefix} and a major version of {major_filter:?}"
221    )]
222    #[cfg_attr(
223        feature = "miette",
224        diagnostic(
225            code(go::no_matching_tag),
226            help("The go.mod file must have a matching tag in the repository."),
227            url("https://knope.tech/reference/config-file/packages/#gomod")
228        )
229    )]
230    NoMatchingTag {
231        prefix: String,
232        major_filter: Vec<u64>,
233    },
234}
235
236/// The versioning strategy for Go modules.
237#[derive(Debug, Default, Eq, Clone, Copy, PartialEq)]
238pub enum GoVersioning {
239    /// The standard versioning strategy for Go modules:
240    ///
241    /// 1. Major version can't be bumped beyond 1
242    /// 2. Module line must end with v{major} for major versions > 1
243    #[default]
244    Standard,
245    /// Don't worry about the major version of the module line.
246    IgnoreMajorRules,
247    /// Bumping the major version of the module line is okay
248    BumpMajor,
249}
250
251/// The line defining the module in go.mod, formatted like `module github.com/owner/repo/v2 // v2.1.4`.
252///
253/// The final component of the URI will only exist for versions >=2.0.0, and is only the major
254/// component of the version.
255///
256/// The comment at the end is maintained by Knope and may not exist for projects which haven't yet
257/// used Knope to set a new version.
258///
259/// More details from [the go docs](https://go.dev/doc/modules/gomod-ref#module)
260#[derive(Clone, Debug, Eq, PartialEq)]
261struct ModuleLine {
262    module: String,
263    major_version: Option<u64>,
264    version: Option<Version>,
265}
266
267impl FromStr for ModuleLine {
268    type Err = ModuleLineError;
269
270    fn from_str(s: &str) -> Result<Self, Self::Err> {
271        let parts = s.split_whitespace().collect::<Vec<_>>();
272        // parts[0] is "module"
273        let mut module = (*parts.get(1).ok_or(ModuleLineError::MissingModulePath)?).to_string();
274        let major_version = module
275            .rsplit_once('/')
276            .and_then(|(_, major)| major.strip_prefix('v'))
277            .and_then(|major| major.parse::<u64>().ok());
278        if major_version.is_some() {
279            // We store this separately for easy incrementing and rebuilding
280            module = module
281                .rsplit_once('/')
282                .map(|(uri, _)| uri.to_string())
283                .unwrap_or(module);
284        }
285
286        let version = parts
287            .get(2)
288            .and_then(|comment_start| (*comment_start == "//").then_some(()))
289            .and_then(|()| parts.get(3))
290            .and_then(|v| v.strip_prefix('v'))
291            .and_then(|v| {
292                if let Ok(version) = Version::from_str(v) {
293                    Some(version)
294                } else {
295                    None
296                }
297            });
298        Ok(Self {
299            module,
300            major_version,
301            version,
302        })
303    }
304}
305
306impl Display for ModuleLine {
307    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
308        write!(f, "module {}", self.module)?;
309        if let Some(major_version) = self.major_version {
310            if major_version > 1 {
311                write!(f, "/v{major_version}")?;
312            }
313        }
314        if let Some(version) = &self.version {
315            write!(f, " // v{version}")?;
316        }
317        Ok(())
318    }
319}
320
321#[derive(Debug, Error)]
322#[cfg_attr(feature = "miette", derive(Diagnostic))]
323pub enum ModuleLineError {
324    #[error("missing module path")]
325    #[cfg_attr(
326        feature = "miette",
327        diagnostic(
328            code(go::missing_module_path),
329            help(
330                "The module line in go.mod must contain a module path, usually the URI of the repository."
331            )
332        )
333    )]
334    MissingModulePath,
335}
336
337#[cfg(test)]
338#[allow(clippy::unwrap_used)]
339mod test_go_mod {
340    use super::*;
341
342    #[test]
343    fn if_module_line_has_comment_no_tags_needed() {
344        let go_mod = GoMod::new(
345            RelativePathBuf::from("go.mod"),
346            "module github.com/owner/repo // v2.1.4".to_string(),
347            &[""],
348        )
349        .unwrap();
350        assert_eq!(go_mod.get_version(), &Version::new(2, 1, 4, None));
351    }
352
353    #[test]
354    fn get_version_from_tag() {
355        let go_mod = GoMod::new(
356            RelativePathBuf::from("go.mod"),
357            "module github.com/owner/repo".to_string(),
358            &["v1.2.3"],
359        )
360        .unwrap();
361        assert_eq!(go_mod.get_version(), &Version::new(1, 2, 3, None));
362    }
363
364    #[test]
365    fn use_v1_tags() {
366        let go_mod = GoMod::new(
367            RelativePathBuf::from("go.mod"),
368            "module github.com/owner/repo".to_string(),
369            &["v1.2.3", "v2.0.0"],
370        )
371        .unwrap();
372        assert_eq!(go_mod.get_version(), &Version::new(1, 2, 3, None));
373    }
374
375    #[test]
376    fn look_for_major_tag() {
377        let go_mod = GoMod::new(
378            RelativePathBuf::from("go.mod"),
379            "module github.com/owner/repo/v2".to_string(),
380            &["v1.2.3", "v2.0.0", "v3.0.0"],
381        )
382        .unwrap();
383        assert_eq!(go_mod.get_version(), &Version::new(2, 0, 0, None));
384    }
385
386    #[test]
387    fn tag_prefix_for_submodules() {
388        let go_mod = GoMod::new(
389            RelativePathBuf::from("submodule/go.mod"),
390            "module github.com/owner/repo/submodule".to_string(),
391            &["v1.2.3", "submodule/v0.2.0", "v1.2.4"],
392        )
393        .unwrap();
394        assert_eq!(go_mod.get_version(), &Version::new(0, 2, 0, None));
395    }
396}
397
398#[cfg(test)]
399#[allow(clippy::unwrap_used)]
400mod test_module_line {
401    use std::str::FromStr;
402
403    use pretty_assertions::assert_eq;
404
405    use super::ModuleLine;
406
407    #[test]
408    fn parse_basic() {
409        let line = ModuleLine::from_str("module github.com/owner/repo").unwrap();
410        assert_eq!(
411            line,
412            ModuleLine {
413                module: "github.com/owner/repo".to_string(),
414                major_version: None,
415                version: None,
416            }
417        );
418    }
419
420    #[test]
421    fn parse_with_major_version() {
422        let line = ModuleLine::from_str("module github.com/owner/repo/v2").unwrap();
423        assert_eq!(
424            line,
425            ModuleLine {
426                module: "github.com/owner/repo".to_string(),
427                major_version: Some(2),
428                version: None,
429            }
430        );
431    }
432
433    #[test]
434    fn parse_with_version() {
435        let line = ModuleLine::from_str("module github.com/owner/repo // v2.1.4").unwrap();
436        assert_eq!(
437            line,
438            ModuleLine {
439                module: "github.com/owner/repo".to_string(),
440                major_version: None,
441                version: Some("2.1.4".parse().unwrap()),
442            }
443        );
444    }
445
446    #[test]
447    fn parse_with_major_version_and_version() {
448        let line = ModuleLine::from_str("module github.com/owner/repo/v2 // v3.1.4").unwrap();
449        assert_eq!(
450            line,
451            ModuleLine {
452                module: "github.com/owner/repo".to_string(),
453                major_version: Some(2),
454                version: Some("3.1.4".parse().unwrap()),
455            }
456        );
457    }
458
459    #[test]
460    fn parse_with_random_comment() {
461        let line = ModuleLine::from_str(
462            "module github.com/owner/repo/v2 // comment that is not the thing you expect",
463        )
464        .unwrap();
465        assert_eq!(
466            line,
467            ModuleLine {
468                module: "github.com/owner/repo".to_string(),
469                major_version: Some(2),
470                version: None,
471            }
472        );
473    }
474
475    #[test]
476    fn format_basic() {
477        let line = ModuleLine {
478            module: "github.com/owner/repo".to_string(),
479            major_version: None,
480            version: None,
481        };
482        assert_eq!(line.to_string(), "module github.com/owner/repo");
483    }
484
485    #[test]
486    fn format_with_major_version() {
487        let line = ModuleLine {
488            module: "github.com/owner/repo".to_string(),
489            major_version: Some(2),
490            version: None,
491        };
492        assert_eq!(line.to_string(), "module github.com/owner/repo/v2");
493    }
494
495    #[test]
496    fn format_with_version() {
497        let line = ModuleLine {
498            module: "github.com/owner/repo".to_string(),
499            major_version: None,
500            version: Some("2.1.4".parse().unwrap()),
501        };
502        assert_eq!(line.to_string(), "module github.com/owner/repo // v2.1.4");
503    }
504
505    #[test]
506    fn format_with_major_version_and_version() {
507        let line = ModuleLine {
508            module: "github.com/owner/repo".to_string(),
509            major_version: Some(2),
510            version: Some("3.1.4".parse().unwrap()),
511        };
512        assert_eq!(
513            line.to_string(),
514            "module github.com/owner/repo/v2 // v3.1.4"
515        );
516    }
517}