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| {
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}