1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum GoModuleError {
10 EmptyPath,
11 InvalidPath,
12 EmptyVersion,
13 InvalidVersion,
14 EmptyPseudoVersion,
15 InvalidPseudoVersion,
16}
17
18impl fmt::Display for GoModuleError {
19 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20 match self {
21 Self::EmptyPath => formatter.write_str("Go module path cannot be empty"),
22 Self::InvalidPath => formatter.write_str("invalid Go module path"),
23 Self::EmptyVersion => formatter.write_str("Go module version cannot be empty"),
24 Self::InvalidVersion => formatter.write_str("invalid Go module version"),
25 Self::EmptyPseudoVersion => formatter.write_str("Go pseudo-version cannot be empty"),
26 Self::InvalidPseudoVersion => formatter.write_str("invalid Go pseudo-version"),
27 }
28 }
29}
30
31impl Error for GoModuleError {}
32
33#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
35pub struct GoModulePath(String);
36
37impl GoModulePath {
38 pub fn new(value: impl AsRef<str>) -> Result<Self, GoModuleError> {
44 let trimmed = value.as_ref().trim();
45 if trimmed.is_empty() {
46 return Err(GoModuleError::EmptyPath);
47 }
48 if !is_valid_path_text(trimmed) {
49 return Err(GoModuleError::InvalidPath);
50 }
51 Ok(Self(trimmed.to_string()))
52 }
53
54 #[must_use]
56 pub fn as_str(&self) -> &str {
57 &self.0
58 }
59
60 #[must_use]
62 pub fn into_string(self) -> String {
63 self.0
64 }
65}
66
67impl AsRef<str> for GoModulePath {
68 fn as_ref(&self) -> &str {
69 self.as_str()
70 }
71}
72
73impl fmt::Display for GoModulePath {
74 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
75 formatter.write_str(self.as_str())
76 }
77}
78
79impl FromStr for GoModulePath {
80 type Err = GoModuleError;
81
82 fn from_str(value: &str) -> Result<Self, Self::Err> {
83 Self::new(value)
84 }
85}
86
87impl TryFrom<&str> for GoModulePath {
88 type Error = GoModuleError;
89
90 fn try_from(value: &str) -> Result<Self, Self::Error> {
91 Self::new(value)
92 }
93}
94
95#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
97pub struct GoModuleVersion(String);
98
99impl GoModuleVersion {
100 pub fn new(value: impl AsRef<str>) -> Result<Self, GoModuleError> {
106 let trimmed = value.as_ref().trim();
107 if trimmed.is_empty() {
108 return Err(GoModuleError::EmptyVersion);
109 }
110 if !is_lightweight_module_version(trimmed) {
111 return Err(GoModuleError::InvalidVersion);
112 }
113 Ok(Self(trimmed.to_string()))
114 }
115
116 #[must_use]
118 pub fn as_str(&self) -> &str {
119 &self.0
120 }
121
122 #[must_use]
124 pub fn is_pseudo_version(&self) -> bool {
125 is_pseudo_version_like(self.as_str())
126 }
127}
128
129impl AsRef<str> for GoModuleVersion {
130 fn as_ref(&self) -> &str {
131 self.as_str()
132 }
133}
134
135impl fmt::Display for GoModuleVersion {
136 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137 formatter.write_str(self.as_str())
138 }
139}
140
141impl FromStr for GoModuleVersion {
142 type Err = GoModuleError;
143
144 fn from_str(value: &str) -> Result<Self, Self::Err> {
145 Self::new(value)
146 }
147}
148
149impl TryFrom<&str> for GoModuleVersion {
150 type Error = GoModuleError;
151
152 fn try_from(value: &str) -> Result<Self, Self::Error> {
153 Self::new(value)
154 }
155}
156
157#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
159pub struct GoPseudoVersion(String);
160
161impl GoPseudoVersion {
162 pub fn new(value: impl AsRef<str>) -> Result<Self, GoModuleError> {
168 let trimmed = value.as_ref().trim();
169 if trimmed.is_empty() {
170 return Err(GoModuleError::EmptyPseudoVersion);
171 }
172 if !is_pseudo_version_like(trimmed) {
173 return Err(GoModuleError::InvalidPseudoVersion);
174 }
175 Ok(Self(trimmed.to_string()))
176 }
177
178 #[must_use]
180 pub fn as_str(&self) -> &str {
181 &self.0
182 }
183}
184
185impl AsRef<str> for GoPseudoVersion {
186 fn as_ref(&self) -> &str {
187 self.as_str()
188 }
189}
190
191impl fmt::Display for GoPseudoVersion {
192 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
193 formatter.write_str(self.as_str())
194 }
195}
196
197impl FromStr for GoPseudoVersion {
198 type Err = GoModuleError;
199
200 fn from_str(value: &str) -> Result<Self, Self::Err> {
201 Self::new(value)
202 }
203}
204
205impl TryFrom<&str> for GoPseudoVersion {
206 type Error = GoModuleError;
207
208 fn try_from(value: &str) -> Result<Self, Self::Error> {
209 Self::new(value)
210 }
211}
212
213#[derive(Clone, Debug, Eq, PartialEq)]
215pub struct GoModuleDependency {
216 path: GoModulePath,
217 version: GoModuleVersion,
218}
219
220impl GoModuleDependency {
221 #[must_use]
223 pub const fn new(path: GoModulePath, version: GoModuleVersion) -> Self {
224 Self { path, version }
225 }
226
227 #[must_use]
229 pub const fn path(&self) -> &GoModulePath {
230 &self.path
231 }
232
233 #[must_use]
235 pub const fn version(&self) -> &GoModuleVersion {
236 &self.version
237 }
238}
239
240#[derive(Clone, Debug, Eq, PartialEq)]
242pub struct GoModuleReplacement {
243 old_path: GoModulePath,
244 old_version: Option<GoModuleVersion>,
245 new_path: GoModulePath,
246 new_version: Option<GoModuleVersion>,
247}
248
249impl GoModuleReplacement {
250 #[must_use]
252 pub const fn new(old_path: GoModulePath, new_path: GoModulePath) -> Self {
253 Self {
254 old_path,
255 old_version: None,
256 new_path,
257 new_version: None,
258 }
259 }
260
261 #[must_use]
263 pub fn with_old_version(mut self, version: GoModuleVersion) -> Self {
264 self.old_version = Some(version);
265 self
266 }
267
268 #[must_use]
270 pub fn with_new_version(mut self, version: GoModuleVersion) -> Self {
271 self.new_version = Some(version);
272 self
273 }
274
275 #[must_use]
277 pub const fn old_path(&self) -> &GoModulePath {
278 &self.old_path
279 }
280
281 #[must_use]
283 pub const fn old_version(&self) -> Option<&GoModuleVersion> {
284 self.old_version.as_ref()
285 }
286
287 #[must_use]
289 pub const fn new_path(&self) -> &GoModulePath {
290 &self.new_path
291 }
292
293 #[must_use]
295 pub const fn new_version(&self) -> Option<&GoModuleVersion> {
296 self.new_version.as_ref()
297 }
298}
299
300#[derive(Clone, Debug, Eq, PartialEq)]
302pub struct GoModuleRequirement {
303 dependency: GoModuleDependency,
304 indirect: bool,
305}
306
307impl GoModuleRequirement {
308 #[must_use]
310 pub const fn new(dependency: GoModuleDependency) -> Self {
311 Self {
312 dependency,
313 indirect: false,
314 }
315 }
316
317 #[must_use]
319 pub const fn indirect(mut self) -> Self {
320 self.indirect = true;
321 self
322 }
323
324 #[must_use]
326 pub const fn dependency(&self) -> &GoModuleDependency {
327 &self.dependency
328 }
329
330 #[must_use]
332 pub const fn is_indirect(&self) -> bool {
333 self.indirect
334 }
335}
336
337#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
339pub enum GoModuleDirectiveKind {
340 Module,
341 Go,
342 Toolchain,
343 Require,
344 Replace,
345 Exclude,
346 Retract,
347}
348
349impl GoModuleDirectiveKind {
350 #[must_use]
352 pub const fn as_str(self) -> &'static str {
353 match self {
354 Self::Module => "module",
355 Self::Go => "go",
356 Self::Toolchain => "toolchain",
357 Self::Require => "require",
358 Self::Replace => "replace",
359 Self::Exclude => "exclude",
360 Self::Retract => "retract",
361 }
362 }
363}
364
365impl fmt::Display for GoModuleDirectiveKind {
366 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
367 formatter.write_str(self.as_str())
368 }
369}
370
371impl FromStr for GoModuleDirectiveKind {
372 type Err = GoModuleError;
373
374 fn from_str(value: &str) -> Result<Self, Self::Err> {
375 match normalized_label(value)?.as_str() {
376 "module" => Ok(Self::Module),
377 "go" => Ok(Self::Go),
378 "toolchain" => Ok(Self::Toolchain),
379 "require" => Ok(Self::Require),
380 "replace" => Ok(Self::Replace),
381 "exclude" => Ok(Self::Exclude),
382 "retract" => Ok(Self::Retract),
383 _ => Err(GoModuleError::InvalidPath),
384 }
385 }
386}
387
388fn is_valid_path_text(value: &str) -> bool {
389 !value.chars().any(char::is_whitespace)
390 && !value.split('/').any(str::is_empty)
391 && !value.contains('\\')
392}
393
394fn is_lightweight_module_version(value: &str) -> bool {
395 let Some(rest) = value.strip_prefix('v') else {
396 return false;
397 };
398 let base = rest.split('-').next().unwrap_or(rest);
399 is_semver_core(base) && !value.split('-').any(str::is_empty)
400}
401
402fn is_semver_core(value: &str) -> bool {
403 let mut components = value.split('.');
404 let Some(major) = components.next() else {
405 return false;
406 };
407 let Some(minor) = components.next() else {
408 return false;
409 };
410 let Some(patch) = components.next() else {
411 return false;
412 };
413 components.next().is_none()
414 && is_ascii_digits(major)
415 && is_ascii_digits(minor)
416 && is_ascii_digits(patch)
417}
418
419fn is_pseudo_version_like(value: &str) -> bool {
420 let parts = value.split('-').collect::<Vec<_>>();
421 parts.len() >= 3
422 && is_lightweight_module_version(value)
423 && parts.iter().all(|part| !part.is_empty())
424}
425
426fn is_ascii_digits(value: &str) -> bool {
427 !value.is_empty() && value.chars().all(|character| character.is_ascii_digit())
428}
429
430fn normalized_label(value: &str) -> Result<String, GoModuleError> {
431 let trimmed = value.trim();
432 if trimmed.is_empty() {
433 Err(GoModuleError::EmptyPath)
434 } else {
435 Ok(trimmed.to_ascii_lowercase())
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::{
442 GoModuleDependency, GoModuleDirectiveKind, GoModuleError, GoModulePath,
443 GoModuleReplacement, GoModuleRequirement, GoModuleVersion, GoPseudoVersion,
444 };
445
446 #[test]
447 fn validates_module_paths() -> Result<(), GoModuleError> {
448 let path = GoModulePath::new("example.com/project/sub")?;
449 assert_eq!(path.as_str(), "example.com/project/sub");
450 assert_eq!(GoModulePath::new(""), Err(GoModuleError::EmptyPath));
451 assert_eq!(
452 GoModulePath::new("example.com//project"),
453 Err(GoModuleError::InvalidPath)
454 );
455 assert_eq!(
456 GoModulePath::new("example.com/project name"),
457 Err(GoModuleError::InvalidPath)
458 );
459 Ok(())
460 }
461
462 #[test]
463 fn validates_module_versions() -> Result<(), GoModuleError> {
464 let version = GoModuleVersion::new("v1.2.3")?;
465 let pseudo = GoModuleVersion::new("v0.0.0-20240101000000-abcdefabcdef")?;
466
467 assert_eq!(version.as_str(), "v1.2.3");
468 assert!(pseudo.is_pseudo_version());
469 assert_eq!(
470 GoModuleVersion::new("1.2.3"),
471 Err(GoModuleError::InvalidVersion)
472 );
473 assert_eq!(
474 GoModuleVersion::new("v1.2"),
475 Err(GoModuleError::InvalidVersion)
476 );
477 Ok(())
478 }
479
480 #[test]
481 fn validates_pseudo_versions() -> Result<(), GoModuleError> {
482 let pseudo = GoPseudoVersion::new("v0.0.0-20240101000000-abcdefabcdef")?;
483 assert_eq!(pseudo.as_str(), "v0.0.0-20240101000000-abcdefabcdef");
484 assert_eq!(
485 GoPseudoVersion::new("v1.2.3"),
486 Err(GoModuleError::InvalidPseudoVersion)
487 );
488 Ok(())
489 }
490
491 #[test]
492 fn models_dependency_requirement_and_replacement() -> Result<(), GoModuleError> {
493 let path = GoModulePath::new("example.com/library")?;
494 let version = GoModuleVersion::new("v1.2.3")?;
495 let dependency = GoModuleDependency::new(path.clone(), version.clone());
496 let requirement = GoModuleRequirement::new(dependency).indirect();
497 let replacement = GoModuleReplacement::new(path, GoModulePath::new("../library")?)
498 .with_old_version(version.clone())
499 .with_new_version(version);
500
501 assert!(requirement.is_indirect());
502 assert_eq!(
503 replacement.old_version().map(GoModuleVersion::as_str),
504 Some("v1.2.3")
505 );
506 assert_eq!(replacement.new_path().as_str(), "../library");
507 Ok(())
508 }
509
510 #[test]
511 fn parses_directive_kinds() -> Result<(), GoModuleError> {
512 assert_eq!(
513 "require".parse::<GoModuleDirectiveKind>()?,
514 GoModuleDirectiveKind::Require
515 );
516 assert_eq!(GoModuleDirectiveKind::Toolchain.to_string(), "toolchain");
517 Ok(())
518 }
519}