Skip to main content

use_go_module/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned by Go module metadata constructors.
8#[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/// Validated Go module path metadata.
34#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
35pub struct GoModulePath(String);
36
37impl GoModulePath {
38    /// Creates a Go module path from non-empty text.
39    ///
40    /// # Errors
41    ///
42    /// Returns [`GoModuleError`] when the path is empty, has whitespace, or has empty slash segments.
43    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    /// Returns the module path.
55    #[must_use]
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59
60    /// Consumes the path and returns the owned text.
61    #[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/// Validated Go module version metadata.
96#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
97pub struct GoModuleVersion(String);
98
99impl GoModuleVersion {
100    /// Creates a Go module version label.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`GoModuleError`] when the version is empty or not lightweight `vMAJOR.MINOR.PATCH`-shaped.
105    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    /// Returns the module version label.
117    #[must_use]
118    pub fn as_str(&self) -> &str {
119        &self.0
120    }
121
122    /// Returns whether this label is pseudo-version-shaped.
123    #[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/// Validated Go pseudo-version metadata.
158#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
159pub struct GoPseudoVersion(String);
160
161impl GoPseudoVersion {
162    /// Creates a pseudo-version-shaped label.
163    ///
164    /// # Errors
165    ///
166    /// Returns [`GoModuleError`] when the label is empty or not pseudo-version-like.
167    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    /// Returns the pseudo-version label.
179    #[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/// Go module dependency metadata.
214#[derive(Clone, Debug, Eq, PartialEq)]
215pub struct GoModuleDependency {
216    path: GoModulePath,
217    version: GoModuleVersion,
218}
219
220impl GoModuleDependency {
221    /// Creates module dependency metadata.
222    #[must_use]
223    pub const fn new(path: GoModulePath, version: GoModuleVersion) -> Self {
224        Self { path, version }
225    }
226
227    /// Returns the dependency module path.
228    #[must_use]
229    pub const fn path(&self) -> &GoModulePath {
230        &self.path
231    }
232
233    /// Returns the dependency module version.
234    #[must_use]
235    pub const fn version(&self) -> &GoModuleVersion {
236        &self.version
237    }
238}
239
240/// Go module replacement metadata.
241#[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    /// Creates module replacement metadata.
251    #[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    /// Adds the old module version label.
262    #[must_use]
263    pub fn with_old_version(mut self, version: GoModuleVersion) -> Self {
264        self.old_version = Some(version);
265        self
266    }
267
268    /// Adds the replacement module version label.
269    #[must_use]
270    pub fn with_new_version(mut self, version: GoModuleVersion) -> Self {
271        self.new_version = Some(version);
272        self
273    }
274
275    /// Returns the replaced module path.
276    #[must_use]
277    pub const fn old_path(&self) -> &GoModulePath {
278        &self.old_path
279    }
280
281    /// Returns the replaced module version.
282    #[must_use]
283    pub const fn old_version(&self) -> Option<&GoModuleVersion> {
284        self.old_version.as_ref()
285    }
286
287    /// Returns the replacement module path.
288    #[must_use]
289    pub const fn new_path(&self) -> &GoModulePath {
290        &self.new_path
291    }
292
293    /// Returns the replacement module version.
294    #[must_use]
295    pub const fn new_version(&self) -> Option<&GoModuleVersion> {
296        self.new_version.as_ref()
297    }
298}
299
300/// Go module requirement metadata.
301#[derive(Clone, Debug, Eq, PartialEq)]
302pub struct GoModuleRequirement {
303    dependency: GoModuleDependency,
304    indirect: bool,
305}
306
307impl GoModuleRequirement {
308    /// Creates module requirement metadata.
309    #[must_use]
310    pub const fn new(dependency: GoModuleDependency) -> Self {
311        Self {
312            dependency,
313            indirect: false,
314        }
315    }
316
317    /// Marks the requirement as indirect.
318    #[must_use]
319    pub const fn indirect(mut self) -> Self {
320        self.indirect = true;
321        self
322    }
323
324    /// Returns the dependency metadata.
325    #[must_use]
326    pub const fn dependency(&self) -> &GoModuleDependency {
327        &self.dependency
328    }
329
330    /// Returns whether this requirement is indirect.
331    #[must_use]
332    pub const fn is_indirect(&self) -> bool {
333        self.indirect
334    }
335}
336
337/// Go module directive kind metadata.
338#[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    /// Returns the directive label.
351    #[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}