upstream_ontologist/providers/
ruby.rs1use crate::{
2 Certainty, GuesserSettings, Person, ProviderError, UpstreamDatum, UpstreamDatumWithMetadata,
3 UpstreamMetadata,
4};
5use log::debug;
6use serde::Deserialize;
7use std::fs::File;
8use std::io::{BufRead, BufReader};
9use std::path::Path;
10
11pub async fn guess_from_gemspec(
13 path: &Path,
14 _settings: &GuesserSettings,
15) -> std::result::Result<Vec<UpstreamDatumWithMetadata>, ProviderError> {
16 let file = File::open(path)?;
17
18 let reader = BufReader::new(file);
19 let mut results = Vec::new();
20
21 #[derive(Debug)]
22 enum GemValue {
23 String(String),
24 Array(Vec<GemValue>),
25 }
26
27 impl GemValue {
28 fn as_str(&self) -> Option<&str> {
29 match self {
30 GemValue::String(s) => Some(s),
31 GemValue::Array(_) => None,
32 }
33 }
34
35 fn as_array(&self) -> Option<&Vec<GemValue>> {
36 match self {
37 GemValue::String(_) => None,
38 GemValue::Array(a) => Some(a),
39 }
40 }
41 }
42
43 fn parse_value(value: &str) -> Result<GemValue, String> {
44 let trimmed = value.trim();
45 if (trimmed.starts_with('"') && trimmed.ends_with('"'))
46 || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
47 {
48 return Ok(GemValue::String(trimmed[1..trimmed.len() - 1].to_string()));
49 } else if trimmed.starts_with('"') || trimmed.starts_with("'.freeze") {
50 return Ok(GemValue::String(trimmed[1..].to_string()));
51 } else if trimmed.starts_with('[') && trimmed.ends_with(']') {
52 let elements = trimmed[1..trimmed.len() - 1]
53 .split(',')
54 .map(parse_value)
55 .collect::<Result<Vec<GemValue>, _>>()?;
56 return Ok(GemValue::Array(elements));
57 }
58 Err(format!("Could not parse value: {}", value))
59 }
60
61 for line in reader.lines().map_while(Result::ok) {
62 if line.starts_with('#') {
63 continue;
64 }
65 if line.trim().is_empty() {
66 continue;
67 }
68 if line == "Gem::Specification.new do |s|\n" || line == "end\n" {
69 continue;
70 }
71 if let Some(line) = line.strip_prefix(" s.") {
72 let (key, rawval) = match line.split_once('=') {
73 Some((key, rawval)) => (key.trim(), rawval),
74 _ => continue,
75 };
76
77 let val = match parse_value(rawval.trim()) {
78 Ok(val) => val,
79 Err(_) => {
80 debug!("Could not parse value: {}", rawval);
81 continue;
82 }
83 };
84
85 match key {
86 "name" | "version" | "homepage" | "summary" | "description" | "license" => {
87 if let Some(s) = val.as_str() {
88 let datum = match key {
89 "name" => UpstreamDatum::Name(s.to_string()),
90 "version" => UpstreamDatum::Version(s.to_string()),
91 "homepage" => UpstreamDatum::Homepage(s.to_string()),
92 "summary" => UpstreamDatum::Summary(s.to_string()),
93 "description" => UpstreamDatum::Description(s.to_string()),
94 "license" => UpstreamDatum::License(s.to_string()),
95 _ => unreachable!(),
96 };
97 results.push(UpstreamDatumWithMetadata {
98 datum,
99 certainty: Some(Certainty::Certain),
100 origin: Some(path.into()),
101 });
102 } else {
103 debug!("gemspec: expected string for '{}', got {:?}", key, val);
104 }
105 }
106 "authors" => {
107 if let Some(arr) = val.as_array() {
108 let persons: Vec<Person> = arr
109 .iter()
110 .filter_map(|p| p.as_str().map(Person::from))
111 .collect();
112 if !persons.is_empty() {
113 results.push(UpstreamDatumWithMetadata {
114 datum: UpstreamDatum::Author(persons),
115 certainty: Some(Certainty::Certain),
116 origin: Some(path.into()),
117 });
118 }
119 } else {
120 debug!("gemspec: expected array for 'authors', got {:?}", val);
121 }
122 }
123 _ => debug!("unknown field {} ({:?}) in gemspec", key, val),
124 }
125 } else {
126 debug!("ignoring unparsable line in {}: {:?}", path.display(), line);
127 }
128 }
129
130 Ok(results)
131}
132
133#[derive(Deserialize)]
135pub struct RubygemMetadata {
136 pub changelog_uri: Option<url::Url>,
138 pub source_code_uri: Option<url::Url>,
140}
141
142#[derive(Deserialize)]
144pub struct RubygemDependency {
145 pub name: String,
147 pub requirements: String,
149}
150
151#[derive(Deserialize)]
153pub struct RubygemDependencies {
154 pub development: Vec<RubygemDependency>,
156 pub runtime: Vec<RubygemDependency>,
158}
159
160#[derive(Deserialize)]
162pub struct Rubygem {
163 pub name: String,
165 pub downloads: usize,
167 pub version: String,
169 pub version_created_at: String,
171 pub version_downloads: usize,
173 pub platform: String,
175 pub authors: String,
177 pub info: String,
179 pub licenses: Vec<String>,
181 pub metadata: RubygemMetadata,
183 pub yanked: bool,
185 pub sha: String,
187 pub spec_sha: String,
189 pub project_uri: url::Url,
191 pub gem_uri: url::Url,
193 pub homepage_uri: Option<url::Url>,
195 pub wiki_uri: Option<url::Url>,
197 pub documentation_uri: Option<url::Url>,
199 pub mailing_list_uri: Option<url::Url>,
201 pub source_code_uri: Option<url::Url>,
203 pub bug_tracker_uri: Option<url::Url>,
205 pub changelog_uri: Option<url::Url>,
207 pub funding_uri: Option<url::Url>,
209 pub dependencies: RubygemDependencies,
211}
212
213impl TryFrom<Rubygem> for UpstreamMetadata {
214 type Error = ProviderError;
215
216 fn try_from(gem: Rubygem) -> Result<Self, ProviderError> {
217 let mut metadata = UpstreamMetadata::default();
218 metadata.insert(UpstreamDatumWithMetadata {
219 datum: UpstreamDatum::Name(gem.name),
220 certainty: Some(Certainty::Certain),
221 origin: None,
222 });
223
224 metadata.insert(UpstreamDatumWithMetadata {
225 datum: UpstreamDatum::Version(gem.version),
226 certainty: Some(Certainty::Certain),
227 origin: None,
228 });
229
230 metadata.insert(UpstreamDatumWithMetadata {
231 datum: UpstreamDatum::Author(vec![Person::from(gem.authors.as_str())]),
232 certainty: Some(Certainty::Certain),
233 origin: None,
234 });
235
236 metadata.insert(UpstreamDatumWithMetadata {
237 datum: UpstreamDatum::Homepage(gem.homepage_uri.unwrap_or(gem.project_uri).to_string()),
238 certainty: Some(Certainty::Certain),
239 origin: None,
240 });
241
242 if let Some(wiki_uri) = gem.wiki_uri {
243 metadata.insert(UpstreamDatumWithMetadata {
244 datum: UpstreamDatum::Wiki(wiki_uri.to_string()),
245 certainty: Some(Certainty::Certain),
246 origin: None,
247 });
248 }
249
250 if let Some(mailing_list_uri) = gem.mailing_list_uri {
251 metadata.insert(UpstreamDatumWithMetadata {
252 datum: UpstreamDatum::MailingList(mailing_list_uri.to_string()),
253 certainty: Some(Certainty::Certain),
254 origin: None,
255 });
256 }
257
258 if let Some(bug_tracker_uri) = gem.bug_tracker_uri {
259 metadata.insert(UpstreamDatumWithMetadata {
260 datum: UpstreamDatum::BugDatabase(bug_tracker_uri.to_string()),
261 certainty: Some(Certainty::Certain),
262 origin: None,
263 });
264 }
265
266 if let Some(funding_uri) = gem.funding_uri {
267 metadata.insert(UpstreamDatumWithMetadata {
268 datum: UpstreamDatum::Funding(funding_uri.to_string()),
269 certainty: Some(Certainty::Certain),
270 origin: None,
271 });
272 }
273
274 if let Some(source_code_uri) = gem.source_code_uri {
275 metadata.insert(UpstreamDatumWithMetadata {
276 datum: UpstreamDatum::Repository(source_code_uri.to_string()),
277 certainty: Some(Certainty::Certain),
278 origin: None,
279 });
280 }
281
282 metadata.insert(UpstreamDatumWithMetadata {
283 datum: UpstreamDatum::License(gem.licenses.join(", ")),
284 certainty: Some(Certainty::Certain),
285 origin: None,
286 });
287
288 if let Some(documentation_uri) = gem.documentation_uri {
289 metadata.insert(UpstreamDatumWithMetadata {
290 datum: UpstreamDatum::Documentation(documentation_uri.to_string()),
291 certainty: Some(Certainty::Certain),
292 origin: None,
293 });
294 }
295
296 if let Some(changelog_uri) = gem.changelog_uri {
297 metadata.insert(UpstreamDatumWithMetadata {
298 datum: UpstreamDatum::Changelog(changelog_uri.to_string()),
299 certainty: Some(Certainty::Certain),
300 origin: None,
301 });
302 }
303
304 Ok(metadata)
305 }
306}
307
308pub async fn load_rubygem(name: &str) -> Result<Option<Rubygem>, ProviderError> {
310 let url: url::Url = format!("https://rubygems.org/api/v1/gems/{}.json", name)
311 .parse()
312 .map_err(|e: url::ParseError| ProviderError::Other(e.to_string()))?;
313 let data = crate::load_json_url(&url, None).await?;
314 let gem: Rubygem = serde_json::from_value(data)
315 .map_err(|e| ProviderError::ParseError(format!("Failed to parse rubygem data: {}", e)))?;
316 Ok(Some(gem))
317}
318
319pub async fn remote_rubygem_metadata(name: &str) -> Result<UpstreamMetadata, ProviderError> {
321 let gem = load_rubygem(name).await?;
322
323 match gem {
324 Some(gem) => gem.try_into(),
325 None => Ok(UpstreamMetadata::default()),
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 #[tokio::test]
332 async fn test_gemspec_array_where_string_expected() {
333 let td = tempfile::tempdir().unwrap();
334 let path = td.path().join("test.gemspec");
335 std::fs::write(
336 &path,
337 r#"Gem::Specification.new do |s|
338 s.name = ['not', 'a', 'string']
339 s.version = '1.0'
340 s.authors = ['Alice', 'Bob']
341end
342"#,
343 )
344 .unwrap();
345 let result = super::guess_from_gemspec(&path, &super::GuesserSettings::default())
346 .await
347 .unwrap();
348 assert!(!result
350 .iter()
351 .any(|d| matches!(&d.datum, super::UpstreamDatum::Name(_))));
352 assert!(result
353 .iter()
354 .any(|d| matches!(&d.datum, super::UpstreamDatum::Version(v) if v == "1.0")));
355 assert!(result
356 .iter()
357 .any(|d| matches!(&d.datum, super::UpstreamDatum::Author(_))));
358 }
359
360 #[test]
361 fn test_parse_gem() {
362 let gemspec = include_str!("../testdata/rubygem.json");
363
364 let gem: super::Rubygem = serde_json::from_str(gemspec).unwrap();
365
366 assert_eq!(gem.name, "bullet");
367 }
368}