gem_audit/lockfile/
mod.rs1mod parser;
2pub mod platform;
3mod ruby_version;
4
5pub use parser::parse;
6pub use ruby_version::RubyVersion;
7
8use thiserror::Error;
9
10#[derive(Debug, Clone)]
12pub struct Lockfile {
13 pub sources: Vec<Source>,
15 pub specs: Vec<GemSpec>,
17 pub platforms: Vec<String>,
19 pub dependencies: Vec<Dependency>,
21 pub ruby_version: Option<String>,
23 pub bundled_with: Option<String>,
25}
26
27impl Lockfile {
28 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 pub fn find_specs(&self, name: &str) -> Vec<&GemSpec> {
37 self.specs.iter().filter(|s| s.name == name).collect()
38 }
39
40 pub fn parsed_ruby_version(&self) -> Option<RubyVersion> {
44 self.ruby_version.as_deref().and_then(RubyVersion::parse)
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum Source {
51 Rubygems(RubygemsSource),
52 Git(GitSource),
53 Path(PathSource),
54}
55
56impl Source {
57 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#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct RubygemsSource {
70 pub remote: String,
71}
72
73#[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#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct PathSource {
85 pub remote: String,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct GemSpec {
91 pub name: String,
93 pub version: String,
95 pub platform: Option<String>,
97 pub dependencies: Vec<GemDependency>,
99 pub source_index: usize,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct GemDependency {
106 pub name: String,
108 pub requirement: Option<String>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct Dependency {
115 pub name: String,
117 pub requirement: Option<String>,
119 pub pinned: bool,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Error)]
125pub enum ParseError {
126 #[error("unexpected line at {line_number}: '{content}'")]
128 UnexpectedLine { line_number: usize, content: String },
129 #[error("missing field '{field}' in section '{section}'")]
131 MissingField { section: String, field: String },
132 #[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}