gem_audit/lockfile/
mod.rs1mod parser;
2mod ruby_version;
3
4pub use parser::parse;
5pub use ruby_version::RubyVersion;
6
7use thiserror::Error;
8
9#[derive(Debug, Clone)]
11pub struct Lockfile {
12 pub sources: Vec<Source>,
14 pub specs: Vec<GemSpec>,
16 pub platforms: Vec<String>,
18 pub dependencies: Vec<Dependency>,
20 pub ruby_version: Option<String>,
22 pub bundled_with: Option<String>,
24}
25
26impl Lockfile {
27 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 pub fn find_specs(&self, name: &str) -> Vec<&GemSpec> {
36 self.specs.iter().filter(|s| s.name == name).collect()
37 }
38
39 pub fn parsed_ruby_version(&self) -> Option<RubyVersion> {
43 self.ruby_version.as_deref().and_then(RubyVersion::parse)
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum Source {
50 Rubygems(RubygemsSource),
51 Git(GitSource),
52 Path(PathSource),
53}
54
55impl Source {
56 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#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct RubygemsSource {
69 pub remote: String,
70}
71
72#[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#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct PathSource {
84 pub remote: String,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct GemSpec {
90 pub name: String,
92 pub version: String,
94 pub platform: Option<String>,
96 pub dependencies: Vec<GemDependency>,
98 pub source_index: usize,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct GemDependency {
105 pub name: String,
107 pub requirement: Option<String>,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Dependency {
114 pub name: String,
116 pub requirement: Option<String>,
118 pub pinned: bool,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Error)]
124pub enum ParseError {
125 #[error("unexpected line at {line_number}: '{content}'")]
127 UnexpectedLine { line_number: usize, content: String },
128 #[error("missing field '{field}' in section '{section}'")]
130 MissingField { section: String, field: String },
131 #[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}