1use crate::plugin::{ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta};
29use std::collections::HashMap;
30
31fn parse_coords(path: &str, extensions: &[&str]) -> (String, Option<String>) {
38 let filename = path.rsplit('/').next().unwrap_or(path);
39 let stem = extensions
40 .iter()
41 .find_map(|ext| filename.strip_suffix(ext))
42 .unwrap_or(filename);
43
44 let mut split_pos = None;
45 for (i, c) in stem.char_indices() {
46 if matches!(c, '-' | '_' | '@') {
47 if let Some(next) = stem[i + 1..].chars().next() {
48 if next.is_ascii_digit() {
49 split_pos = Some(i);
50 }
51 }
52 }
53 }
54
55 match split_pos {
56 Some(pos) => (stem[..pos].to_string(), Some(stem[pos + 1..].to_string())),
57 None => (stem.to_string(), None),
58 }
59}
60
61macro_rules! skeleton_handler {
64 (
65 struct: $struct:ident,
66 name: $name:literal,
67 type_id: $tid:literal,
68 aliases: [$($alias:literal),* $(,)?],
69 ecosystem: $eco:literal,
70 extensions: [$($ext:literal),* $(,)?],
71 description: $desc:literal $(,)?
72 ) => {
73 #[doc = concat!("Skeleton handler for the ", $eco, " ecosystem (stub).")]
74 pub struct $struct;
75
76 impl $struct {
77 const EXTENSIONS: &'static [&'static str] = &[$($ext),*];
78 }
79
80 impl ArchiveTypePlugin for $struct {
81 fn name(&self) -> &str { $name }
82
83 fn type_id(&self) -> i8 { $tid }
84
85 fn meta(&self) -> HandlerMeta {
86 HandlerMeta {
87 name: $name.into(),
88 aliases: vec![$($alias.into()),*],
89 type_id: $tid,
90 ecosystem: $eco.into(),
91 extensions: vec![$($ext.into()),*],
92 description: concat!($desc, " (skeleton — filename-only)").into(),
93 commands: vec![
94 HandlerCommand::new(
95 "coords",
96 concat!("Print ", $name, " package name + version parsed from a path"),
97 ),
98 ],
99 }
100 }
101
102 fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
103 match cmd {
104 "coords" => {
105 let path = args.first().ok_or_else(|| {
106 anyhow::anyhow!(concat!("usage: ", $name, " coords <file>"))
107 })?;
108 let (name, version) = parse_coords(path, Self::EXTENSIONS);
109 match version {
110 Some(v) => println!("{} {}", name, v),
111 None => println!("{}", name),
112 }
113 Ok(())
114 }
115 other => anyhow::bail!(concat!($name, ": unknown subcommand '{}'"), other),
116 }
117 }
118
119 fn matches_path(&self, path: &str) -> bool {
120 Self::EXTENSIONS.iter().any(|ext| path.ends_with(ext))
121 }
122
123 fn extract_metadata(&self, path: &str, _data: &[u8]) -> Option<ExtensionRow> {
124 let (name, version) = parse_coords(path, Self::EXTENSIONS);
125 let mut fields = HashMap::new();
126 fields.insert("name".into(), ExtensionValue::Str(name));
127 fields.insert(
128 "version".into(),
129 ExtensionValue::OptStr(version),
130 );
131 Some(ExtensionRow { fields })
132 }
133 }
134 };
135}
136
137skeleton_handler! {
138 struct: GoPlugin,
139 name: "go",
140 type_id: 4,
141 aliases: ["golang"],
142 ecosystem: "Go / Go modules (proxy.golang.org)",
143 extensions: [".zip", ".mod", ".info"],
144 description: "Go module zips published to the module proxy",
145}
146
147skeleton_handler! {
148 struct: NugetPlugin,
149 name: "nuget",
150 type_id: 5,
151 aliases: ["dotnet", ".net"],
152 ecosystem: ".NET / NuGet (nuget.org)",
153 extensions: [".nupkg", ".snupkg"],
154 description: ".NET NuGet packages (zip with a .nuspec manifest)",
155}
156
157skeleton_handler! {
162 struct: ElfPlugin,
163 name: "elf",
164 type_id: 7,
165 aliases: ["binary", "so"],
166 ecosystem: "Linux ELF binaries / shared objects",
167 extensions: [".elf", ".so", ".bin", ".out"],
168 description: "Raw ELF executables / shared libraries (no embedded version)",
169}
170
171skeleton_handler! {
181 struct: FlatpakPlugin,
182 name: "flatpak",
183 type_id: 10,
184 aliases: ["flatpakref"],
185 ecosystem: "Flatpak bundles (Flathub)",
186 extensions: [".flatpak", ".flatpakref"],
187 description: "Flatpak single-file application bundles (OSTree)",
188}
189
190skeleton_handler! {
195 struct: DockerPlugin,
196 name: "docker",
197 type_id: 12,
198 aliases: ["oci", "container", "image"],
199 ecosystem: "OCI / Docker container images",
200 extensions: [".oci", ".docker"],
201 description: "OCI image layouts / docker save tarballs (manifest + layers)",
202}
203
204skeleton_handler! {
205 struct: HelmPlugin,
206 name: "helm",
207 type_id: 13,
208 aliases: ["chart", "k8s"],
209 ecosystem: "Helm charts (Kubernetes / Artifact Hub)",
210 extensions: [".tgz"],
211 description: "Helm chart archives (Chart.yaml + templates)",
212}
213
214skeleton_handler! {
220 struct: SnapPlugin,
221 name: "snap",
222 type_id: 15,
223 aliases: ["snapcraft", "snapd"],
224 ecosystem: "Snap packages (Snapcraft / Snap Store)",
225 extensions: [".snap"],
226 description: "Snap application packages (squashfs image)",
227}
228
229skeleton_handler! {
230 struct: AppImagePlugin,
231 name: "appimage",
232 type_id: 16,
233 aliases: ["appdir"],
234 ecosystem: "AppImage portable Linux applications",
235 extensions: [".AppImage", ".appimage"],
236 description: "AppImage self-mounting application bundles (ELF + squashfs)",
237}
238
239skeleton_handler! {
240 struct: ComposerPlugin,
241 name: "composer",
242 type_id: 17,
243 aliases: ["php", "packagist"],
244 ecosystem: "PHP / Composer (Packagist)",
245 extensions: [".zip", ".phar"],
246 description: "Composer/PHP packages (zip with composer.json, or PHAR)",
247}
248
249skeleton_handler! {
250 struct: HexPlugin,
251 name: "hex",
252 type_id: 18,
253 aliases: ["elixir", "erlang", "mix"],
254 ecosystem: "Erlang / Elixir (hex.pm)",
255 extensions: [".tar"],
256 description: "Hex packages (tar of metadata + contents.tar.gz)",
257}
258
259skeleton_handler! {
260 struct: CabalPlugin,
261 name: "cabal",
262 type_id: 19,
263 aliases: ["haskell", "hackage"],
264 ecosystem: "Haskell / Cabal (Hackage)",
265 extensions: [".tar.gz"],
266 description: "Cabal source packages (sdist tarball with a .cabal file)",
267}
268
269skeleton_handler! {
270 struct: SwiftPlugin,
271 name: "swift",
272 type_id: 20,
273 aliases: ["swiftpm", "spm"],
274 ecosystem: "Swift / Swift Package Manager",
275 extensions: [".zip"],
276 description: "Swift package archives (Package.swift + sources)",
277}
278
279skeleton_handler! {
280 struct: ParquetPlugin,
281 name: "parquet",
282 type_id: 21,
283 aliases: ["geoparquet", "pq"],
284 ecosystem: "Apache Parquet / GeoParquet columnar data",
285 extensions: [".parquet", ".geoparquet"],
286 description: "Parquet column files (GeoParquet adds a geo metadata key)",
287}
288
289skeleton_handler! {
290 struct: DatasetPlugin,
291 name: "dataset",
292 type_id: 22,
293 aliases: ["hf", "huggingface", "hub"],
294 ecosystem: "ML datasets (Hugging Face Hub / Croissant)",
295 extensions: [".dataset", ".hf"],
296 description: "Versioned ML dataset bundles (data shards + dataset card)",
297}
298
299skeleton_handler! {
300 struct: ArrowPlugin,
301 name: "arrow",
302 type_id: 23,
303 aliases: ["ipc", "feather"],
304 ecosystem: "Apache Arrow IPC / Feather",
305 extensions: [".arrow", ".feather", ".ipc"],
306 description: "Arrow IPC record-batch files (zero-copy columnar)",
307}
308
309skeleton_handler! {
310 struct: GeoJsonPlugin,
311 name: "geojson",
312 type_id: 24,
313 aliases: ["geo", "gis", "shapefile"],
314 ecosystem: "GIS vector data (GeoJSON / Shapefile / GeoPackage)",
315 extensions: [".geojson", ".gpkg", ".shp", ".fgb"],
316 description: "Geospatial vector datasets (features + CRS)",
317}
318
319pub fn skeleton_handlers() -> Vec<Box<dyn ArchiveTypePlugin>> {
322 vec![
323 Box::new(GoPlugin),
324 Box::new(NugetPlugin),
325 Box::new(ElfPlugin),
326 Box::new(FlatpakPlugin),
327 Box::new(DockerPlugin),
328 Box::new(HelmPlugin),
329 Box::new(SnapPlugin),
330 Box::new(AppImagePlugin),
331 Box::new(ComposerPlugin),
332 Box::new(HexPlugin),
333 Box::new(CabalPlugin),
334 Box::new(SwiftPlugin),
335 Box::new(ParquetPlugin),
336 Box::new(DatasetPlugin),
337 Box::new(ArrowPlugin),
338 Box::new(GeoJsonPlugin),
339 ]
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn parse_coords_splits_name_and_version() {
348 assert_eq!(
349 parse_coords("foo/bar-1.2.3.nupkg", &[".nupkg"]),
350 ("bar".to_string(), Some("1.2.3".to_string()))
351 );
352 assert_eq!(
353 parse_coords("lodash-4.17.21.tgz", &[".tgz"]),
354 ("lodash".to_string(), Some("4.17.21".to_string()))
355 );
356 }
357
358 #[test]
359 fn parse_coords_handles_no_version() {
360 assert_eq!(
361 parse_coords("/usr/bin/ls.elf", &[".elf"]),
362 ("ls".to_string(), None)
363 );
364 }
365
366 #[test]
367 fn skeletons_have_unique_sequential_type_ids() {
368 let mut ids: Vec<i8> = skeleton_handlers().iter().map(|h| h.type_id()).collect();
369 let mut sorted = ids.clone();
370 sorted.sort();
371 sorted.dedup();
372 assert_eq!(ids.len(), sorted.len(), "type_ids must be unique");
373 ids.sort();
374 assert_eq!(*ids.first().unwrap(), 4, "skeletons start at type_id 4");
375 assert_eq!(*ids.last().unwrap(), 24);
376 }
377
378 #[test]
379 fn every_skeleton_matches_and_extracts() {
380 for h in skeleton_handlers() {
381 let m = h.meta();
382 assert!(!m.extensions.is_empty(), "{} has no extensions", m.name);
383 let sample = format!("pkg-1.0{}", m.extensions[0]);
384 assert!(h.matches_path(&sample), "{} should match {}", m.name, sample);
385 let row = h.extract_metadata(&sample, &[]).expect("row");
386 assert!(row.fields.contains_key("name"));
387 }
388 }
389}