Skip to main content

normalize_package_index/index/
luarocks.rs

1//! LuaRocks package index fetcher (Lua).
2//!
3//! Fetches package metadata from LuaRocks. Uses the manifest file for package
4//! listing and individual rockspec files for metadata.
5//!
6//! ## API Strategy
7//! - **fetch**: `luarocks.org/manifests/root/{name}-{version}.rockspec`
8//! - **fetch_versions**: Parses `luarocks.org/manifest` (Lua table format)
9//! - **search**: Not supported (would require HTML scraping)
10//! - **fetch_all**: Parses manifest file (cached 1 hour)
11
12use super::{IndexError, PackageIndex, PackageMeta, VersionMeta};
13
14/// LuaRocks package index fetcher.
15pub struct LuaRocks;
16
17impl LuaRocks {
18    /// LuaRocks base URL.
19    const BASE_URL: &'static str = "https://luarocks.org";
20
21    /// Parse Lua table-like manifest to extract versions for a package.
22    fn parse_manifest(content: &str, name: &str) -> Option<Vec<String>> {
23        // Look for the package in the repository table
24        // Format can be either:
25        // - ["package-name"] = { ... }  (for names with special chars)
26        // - package_name = { ... }       (for simple names)
27
28        // Try multiple formats - luarocks manifest can have various indent levels
29        // Format 1: ["package-name"] = { (for names with special chars)
30        // Format 2: package_name = { (for simple names, various indentation)
31        let quoted_search = format!("[\"{}\"] = {{", name);
32        let simple_search = format!("{} = {{", name);
33
34        let start = content
35            .find(&quoted_search)
36            .or_else(|| content.find(&simple_search))?;
37
38        let rest = &content[start..];
39
40        // Find the opening brace
41        let brace_pos = rest.find('{')?;
42        let after_brace = &rest[brace_pos + 1..];
43
44        // Extract version strings - they appear as ["version"] = { ... }
45        let mut versions = Vec::new();
46        let mut pos = 0;
47
48        while let Some(find_start) = after_brace[pos..].find("[\"") {
49            let version_start = pos + find_start + 2;
50            if let Some(end) = after_brace[version_start..].find("\"]") {
51                let version = &after_brace[version_start..version_start + end];
52                // Check if this is a version at depth 1 (directly under the package)
53                // by counting braces. We started after the main opening brace,
54                // so we're at depth 1 when opens == closes (all nested braces closed)
55                let prefix = &after_brace[..version_start];
56                let open_braces = prefix.matches('{').count();
57                let close_braces = prefix.matches('}').count();
58                if open_braces == close_braces {
59                    versions.push(version.to_string());
60                }
61                pos = version_start + end + 2;
62            } else {
63                break;
64            }
65
66            // Stop if we've exited this package's block
67            let so_far = &after_brace[..pos];
68            let opens = so_far.matches('{').count();
69            let closes = so_far.matches('}').count();
70            if closes > opens {
71                break;
72            }
73        }
74
75        if versions.is_empty() {
76            None
77        } else {
78            Some(versions)
79        }
80    }
81}
82
83impl PackageIndex for LuaRocks {
84    fn ecosystem(&self) -> &'static str {
85        "luarocks"
86    }
87
88    fn display_name(&self) -> &'static str {
89        "LuaRocks (Lua)"
90    }
91
92    fn fetch(&self, name: &str) -> Result<PackageMeta, IndexError> {
93        // Fetch the manifest to get version info
94        let manifest_url = format!("{}/manifest", Self::BASE_URL);
95        let manifest = ureq::get(&manifest_url)
96            .call()?
97            .into_string()
98            .map_err(|e| IndexError::Io(e))?;
99
100        let versions = Self::parse_manifest(&manifest, name)
101            .ok_or_else(|| IndexError::NotFound(name.to_string()))?;
102
103        // Get latest version (filter out scm/dev, sort by semver)
104        let latest = versions
105            .iter()
106            .filter(|v| !v.contains("scm") && !v.contains("dev"))
107            .max_by(|a, b| version_compare(a, b))
108            .or(versions.first())
109            .ok_or_else(|| IndexError::NotFound(name.to_string()))?;
110
111        // Extract version number (remove revision suffix like "-1")
112        let version_clean = latest.rsplit_once('-').map(|(v, _)| v).unwrap_or(latest);
113
114        Ok(PackageMeta {
115            name: name.to_string(),
116            version: version_clean.to_string(),
117            description: None, // Would require HTML scraping
118            homepage: Some(format!("{}/modules/{}/{}", Self::BASE_URL, name, name)),
119            repository: None,
120            license: None,
121            binaries: Vec::new(),
122            keywords: Vec::new(),
123            maintainers: Vec::new(),
124            published: None,
125            downloads: None,
126            archive_url: Some(format!("{}/{}-{}.src.rock", Self::BASE_URL, name, latest)),
127            checksum: None,
128            extra: Default::default(),
129        })
130    }
131
132    fn fetch_versions(&self, name: &str) -> Result<Vec<VersionMeta>, IndexError> {
133        let manifest_url = format!("{}/manifest", Self::BASE_URL);
134        let manifest = ureq::get(&manifest_url)
135            .call()?
136            .into_string()
137            .map_err(|e| IndexError::Io(e))?;
138
139        let versions = Self::parse_manifest(&manifest, name)
140            .ok_or_else(|| IndexError::NotFound(name.to_string()))?;
141
142        let mut result: Vec<VersionMeta> = versions
143            .iter()
144            .map(|v| {
145                let version_clean = v.rsplit_once('-').map(|(ver, _)| ver).unwrap_or(v);
146                VersionMeta {
147                    version: version_clean.to_string(),
148                    released: None,
149                    yanked: false,
150                }
151            })
152            .collect();
153
154        // Sort descending
155        result.sort_by(|a, b| version_compare(&b.version, &a.version));
156
157        // Deduplicate (multiple rock revisions for same version)
158        result.dedup_by(|a, b| a.version == b.version);
159
160        Ok(result)
161    }
162
163    fn search(&self, _query: &str) -> Result<Vec<PackageMeta>, IndexError> {
164        // LuaRocks doesn't have a JSON search API
165        // Would require HTML scraping
166        Err(IndexError::Parse(
167            "LuaRocks search requires HTML scraping (not implemented)".into(),
168        ))
169    }
170}
171
172/// Simple version comparison.
173fn version_compare(a: &str, b: &str) -> std::cmp::Ordering {
174    let parse = |s: &str| -> Vec<u32> {
175        s.split(|c: char| !c.is_ascii_digit())
176            .filter_map(|p| p.parse().ok())
177            .collect()
178    };
179    parse(a).cmp(&parse(b))
180}