1use std::collections::HashMap;
35use std::path::Path;
36
37use packageurl::PackageUrl;
38use serde_json::Value as JsonValue;
39
40use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
41use crate::parser_warn as warn;
42
43use super::PackageParser;
44use super::license_normalization::normalize_spdx_declared_license;
45use super::utils::{CappedIterExt, read_file_to_string, truncate_field};
46
47pub struct HuggingfaceModelCardParser;
49
50pub struct HuggingfaceConfigParser;
52
53pub struct HuggingfaceModelIndexParser;
55
56impl PackageParser for HuggingfaceModelCardParser {
57 const PACKAGE_TYPE: PackageType = PackageType::Huggingface;
58
59 fn is_match(path: &Path) -> bool {
60 path.file_name()
61 .and_then(|name| name.to_str())
62 .is_some_and(|name| name.eq_ignore_ascii_case("README.md"))
63 }
64
65 fn extract_packages(path: &Path) -> Vec<PackageData> {
66 let content = match read_file_to_string(path, None) {
67 Ok(content) => content,
68 Err(error) => {
69 warn!(
70 "Failed to read Hugging Face model card at {:?}: {}",
71 path, error
72 );
73 return Vec::new();
74 }
75 };
76
77 let Some(frontmatter) = extract_frontmatter(&content) else {
78 return Vec::new();
79 };
80
81 let yaml: yaml_serde::Value = match yaml_serde::from_str(frontmatter) {
82 Ok(yaml) => yaml,
83 Err(error) => {
84 warn!(
85 "Failed to parse Hugging Face model-card frontmatter at {:?}: {}",
86 path, error
87 );
88 return Vec::new();
89 }
90 };
91
92 if !looks_like_model_card(&yaml) {
96 return Vec::new();
97 }
98
99 vec![parse_model_card(&yaml)]
100 }
101
102 fn metadata() -> Vec<super::metadata::ParserMetadata> {
103 vec![super::metadata::ParserMetadata {
104 description: "Hugging Face model-card README frontmatter",
105 file_patterns: &["**/README.md"],
106 package_type: "huggingface",
107 primary_language: "Python",
108 documentation_url: Some(
109 "https://huggingface.co/docs/hub/model-cards#model-card-metadata",
110 ),
111 }]
112 }
113}
114
115impl PackageParser for HuggingfaceConfigParser {
116 const PACKAGE_TYPE: PackageType = PackageType::Huggingface;
117
118 fn is_match(path: &Path) -> bool {
119 path.file_name()
120 .and_then(|name| name.to_str())
121 .is_some_and(|name| name == "config.json")
122 }
123
124 fn extract_packages(path: &Path) -> Vec<PackageData> {
125 let Some(json) = read_json(path, "config.json") else {
126 return Vec::new();
127 };
128
129 if !looks_like_transformers_config(&json) {
130 return Vec::new();
131 }
132
133 vec![parse_config(&json, DatasourceId::HuggingfaceConfigJson)]
134 }
135
136 fn metadata() -> Vec<super::metadata::ParserMetadata> {
137 vec![super::metadata::ParserMetadata {
138 description: "Hugging Face Transformers config.json",
139 file_patterns: &["**/config.json"],
140 package_type: "huggingface",
141 primary_language: "Python",
142 documentation_url: Some(
143 "https://huggingface.co/docs/transformers/main_classes/configuration",
144 ),
145 }]
146 }
147}
148
149impl PackageParser for HuggingfaceModelIndexParser {
150 const PACKAGE_TYPE: PackageType = PackageType::Huggingface;
151
152 fn is_match(path: &Path) -> bool {
153 path.file_name()
154 .and_then(|name| name.to_str())
155 .is_some_and(|name| name == "model_index.json")
156 }
157
158 fn extract_packages(path: &Path) -> Vec<PackageData> {
159 let Some(json) = read_json(path, "model_index.json") else {
160 return Vec::new();
161 };
162
163 if !looks_like_diffusers_index(&json) {
164 return Vec::new();
165 }
166
167 vec![parse_config(&json, DatasourceId::HuggingfaceModelIndexJson)]
168 }
169
170 fn metadata() -> Vec<super::metadata::ParserMetadata> {
171 vec![super::metadata::ParserMetadata {
172 description: "Hugging Face Diffusers model_index.json",
173 file_patterns: &["**/model_index.json"],
174 package_type: "huggingface",
175 primary_language: "Python",
176 documentation_url: Some(
177 "https://huggingface.co/docs/diffusers/using-diffusers/loading#diffusion-pipeline",
178 ),
179 }]
180 }
181}
182
183fn read_json(path: &Path, label: &str) -> Option<JsonValue> {
185 let content = match read_file_to_string(path, None) {
186 Ok(content) => content,
187 Err(error) => {
188 warn!(
189 "Failed to read Hugging Face {} at {:?}: {}",
190 label, path, error
191 );
192 return None;
193 }
194 };
195
196 match serde_json::from_str::<JsonValue>(&content) {
197 Ok(json) => Some(json),
198 Err(error) => {
199 warn!(
200 "Failed to parse Hugging Face {} at {:?}: {}",
201 label, path, error
202 );
203 None
204 }
205 }
206}
207
208fn extract_frontmatter(content: &str) -> Option<&str> {
214 let trimmed = content.strip_prefix('\u{feff}').unwrap_or(content);
215 let after_open = trimmed
216 .strip_prefix("---\n")
217 .or_else(|| trimmed.strip_prefix("---\r\n"))?;
218
219 let mut search_start = 0;
221 while let Some(rel) = after_open[search_start..].find("---") {
222 let idx = search_start + rel;
223 let at_line_start = idx == 0 || after_open.as_bytes()[idx - 1] == b'\n';
224 let after = &after_open[idx + 3..];
225 let line_ends = after.is_empty() || after.starts_with('\n') || after.starts_with('\r');
226 if at_line_start && line_ends {
227 return Some(&after_open[..idx]);
228 }
229 search_start = idx + 3;
230 }
231 None
232}
233
234const STRONG_MODEL_CARD_KEYS: &[&str] = &[
239 "library_name",
240 "pipeline_tag",
241 "base_model",
242 "datasets",
243 "model-index",
244 "license_name",
245 "license_link",
246 "model_name",
247 "widget",
248 "co2_eq_emissions",
249];
250
251const WEAK_MODEL_CARD_KEYS: &[&str] = &[
255 "license",
256 "tags",
257 "language",
258 "metrics",
259 "inference",
260 "thumbnail",
261];
262
263fn looks_like_model_card(yaml: &yaml_serde::Value) -> bool {
269 if yaml.as_mapping().is_none() {
270 return false;
271 }
272
273 if STRONG_MODEL_CARD_KEYS
274 .iter()
275 .any(|key| yaml.get(*key).is_some())
276 {
277 return true;
278 }
279
280 let weak_hits = WEAK_MODEL_CARD_KEYS
281 .iter()
282 .filter(|key| yaml.get(**key).is_some())
283 .count();
284 weak_hits >= 2
285}
286
287const CONFIG_KEYS: &[&str] = &[
292 "model_type",
293 "architectures",
294 "transformers_version",
295 "hidden_size",
296 "num_attention_heads",
297 "num_hidden_layers",
298 "vocab_size",
299 "max_position_embeddings",
300 "intermediate_size",
301];
302
303fn looks_like_transformers_config(json: &JsonValue) -> bool {
304 if !json.is_object() {
305 return false;
306 }
307 CONFIG_KEYS.iter().any(|key| json.get(*key).is_some())
308}
309
310fn looks_like_diffusers_index(json: &JsonValue) -> bool {
311 json.get("_class_name").is_some() || json.get("_diffusers_version").is_some()
312}
313
314fn default_package(datasource_id: DatasourceId) -> PackageData {
315 PackageData {
316 package_type: Some(PackageType::Huggingface),
317 datasource_id: Some(datasource_id),
318 primary_language: Some("Python".to_string()),
319 ..Default::default()
320 }
321}
322
323fn identity_from_repo_id(repo_id: &str) -> Option<(String, String, String)> {
326 let repo_id = repo_id.trim();
327 let mut parts = repo_id.splitn(2, '/');
328 let namespace = parts.next()?.trim();
329 let name = parts.next()?.trim();
330 if namespace.is_empty() || name.is_empty() || name.contains('/') {
331 return None;
332 }
333
334 let mut purl = match PackageUrl::new(PackageType::Huggingface.as_str(), name) {
335 Ok(purl) => purl,
336 Err(error) => {
337 warn!(
338 "Failed to build Hugging Face purl for '{}': {}",
339 repo_id, error
340 );
341 return None;
342 }
343 };
344 if let Err(error) = purl.with_namespace(namespace) {
345 warn!(
346 "Failed to set namespace '{}' on Hugging Face purl: {}",
347 namespace, error
348 );
349 return None;
350 }
351
352 Some((namespace.to_string(), name.to_string(), purl.to_string()))
353}
354
355fn dependency_purl(reference: &str) -> Option<String> {
358 let reference = reference.trim();
359 if reference.is_empty() {
360 return None;
361 }
362 if let Some((_, _, purl)) = identity_from_repo_id(reference) {
363 return Some(purl);
364 }
365 match PackageUrl::new(PackageType::Huggingface.as_str(), reference) {
366 Ok(purl) => Some(purl.to_string()),
367 Err(error) => {
368 warn!(
369 "Failed to build Hugging Face dependency purl for '{}': {}",
370 reference, error
371 );
372 None
373 }
374 }
375}
376
377fn parse_model_card(yaml: &yaml_serde::Value) -> PackageData {
378 let mut package = default_package(DatasourceId::HuggingfaceModelCard);
379
380 if let Some((namespace, name, purl)) = yaml
381 .get("model_name")
382 .and_then(yaml_serde::Value::as_str)
383 .and_then(identity_from_repo_id)
384 {
385 package.namespace = Some(truncate_field(namespace));
386 package.name = Some(truncate_field(name));
387 package.purl = Some(purl);
388 }
389
390 package.keywords = collect_string_seq(yaml.get("tags"));
391
392 let languages = collect_string_seq(yaml.get("language"));
393 if !languages.is_empty() {
394 let mut extra = HashMap::new();
395 extra.insert(
396 "language".to_string(),
397 serde_json::Value::Array(
398 languages
399 .into_iter()
400 .map(serde_json::Value::String)
401 .collect(),
402 ),
403 );
404 package.extra_data = Some(extra);
405 }
406
407 apply_declared_license(&mut package, model_card_license(yaml));
408 package.dependencies = model_card_dependencies(yaml);
409
410 package
411}
412
413fn model_card_license(yaml: &yaml_serde::Value) -> Option<String> {
418 first_scalar_or_seq(yaml.get("license"))
419 .or_else(|| first_scalar_or_seq(yaml.get("license_name")))
420}
421
422fn first_scalar_or_seq(value: Option<&yaml_serde::Value>) -> Option<String> {
424 collect_string_seq(value).into_iter().next()
425}
426
427fn model_card_dependencies(yaml: &yaml_serde::Value) -> Vec<Dependency> {
428 let mut dependencies = Vec::new();
429
430 for base_model in collect_string_seq(yaml.get("base_model")) {
431 if let Some(purl) = dependency_purl(&base_model) {
432 dependencies.push(Dependency {
433 purl: Some(purl),
434 extracted_requirement: Some(truncate_field(base_model)),
435 scope: Some("base_model".to_string()),
436 is_runtime: None,
437 is_optional: None,
438 is_pinned: None,
439 is_direct: Some(true),
440 resolved_package: None,
441 extra_data: None,
442 });
443 }
444 }
445
446 for dataset in collect_string_seq(yaml.get("datasets")) {
447 if let Some(purl) = dependency_purl(&dataset) {
448 dependencies.push(Dependency {
449 purl: Some(purl),
450 extracted_requirement: Some(truncate_field(dataset)),
451 scope: Some("dataset".to_string()),
452 is_runtime: None,
453 is_optional: None,
454 is_pinned: None,
455 is_direct: Some(true),
456 resolved_package: None,
457 extra_data: None,
458 });
459 }
460 }
461
462 dependencies
463}
464
465fn parse_config(json: &JsonValue, datasource_id: DatasourceId) -> PackageData {
466 let mut package = default_package(datasource_id);
467
468 if let Some((namespace, name, purl)) = json
469 .get("_name_or_path")
470 .and_then(JsonValue::as_str)
471 .filter(|value| !value.is_empty())
472 .and_then(identity_from_repo_id)
473 {
474 package.namespace = Some(truncate_field(namespace));
475 package.name = Some(truncate_field(name));
476 package.purl = Some(purl);
477 }
478
479 let mut extra = HashMap::new();
480 for key in [
481 "model_type",
482 "transformers_version",
483 "_class_name",
484 "_diffusers_version",
485 ] {
486 if let Some(value) = json.get(key).and_then(JsonValue::as_str) {
487 extra.insert(
488 key.to_string(),
489 serde_json::Value::String(truncate_field(value.to_string())),
490 );
491 }
492 }
493 if let Some(architectures) = json.get("architectures").and_then(JsonValue::as_array) {
494 let values: Vec<serde_json::Value> = architectures
495 .iter()
496 .capped("config.json architectures")
497 .filter_map(|value| value.as_str())
498 .map(|value| serde_json::Value::String(truncate_field(value.to_string())))
499 .collect();
500 if !values.is_empty() {
501 extra.insert(
502 "architectures".to_string(),
503 serde_json::Value::Array(values),
504 );
505 }
506 }
507 if !extra.is_empty() {
508 package.extra_data = Some(extra);
509 }
510
511 package
512}
513
514fn apply_declared_license(package: &mut PackageData, license: Option<String>) {
515 let Some(license) = license else {
516 return;
517 };
518 let license = truncate_field(license);
519 if license.is_empty() {
520 return;
521 }
522 package.extracted_license_statement = Some(license.clone());
523 let (declared, declared_spdx, detections) = normalize_spdx_declared_license(Some(&license));
524 package.declared_license_expression = declared;
525 package.declared_license_expression_spdx = declared_spdx;
526 package.license_detections = detections;
527}
528
529fn collect_string_seq(value: Option<&yaml_serde::Value>) -> Vec<String> {
532 match value {
533 Some(yaml_serde::Value::String(single)) => {
534 let single = single.trim();
535 if single.is_empty() {
536 Vec::new()
537 } else {
538 vec![truncate_field(single.to_string())]
539 }
540 }
541 Some(seq) => seq
542 .as_sequence()
543 .into_iter()
544 .flatten()
545 .capped("model card string sequence")
546 .filter_map(yaml_serde::Value::as_str)
547 .map(str::trim)
548 .filter(|entry| !entry.is_empty())
549 .map(|entry| truncate_field(entry.to_string()))
550 .collect(),
551 None => Vec::new(),
552 }
553}