Skip to main content

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| Version::from_str(v).ok());
292        Ok(Self {
293            module,
294            major_version,
295            version,
296        })
297    }
298}
299
300impl Display for ModuleLine {
301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        write!(f, "module {}", self.module)?;
303        if let Some(major_version) = self.major_version {
304            if major_version > 1 {
305                write!(f, "/v{major_version}")?;
306            }
307        }
308        if let Some(version) = &self.version {
309            write!(f, " // v{version}")?;
310        }
311        Ok(())
312    }
313}
314
315#[derive(Debug, Error)]
316#[cfg_attr(feature = "miette", derive(Diagnostic))]
317pub enum ModuleLineError {
318    #[error("missing module path")]
319    #[cfg_attr(
320        feature = "miette",
321        diagnostic(
322            code(go::missing_module_path),
323            help(
324                "The module line in go.mod must contain a module path, usually the URI of the repository."
325            )
326        )
327    )]
328    MissingModulePath,
329}
330
331#[cfg(test)]
332#[allow(clippy::unwrap_used)]
333mod test_go_mod {
334    use super::*;
335
336    #[test]
337    fn if_module_line_has_comment_no_tags_needed() {
338        let go_mod = GoMod::new(
339            RelativePathBuf::from("go.mod"),
340            "module github.com/owner/repo // v2.1.4".to_string(),
341            &[""],
342        )
343        .unwrap();
344        assert_eq!(go_mod.get_version(), &Version::new(2, 1, 4, None));
345    }
346
347    #[test]
348    fn get_version_from_tag() {
349        let go_mod = GoMod::new(
350            RelativePathBuf::from("go.mod"),
351            "module github.com/owner/repo".to_string(),
352            &["v1.2.3"],
353        )
354        .unwrap();
355        assert_eq!(go_mod.get_version(), &Version::new(1, 2, 3, None));
356    }
357
358    #[test]
359    fn use_v1_tags() {
360        let go_mod = GoMod::new(
361            RelativePathBuf::from("go.mod"),
362            "module github.com/owner/repo".to_string(),
363            &["v1.2.3", "v2.0.0"],
364        )
365        .unwrap();
366        assert_eq!(go_mod.get_version(), &Version::new(1, 2, 3, None));
367    }
368
369    #[test]
370    fn look_for_major_tag() {
371        let go_mod = GoMod::new(
372            RelativePathBuf::from("go.mod"),
373            "module github.com/owner/repo/v2".to_string(),
374            &["v1.2.3", "v2.0.0", "v3.0.0"],
375        )
376        .unwrap();
377        assert_eq!(go_mod.get_version(), &Version::new(2, 0, 0, None));
378    }
379
380    #[test]
381    fn tag_prefix_for_submodules() {
382        let go_mod = GoMod::new(
383            RelativePathBuf::from("submodule/go.mod"),
384            "module github.com/owner/repo/submodule".to_string(),
385            &["v1.2.3", "submodule/v0.2.0", "v1.2.4"],
386        )
387        .unwrap();
388        assert_eq!(go_mod.get_version(), &Version::new(0, 2, 0, None));
389    }
390}
391
392#[cfg(test)]
393#[allow(clippy::unwrap_used)]
394mod test_module_line {
395    use std::str::FromStr;
396
397    use pretty_assertions::assert_eq;
398
399    use super::ModuleLine;
400
401    #[test]
402    fn parse_basic() {
403        let line = ModuleLine::from_str("module github.com/owner/repo").unwrap();
404        assert_eq!(
405            line,
406            ModuleLine {
407                module: "github.com/owner/repo".to_string(),
408                major_version: None,
409                version: None,
410            }
411        );
412    }
413
414    #[test]
415    fn parse_with_major_version() {
416        let line = ModuleLine::from_str("module github.com/owner/repo/v2").unwrap();
417        assert_eq!(
418            line,
419            ModuleLine {
420                module: "github.com/owner/repo".to_string(),
421                major_version: Some(2),
422                version: None,
423            }
424        );
425    }
426
427    #[test]
428    fn parse_with_version() {
429        let line = ModuleLine::from_str("module github.com/owner/repo // v2.1.4").unwrap();
430        assert_eq!(
431            line,
432            ModuleLine {
433                module: "github.com/owner/repo".to_string(),
434                major_version: None,
435                version: Some("2.1.4".parse().unwrap()),
436            }
437        );
438    }
439
440    #[test]
441    fn parse_with_major_version_and_version() {
442        let line = ModuleLine::from_str("module github.com/owner/repo/v2 // v3.1.4").unwrap();
443        assert_eq!(
444            line,
445            ModuleLine {
446                module: "github.com/owner/repo".to_string(),
447                major_version: Some(2),
448                version: Some("3.1.4".parse().unwrap()),
449            }
450        );
451    }
452
453    #[test]
454    fn parse_with_random_comment() {
455        let line = ModuleLine::from_str(
456            "module github.com/owner/repo/v2 // comment that is not the thing you expect",
457        )
458        .unwrap();
459        assert_eq!(
460            line,
461            ModuleLine {
462                module: "github.com/owner/repo".to_string(),
463                major_version: Some(2),
464                version: None,
465            }
466        );
467    }
468
469    #[test]
470    fn format_basic() {
471        let line = ModuleLine {
472            module: "github.com/owner/repo".to_string(),
473            major_version: None,
474            version: None,
475        };
476        assert_eq!(line.to_string(), "module github.com/owner/repo");
477    }
478
479    #[test]
480    fn format_with_major_version() {
481        let line = ModuleLine {
482            module: "github.com/owner/repo".to_string(),
483            major_version: Some(2),
484            version: None,
485        };
486        assert_eq!(line.to_string(), "module github.com/owner/repo/v2");
487    }
488
489    #[test]
490    fn format_with_version() {
491        let line = ModuleLine {
492            module: "github.com/owner/repo".to_string(),
493            major_version: None,
494            version: Some("2.1.4".parse().unwrap()),
495        };
496        assert_eq!(line.to_string(), "module github.com/owner/repo // v2.1.4");
497    }
498
499    #[test]
500    fn format_with_major_version_and_version() {
501        let line = ModuleLine {
502            module: "github.com/owner/repo".to_string(),
503            major_version: Some(2),
504            version: Some("3.1.4".parse().unwrap()),
505        };
506        assert_eq!(
507            line.to_string(),
508            "module github.com/owner/repo/v2 // v3.1.4"
509        );
510    }
511}