Skip to main content

gem_audit/lockfile/
mod.rs

1mod parser;
2pub mod platform;
3mod ruby_version;
4
5pub use parser::parse;
6pub use ruby_version::RubyVersion;
7
8use thiserror::Error;
9
10/// A parsed Gemfile.lock file.
11#[derive(Debug, Clone)]
12pub struct Lockfile {
13    /// All gem sources (GEM, GIT, PATH sections).
14    pub sources: Vec<Source>,
15    /// All resolved gem specifications across all sources.
16    pub specs: Vec<GemSpec>,
17    /// Target platforms.
18    pub platforms: Vec<String>,
19    /// Top-level dependencies from the DEPENDENCIES section.
20    pub dependencies: Vec<Dependency>,
21    /// Ruby version constraint, if specified.
22    pub ruby_version: Option<String>,
23    /// Bundler version that generated this lockfile.
24    pub bundled_with: Option<String>,
25}
26
27impl Lockfile {
28    /// Find a gem spec by name. Returns the first match (without platform suffix).
29    pub fn find_spec(&self, name: &str) -> Option<&GemSpec> {
30        self.specs
31            .iter()
32            .find(|s| s.name == name && s.platform.is_none())
33    }
34
35    /// Find all gem specs by name (including platform variants).
36    pub fn find_specs(&self, name: &str) -> Vec<&GemSpec> {
37        self.specs.iter().filter(|s| s.name == name).collect()
38    }
39
40    /// Parse the `RUBY VERSION` section into a `RubyVersion`.
41    ///
42    /// Returns `None` if the section is absent or cannot be parsed.
43    pub fn parsed_ruby_version(&self) -> Option<RubyVersion> {
44        self.ruby_version.as_deref().and_then(RubyVersion::parse)
45    }
46}
47
48/// A gem source section (GEM, GIT, or PATH).
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum Source {
51    Rubygems(RubygemsSource),
52    Git(GitSource),
53    Path(PathSource),
54}
55
56impl Source {
57    /// Returns the remote URL/path of this source.
58    pub fn remote(&self) -> &str {
59        match self {
60            Source::Rubygems(s) => &s.remote,
61            Source::Git(s) => &s.remote,
62            Source::Path(s) => &s.remote,
63        }
64    }
65}
66
67/// A RubyGems source (GEM section).
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct RubygemsSource {
70    pub remote: String,
71}
72
73/// A Git source (GIT section).
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct GitSource {
76    pub remote: String,
77    pub revision: Option<String>,
78    pub branch: Option<String>,
79    pub tag: Option<String>,
80}
81
82/// A local path source (PATH section).
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct PathSource {
85    pub remote: String,
86}
87
88/// A resolved gem specification from the lockfile.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct GemSpec {
91    /// Gem name (e.g., "rails").
92    pub name: String,
93    /// Resolved version string (e.g., "5.2.8").
94    pub version: String,
95    /// Platform suffix, if any (e.g., "x86_64-linux").
96    pub platform: Option<String>,
97    /// Direct dependencies of this gem.
98    pub dependencies: Vec<GemDependency>,
99    /// Which source this gem came from.
100    pub source_index: usize,
101}
102
103/// A dependency of a resolved gem (sub-dependency with version constraints).
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct GemDependency {
106    /// Dependency gem name.
107    pub name: String,
108    /// Version constraint string (e.g., "~> 2.0, >= 2.0.8"), or None if unconstrained.
109    pub requirement: Option<String>,
110}
111
112/// A top-level dependency from the DEPENDENCIES section.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct Dependency {
115    /// Gem name.
116    pub name: String,
117    /// Version constraint, if specified.
118    pub requirement: Option<String>,
119    /// Whether this dependency was pinned with `!` in the Gemfile.
120    pub pinned: bool,
121}
122
123/// Errors that can occur while parsing a Gemfile.lock.
124#[derive(Debug, Clone, PartialEq, Eq, Error)]
125pub enum ParseError {
126    /// An unexpected line was encountered.
127    #[error("unexpected line at {line_number}: '{content}'")]
128    UnexpectedLine { line_number: usize, content: String },
129    /// A required field was missing.
130    #[error("missing field '{field}' in section '{section}'")]
131    MissingField { section: String, field: String },
132    /// The file is empty or contains no parseable content.
133    #[error("empty or unparseable lockfile")]
134    Empty,
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn sample_lockfile() -> Lockfile {
142        let input = "\
143GEM
144  remote: https://rubygems.org/
145  specs:
146    rack (2.2.0)
147    rack (2.2.0-x86_64-linux)
148    json (2.6.0)
149
150PLATFORMS
151  ruby
152
153DEPENDENCIES
154  rack
155";
156        parse(input).unwrap()
157    }
158
159    #[test]
160    fn source_remote_rubygems() {
161        let src = Source::Rubygems(RubygemsSource {
162            remote: "https://rubygems.org/".to_string(),
163        });
164        assert_eq!(src.remote(), "https://rubygems.org/");
165    }
166
167    #[test]
168    fn source_remote_git() {
169        let src = Source::Git(GitSource {
170            remote: "git://github.com/foo/bar.git".to_string(),
171            revision: None,
172            branch: None,
173            tag: None,
174        });
175        assert_eq!(src.remote(), "git://github.com/foo/bar.git");
176    }
177
178    #[test]
179    fn source_remote_path() {
180        let src = Source::Path(PathSource {
181            remote: ".".to_string(),
182        });
183        assert_eq!(src.remote(), ".");
184    }
185
186    #[test]
187    fn find_spec_returns_platformless() {
188        let lockfile = sample_lockfile();
189        let spec = lockfile.find_spec("rack").unwrap();
190        assert_eq!(spec.version, "2.2.0");
191        assert!(spec.platform.is_none());
192    }
193
194    #[test]
195    fn find_spec_nonexistent() {
196        let lockfile = sample_lockfile();
197        assert!(lockfile.find_spec("nonexistent").is_none());
198    }
199
200    #[test]
201    fn find_specs_returns_all_variants() {
202        let lockfile = sample_lockfile();
203        let specs = lockfile.find_specs("rack");
204        assert_eq!(specs.len(), 2);
205    }
206
207    #[test]
208    fn find_specs_nonexistent() {
209        let lockfile = sample_lockfile();
210        let specs = lockfile.find_specs("nonexistent");
211        assert!(specs.is_empty());
212    }
213
214    #[test]
215    fn parse_error_unexpected_line_display() {
216        let err = ParseError::UnexpectedLine {
217            line_number: 42,
218            content: "bad line".to_string(),
219        };
220        assert_eq!(err.to_string(), "unexpected line at 42: 'bad line'");
221    }
222
223    #[test]
224    fn parse_error_missing_field_display() {
225        let err = ParseError::MissingField {
226            section: "GEM".to_string(),
227            field: "remote".to_string(),
228        };
229        assert_eq!(err.to_string(), "missing field 'remote' in section 'GEM'");
230    }
231
232    #[test]
233    fn parse_error_empty_display() {
234        let err = ParseError::Empty;
235        assert_eq!(err.to_string(), "empty or unparseable lockfile");
236    }
237}