Skip to main content

upstream_ontologist/providers/
ruby.rs

1use 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
11/// Extracts upstream metadata from Ruby gemspec file
12pub 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/// Ruby gem metadata URIs
134#[derive(Deserialize)]
135pub struct RubygemMetadata {
136    /// Changelog URI
137    pub changelog_uri: Option<url::Url>,
138    /// Source code URI
139    pub source_code_uri: Option<url::Url>,
140}
141
142/// Ruby gem dependency information
143#[derive(Deserialize)]
144pub struct RubygemDependency {
145    /// Dependency name
146    pub name: String,
147    /// Version requirements
148    pub requirements: String,
149}
150
151/// Ruby gem dependencies collection
152#[derive(Deserialize)]
153pub struct RubygemDependencies {
154    /// Development dependencies
155    pub development: Vec<RubygemDependency>,
156    /// Runtime dependencies
157    pub runtime: Vec<RubygemDependency>,
158}
159
160/// Complete Ruby gem metadata
161#[derive(Deserialize)]
162pub struct Rubygem {
163    /// Gem name
164    pub name: String,
165    /// Total downloads
166    pub downloads: usize,
167    /// Version number
168    pub version: String,
169    /// Version creation timestamp
170    pub version_created_at: String,
171    /// Downloads for this version
172    pub version_downloads: usize,
173    /// Target platform
174    pub platform: String,
175    /// Authors
176    pub authors: String,
177    /// Gem description
178    pub info: String,
179    /// License information
180    pub licenses: Vec<String>,
181    /// Additional metadata URIs
182    pub metadata: RubygemMetadata,
183    /// Whether version is yanked
184    pub yanked: bool,
185    /// SHA hash
186    pub sha: String,
187    /// Spec SHA hash
188    pub spec_sha: String,
189    /// Project URI
190    pub project_uri: url::Url,
191    /// Gem download URI
192    pub gem_uri: url::Url,
193    /// Homepage URI
194    pub homepage_uri: Option<url::Url>,
195    /// Wiki URI
196    pub wiki_uri: Option<url::Url>,
197    /// Documentation URI
198    pub documentation_uri: Option<url::Url>,
199    /// Mailing list URI
200    pub mailing_list_uri: Option<url::Url>,
201    /// Source code URI
202    pub source_code_uri: Option<url::Url>,
203    /// Bug tracker URI
204    pub bug_tracker_uri: Option<url::Url>,
205    /// Changelog URI
206    pub changelog_uri: Option<url::Url>,
207    /// Funding URI
208    pub funding_uri: Option<url::Url>,
209    /// Dependencies
210    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
308/// Loads Ruby gem data from the RubyGems API
309pub 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
319/// Retrieves upstream metadata for a Ruby gem from RubyGems
320pub 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        // name should be skipped (array, not string), version and authors should parse
349        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}