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 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#[derive(Debug, Default, Eq, Clone, Copy, PartialEq)]
238pub enum GoVersioning {
239 #[default]
244 Standard,
245 IgnoreMajorRules,
247 BumpMajor,
249}
250
251#[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 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 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}