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(feature = "miette", diagnostic(
176 code(go::cannot_increase_major_version),
177 help("Go recommends a directory-based versioning strategy for major versions above v1. See the docs for more details."),
178 url("https://knope.tech/recipes/multiple-major-go-versions/"),
179 ))]
180 BumpingToV2,
181 #[error("Cannot bump major versions of directory-based go modules")]
182 #[cfg_attr(feature = "miette", diagnostic(
183 code(go::major_version_directory_based),
184 help("You are using directory-based major versions—Knope cannot create a new major version directory for you. \
185 Create the new directory manually and add it as a new package in knope.toml."),
186 url("https://knope.tech/recipes/multiple-major-go-versions/"),
187 ))]
188 MajorVersionDirectoryBased,
189}
190
191#[derive(Debug, Error)]
192#[cfg_attr(feature = "miette", derive(Diagnostic))]
193pub enum Error {
194 #[error("No module line found in go.mod file")]
195 #[cfg_attr(feature = "miette", diagnostic(
196 code(go::no_module_line),
197 help("The go.mod file does not contain a module line. This is required for the step to work."),
198 url("https://knope.tech/reference/config-file/packages/#gomod")
199 ))]
200 MissingModuleLine,
201 #[error(transparent)]
202 #[cfg_attr(feature = "miette", diagnostic(transparent))]
203 ModuleLine(#[from] ModuleLineError),
204 #[error("No matching tag found for the go.mod file. Searched for a tag with the prefix {prefix} and a major version of {major_filter:?}")]
205 #[cfg_attr(
206 feature = "miette",
207 diagnostic(
208 code(go::no_matching_tag),
209 help("The go.mod file must have a matching tag in the repository."),
210 url("https://knope.tech/reference/config-file/packages/#gomod")
211 )
212 )]
213 NoMatchingTag {
214 prefix: String,
215 major_filter: Vec<u64>,
216 },
217}
218
219#[derive(Debug, Default, Eq, Clone, Copy, PartialEq)]
221pub enum GoVersioning {
222 #[default]
227 Standard,
228 IgnoreMajorRules,
230 BumpMajor,
232}
233
234#[derive(Clone, Debug, Eq, PartialEq)]
244struct ModuleLine {
245 module: String,
246 major_version: Option<u64>,
247 version: Option<Version>,
248}
249
250impl FromStr for ModuleLine {
251 type Err = ModuleLineError;
252
253 fn from_str(s: &str) -> Result<Self, Self::Err> {
254 let parts = s.split_whitespace().collect::<Vec<_>>();
255 let mut module = (*parts.get(1).ok_or(ModuleLineError::MissingModulePath)?).to_string();
257 let major_version = module
258 .rsplit_once('/')
259 .and_then(|(_, major)| major.strip_prefix('v'))
260 .and_then(|major| major.parse::<u64>().ok());
261 if major_version.is_some() {
262 module = module
264 .rsplit_once('/')
265 .map(|(uri, _)| uri.to_string())
266 .unwrap_or(module);
267 }
268
269 let version = parts
270 .get(2)
271 .and_then(|comment_start| (*comment_start == "//").then_some(()))
272 .and_then(|()| parts.get(3))
273 .and_then(|v| v.strip_prefix('v'))
274 .and_then(|v| {
275 if let Ok(version) = Version::from_str(v) {
276 Some(version)
277 } else {
278 None
279 }
280 });
281 Ok(Self {
282 module,
283 major_version,
284 version,
285 })
286 }
287}
288
289impl Display for ModuleLine {
290 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291 write!(f, "module {}", self.module)?;
292 if let Some(major_version) = self.major_version {
293 if major_version > 1 {
294 write!(f, "/v{major_version}")?;
295 }
296 }
297 if let Some(version) = &self.version {
298 write!(f, " // v{version}")?;
299 }
300 Ok(())
301 }
302}
303
304#[derive(Debug, Error)]
305#[cfg_attr(feature = "miette", derive(Diagnostic))]
306pub enum ModuleLineError {
307 #[error("missing module path")]
308 #[cfg_attr(feature = "miette", diagnostic(
309 code(go::missing_module_path),
310 help("The module line in go.mod must contain a module path, usually the URI of the repository.")
311 ))]
312 MissingModulePath,
313}
314
315#[cfg(test)]
316#[allow(clippy::unwrap_used)]
317mod test_go_mod {
318 use super::*;
319
320 #[test]
321 fn if_module_line_has_comment_no_tags_needed() {
322 let go_mod = GoMod::new(
323 RelativePathBuf::from("go.mod"),
324 "module github.com/owner/repo // v2.1.4".to_string(),
325 &[""],
326 )
327 .unwrap();
328 assert_eq!(go_mod.get_version(), &Version::new(2, 1, 4, None));
329 }
330
331 #[test]
332 fn get_version_from_tag() {
333 let go_mod = GoMod::new(
334 RelativePathBuf::from("go.mod"),
335 "module github.com/owner/repo".to_string(),
336 &["v1.2.3"],
337 )
338 .unwrap();
339 assert_eq!(go_mod.get_version(), &Version::new(1, 2, 3, None));
340 }
341
342 #[test]
343 fn use_v1_tags() {
344 let go_mod = GoMod::new(
345 RelativePathBuf::from("go.mod"),
346 "module github.com/owner/repo".to_string(),
347 &["v1.2.3", "v2.0.0"],
348 )
349 .unwrap();
350 assert_eq!(go_mod.get_version(), &Version::new(1, 2, 3, None));
351 }
352
353 #[test]
354 fn look_for_major_tag() {
355 let go_mod = GoMod::new(
356 RelativePathBuf::from("go.mod"),
357 "module github.com/owner/repo/v2".to_string(),
358 &["v1.2.3", "v2.0.0", "v3.0.0"],
359 )
360 .unwrap();
361 assert_eq!(go_mod.get_version(), &Version::new(2, 0, 0, None));
362 }
363
364 #[test]
365 fn tag_prefix_for_submodules() {
366 let go_mod = GoMod::new(
367 RelativePathBuf::from("submodule/go.mod"),
368 "module github.com/owner/repo/submodule".to_string(),
369 &["v1.2.3", "submodule/v0.2.0", "v1.2.4"],
370 )
371 .unwrap();
372 assert_eq!(go_mod.get_version(), &Version::new(0, 2, 0, None));
373 }
374}
375
376#[cfg(test)]
377#[allow(clippy::unwrap_used)]
378mod test_module_line {
379 use std::str::FromStr;
380
381 use pretty_assertions::assert_eq;
382
383 use super::ModuleLine;
384
385 #[test]
386 fn parse_basic() {
387 let line = ModuleLine::from_str("module github.com/owner/repo").unwrap();
388 assert_eq!(
389 line,
390 ModuleLine {
391 module: "github.com/owner/repo".to_string(),
392 major_version: None,
393 version: None,
394 }
395 );
396 }
397
398 #[test]
399 fn parse_with_major_version() {
400 let line = ModuleLine::from_str("module github.com/owner/repo/v2").unwrap();
401 assert_eq!(
402 line,
403 ModuleLine {
404 module: "github.com/owner/repo".to_string(),
405 major_version: Some(2),
406 version: None,
407 }
408 );
409 }
410
411 #[test]
412 fn parse_with_version() {
413 let line = ModuleLine::from_str("module github.com/owner/repo // v2.1.4").unwrap();
414 assert_eq!(
415 line,
416 ModuleLine {
417 module: "github.com/owner/repo".to_string(),
418 major_version: None,
419 version: Some("2.1.4".parse().unwrap()),
420 }
421 );
422 }
423
424 #[test]
425 fn parse_with_major_version_and_version() {
426 let line = ModuleLine::from_str("module github.com/owner/repo/v2 // v3.1.4").unwrap();
427 assert_eq!(
428 line,
429 ModuleLine {
430 module: "github.com/owner/repo".to_string(),
431 major_version: Some(2),
432 version: Some("3.1.4".parse().unwrap()),
433 }
434 );
435 }
436
437 #[test]
438 fn parse_with_random_comment() {
439 let line = ModuleLine::from_str(
440 "module github.com/owner/repo/v2 // comment that is not the thing you expect",
441 )
442 .unwrap();
443 assert_eq!(
444 line,
445 ModuleLine {
446 module: "github.com/owner/repo".to_string(),
447 major_version: Some(2),
448 version: None,
449 }
450 );
451 }
452
453 #[test]
454 fn format_basic() {
455 let line = ModuleLine {
456 module: "github.com/owner/repo".to_string(),
457 major_version: None,
458 version: None,
459 };
460 assert_eq!(line.to_string(), "module github.com/owner/repo");
461 }
462
463 #[test]
464 fn format_with_major_version() {
465 let line = ModuleLine {
466 module: "github.com/owner/repo".to_string(),
467 major_version: Some(2),
468 version: None,
469 };
470 assert_eq!(line.to_string(), "module github.com/owner/repo/v2");
471 }
472
473 #[test]
474 fn format_with_version() {
475 let line = ModuleLine {
476 module: "github.com/owner/repo".to_string(),
477 major_version: None,
478 version: Some("2.1.4".parse().unwrap()),
479 };
480 assert_eq!(line.to_string(), "module github.com/owner/repo // v2.1.4");
481 }
482
483 #[test]
484 fn format_with_major_version_and_version() {
485 let line = ModuleLine {
486 module: "github.com/owner/repo".to_string(),
487 major_version: Some(2),
488 version: Some("3.1.4".parse().unwrap()),
489 };
490 assert_eq!(
491 line.to_string(),
492 "module github.com/owner/repo/v2 // v3.1.4"
493 );
494 }
495}