Skip to main content

gem_audit/lockfile/
mod.rs

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