Skip to main content

agm_core/import/
constraint.rs

1//! Version constraint validation for AGM imports (spec S10.2).
2
3use semver::VersionReq;
4
5use crate::error::{AgmError, ErrorCode, ErrorLocation};
6use crate::model::imports::ImportEntry;
7
8// ---------------------------------------------------------------------------
9// ValidatedImport
10// ---------------------------------------------------------------------------
11
12/// An import entry whose version constraint has been validated as a semver range.
13/// If the original ImportEntry had no version constraint, `version_req` is None
14/// (meaning "any version matches").
15#[derive(Debug, Clone)]
16pub struct ValidatedImport {
17    /// The original import entry (package name + raw constraint string).
18    pub entry: ImportEntry,
19    /// The parsed semver version requirement, or None if no constraint was specified.
20    pub version_req: Option<semver::VersionReq>,
21}
22
23impl ValidatedImport {
24    /// Returns the package name.
25    #[must_use]
26    pub fn package(&self) -> &str {
27        &self.entry.package
28    }
29
30    /// Returns true if the given version satisfies this import's constraint.
31    /// If no constraint was specified, any version matches.
32    #[must_use]
33    pub fn matches_version(&self, version: &semver::Version) -> bool {
34        match &self.version_req {
35            Some(req) => req.matches(version),
36            None => true,
37        }
38    }
39}
40
41// ---------------------------------------------------------------------------
42// Functions
43// ---------------------------------------------------------------------------
44
45/// Parses a version constraint string into a semver::VersionReq.
46///
47/// Thin wrapper around `semver::VersionReq::parse()` that maps parse failures
48/// to AgmError with appropriate context.
49pub fn parse_version_constraint(constraint: &str) -> Result<VersionReq, AgmError> {
50    let trimmed = constraint.trim();
51    semver::VersionReq::parse(trimmed).map_err(|e| {
52        AgmError::new(
53            ErrorCode::I002,
54            format!("Invalid version constraint: `{constraint}` ({e})"),
55            ErrorLocation::default(),
56        )
57    })
58}
59
60/// Validates an ImportEntry's version constraint string as a semver range.
61///
62/// If the entry has no version constraint (`None`), returns a ValidatedImport
63/// with `version_req: None` (any version matches).
64///
65/// If the constraint string is present but not a valid semver range, returns
66/// an error using error code I002.
67///
68/// This function does NOT modify the original ImportEntry.
69pub fn validate_import(entry: &ImportEntry) -> Result<ValidatedImport, AgmError> {
70    match &entry.version_constraint {
71        None => Ok(ValidatedImport {
72            entry: entry.clone(),
73            version_req: None,
74        }),
75        Some(constraint) => {
76            let req = parse_version_constraint(constraint)?;
77            Ok(ValidatedImport {
78                entry: entry.clone(),
79                version_req: Some(req),
80            })
81        }
82    }
83}
84
85/// Validates all imports in a header, returning validated imports and any errors.
86///
87/// Errors are collected (not short-circuited): all imports are attempted.
88/// Returns (validated_imports, errors).
89pub fn validate_all_imports(imports: &[ImportEntry]) -> (Vec<ValidatedImport>, Vec<AgmError>) {
90    let mut validated = Vec::new();
91    let mut errors = Vec::new();
92
93    for entry in imports {
94        match validate_import(entry) {
95            Ok(v) => validated.push(v),
96            Err(e) => errors.push(e),
97        }
98    }
99
100    (validated, errors)
101}
102
103// ---------------------------------------------------------------------------
104// Tests
105// ---------------------------------------------------------------------------
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::error::ErrorCode;
111    use crate::model::imports::ImportEntry;
112
113    // Category A: Version constraint parsing
114
115    #[test]
116    fn test_parse_version_constraint_caret_returns_req() {
117        let req = parse_version_constraint("^1.0.0").unwrap();
118        assert!(req.matches(&semver::Version::parse("1.2.3").unwrap()));
119        assert!(!req.matches(&semver::Version::parse("2.0.0").unwrap()));
120    }
121
122    #[test]
123    fn test_parse_version_constraint_tilde_returns_req() {
124        let req = parse_version_constraint("~1.2.0").unwrap();
125        assert!(req.matches(&semver::Version::parse("1.2.5").unwrap()));
126        assert!(!req.matches(&semver::Version::parse("1.3.0").unwrap()));
127    }
128
129    #[test]
130    fn test_parse_version_constraint_exact_returns_req() {
131        // semver crate: a bare "2.0.0" is treated as "^2.0.0" (caret default).
132        // Use "=2.0.0" for an exact-match requirement.
133        let req = parse_version_constraint("=2.0.0").unwrap();
134        assert!(req.matches(&semver::Version::parse("2.0.0").unwrap()));
135        assert!(!req.matches(&semver::Version::parse("2.0.1").unwrap()));
136    }
137
138    #[test]
139    fn test_parse_version_constraint_wildcard_returns_req() {
140        let req = parse_version_constraint("1.*").unwrap();
141        assert!(req.matches(&semver::Version::parse("1.0.0").unwrap()));
142        assert!(req.matches(&semver::Version::parse("1.9.9").unwrap()));
143        assert!(!req.matches(&semver::Version::parse("2.0.0").unwrap()));
144    }
145
146    #[test]
147    fn test_parse_version_constraint_invalid_returns_error() {
148        let err = parse_version_constraint("not_semver").unwrap_err();
149        assert_eq!(err.code, ErrorCode::I002);
150    }
151
152    // Category B: Import validation
153
154    #[test]
155    fn test_validate_import_with_constraint_returns_validated() {
156        let entry = ImportEntry::new("shared.security".to_owned(), Some("^1.0.0".to_owned()));
157        let result = validate_import(&entry).unwrap();
158        assert_eq!(result.package(), "shared.security");
159        assert!(result.version_req.is_some());
160    }
161
162    #[test]
163    fn test_validate_import_without_constraint_returns_none_req() {
164        let entry = ImportEntry::new("shared.http".to_owned(), None);
165        let result = validate_import(&entry).unwrap();
166        assert_eq!(result.package(), "shared.http");
167        assert!(result.version_req.is_none());
168    }
169
170    #[test]
171    fn test_validate_import_invalid_constraint_returns_error() {
172        let entry = ImportEntry::new("pkg".to_owned(), Some("bogus".to_owned()));
173        let err = validate_import(&entry).unwrap_err();
174        assert_eq!(err.code, ErrorCode::I002);
175    }
176
177    #[test]
178    fn test_validate_all_imports_mixed_returns_partial() {
179        let imports = vec![
180            ImportEntry::new("shared.security".to_owned(), Some("^1.0.0".to_owned())),
181            ImportEntry::new("bad.pkg".to_owned(), Some("bogus".to_owned())),
182        ];
183        let (validated, errors) = validate_all_imports(&imports);
184        assert_eq!(validated.len(), 1);
185        assert_eq!(errors.len(), 1);
186        assert_eq!(validated[0].package(), "shared.security");
187        assert_eq!(errors[0].code, ErrorCode::I002);
188    }
189
190    // Category C: ValidatedImport::matches_version
191
192    #[test]
193    fn test_matches_version_with_caret_matching_returns_true() {
194        let entry = ImportEntry::new("pkg".to_owned(), Some("^1.0.0".to_owned()));
195        let validated = validate_import(&entry).unwrap();
196        let version = semver::Version::parse("1.5.0").unwrap();
197        assert!(validated.matches_version(&version));
198    }
199
200    #[test]
201    fn test_matches_version_with_caret_not_matching_returns_false() {
202        let entry = ImportEntry::new("pkg".to_owned(), Some("^1.0.0".to_owned()));
203        let validated = validate_import(&entry).unwrap();
204        let version = semver::Version::parse("2.0.0").unwrap();
205        assert!(!validated.matches_version(&version));
206    }
207
208    #[test]
209    fn test_matches_version_none_constraint_returns_true() {
210        let entry = ImportEntry::new("pkg".to_owned(), None);
211        let validated = validate_import(&entry).unwrap();
212        let version = semver::Version::parse("99.99.99").unwrap();
213        assert!(validated.matches_version(&version));
214    }
215}