1use std::collections::HashMap;
25use std::path::Path;
26
27use crate::parser_warn as warn;
28use crate::parsers::utils::{capped_iteration_limit, read_file_to_string, truncate_field};
29use packageurl::PackageUrl;
30use starlark_syntax::syntax::AstModule;
31use starlark_syntax::syntax::ast;
32
33use crate::models::{DatasourceId, PackageData, PackageType, Party, PartyType, Sha1Digest};
34
35use super::PackageParser;
36use super::metadata::ParserMetadata;
37use super::starlark_parse::{self, RECOVERY_MISSING_SEPARATOR};
38
39type StarlarkCallArgs = ast::CallArgsP<ast::AstNoPayload>;
40
41struct StarlarkCall<'a> {
42 func: &'a ast::AstExpr,
43 args: &'a StarlarkCallArgs,
44}
45
46pub struct BuckBuildParser;
48
49impl PackageParser for BuckBuildParser {
50 const PACKAGE_TYPE: PackageType = PackageType::Buck;
51
52 fn metadata() -> Vec<ParserMetadata> {
53 vec![ParserMetadata {
54 description: "Buck build file and METADATA.bzl",
55 file_patterns: &["**/BUCK", "**/METADATA.bzl"],
56 package_type: "buck",
57 primary_language: "",
58 documentation_url: Some("https://buck.build/"),
59 }]
60 }
61
62 fn is_match(path: &Path) -> bool {
63 path.file_name()
64 .and_then(|name| name.to_str())
65 .is_some_and(|name| name == "BUCK")
66 }
67
68 fn extract_packages(path: &Path) -> Vec<PackageData> {
69 match parse_buck_build(path) {
70 Ok(packages) if !packages.is_empty() => packages,
71 Ok(_) => vec![fallback_package_data(path)],
72 Err(e) => {
73 warn!("Failed to parse Buck BUCK file {:?}: {}", path, e);
74 vec![fallback_package_data(path)]
75 }
76 }
77 }
78}
79
80pub struct BuckMetadataBzlParser;
82
83impl PackageParser for BuckMetadataBzlParser {
84 const PACKAGE_TYPE: PackageType = PackageType::Buck;
85
86 fn is_match(path: &Path) -> bool {
87 path.file_name()
88 .and_then(|name| name.to_str())
89 .is_some_and(|name| name == "METADATA.bzl")
90 }
91
92 fn extract_packages(path: &Path) -> Vec<PackageData> {
93 vec![match parse_metadata_bzl(path) {
94 Ok(pkg) => pkg,
95 Err(e) => {
96 warn!("Failed to parse Buck METADATA.bzl {:?}: {}", path, e);
97 PackageData {
98 datasource_id: Some(DatasourceId::BuckMetadata),
99 ..Default::default()
100 }
101 }
102 }]
103 }
104}
105
106fn parse_buck_build(path: &Path) -> Result<Vec<PackageData>, String> {
108 let content = read_file_to_string(path, None).map_err(|e| e.to_string())?;
109 let (module, repaired) = parse_starlark_module("<BUCK>", content)?;
110
111 let mut packages = Vec::new();
112
113 let statements = top_level_statements(&module);
114 let limit = capped_iteration_limit(statements.len(), "BUCK top-level statements");
115 for statement in statements.iter().take(limit) {
116 if let Some(mut package_data) = extract_build_package_from_statement(statement) {
117 if repaired {
118 starlark_parse::mark_parse_recovery(&mut package_data, RECOVERY_MISSING_SEPARATOR);
119 }
120 packages.push(package_data);
121 }
122 }
123
124 Ok(packages)
125}
126
127fn parse_metadata_bzl(path: &Path) -> Result<PackageData, String> {
129 let content = read_file_to_string(path, None).map_err(|e| e.to_string())?;
130 let (module, repaired) = parse_starlark_module("<METADATA.bzl>", content)?;
131
132 let statements = top_level_statements(&module);
133 let limit = capped_iteration_limit(statements.len(), "METADATA.bzl top-level statements");
134 for statement in statements.iter().take(limit) {
135 if let Some(dict) = extract_metadata_assignment_dict(statement) {
136 let mut package_data = extract_metadata_dict(dict);
137 if repaired {
138 starlark_parse::mark_parse_recovery(&mut package_data, RECOVERY_MISSING_SEPARATOR);
139 }
140 return Ok(package_data);
141 }
142 }
143
144 Ok(PackageData {
146 datasource_id: Some(DatasourceId::BuckMetadata),
147 ..Default::default()
148 })
149}
150
151fn parse_starlark_module(filename: &str, content: String) -> Result<(AstModule, bool), String> {
152 let content = preprocess_starlark_content(&content);
153 starlark_parse::parse_with_repair(filename, content)
154}
155
156fn preprocess_starlark_content(content: &str) -> String {
157 let mut normalized = String::with_capacity(content.len());
158 let mut pending_oss_disable_indent: Option<String> = None;
159
160 for raw_line in content.lines() {
161 let trimmed_start = raw_line.trim_start();
162 let indent_len = raw_line.len() - trimmed_start.len();
163 let indent = &raw_line[..indent_len];
164
165 if trimmed_start.starts_with('#') && trimmed_start.contains("@oss-disable") {
166 pending_oss_disable_indent = Some(indent.to_string());
167 continue;
168 }
169
170 if let Some(marker_index) = raw_line.find("# @oss-enable") {
171 let code = raw_line[..marker_index].trim_end();
172 if !code.is_empty() {
173 if let Some(disabled_indent) = pending_oss_disable_indent.take() {
174 normalized.push_str(&disabled_indent);
175 normalized.push_str(code.trim_start());
176 } else {
177 normalized.push_str(code);
178 }
179 normalized.push('\n');
180 }
181 continue;
182 }
183
184 pending_oss_disable_indent = None;
185 normalized.push_str(raw_line);
186 normalized.push('\n');
187 }
188
189 if !content.ends_with('\n') && normalized.ends_with('\n') {
190 normalized.pop();
191 }
192
193 normalized
194}
195
196fn top_level_statements(module: &AstModule) -> &[ast::AstStmt] {
197 match &module.statement().node {
198 ast::StmtP::Statements(statements) => statements,
199 _ => std::slice::from_ref(module.statement()),
200 }
201}
202
203fn extract_metadata_assignment_dict(
204 statement: &ast::AstStmt,
205) -> Option<&[(ast::AstExpr, ast::AstExpr)]> {
206 let ast::StmtP::Assign(assign) = &statement.node else {
207 return None;
208 };
209 let ast::AssignTargetP::Identifier(target) = &assign.lhs.node else {
210 return None;
211 };
212 if target.node.ident != "METADATA" {
213 return None;
214 }
215 match &assign.rhs.node {
216 ast::ExprP::Dict(items) => Some(items.as_slice()),
217 _ => None,
218 }
219}
220
221fn extract_metadata_dict(dict: &[(ast::AstExpr, ast::AstExpr)]) -> PackageData {
223 let mut fields: HashMap<String, MetadataValue> = HashMap::new();
224
225 let limit = capped_iteration_limit(dict.len(), "BUCK metadata dict entries");
226 for (key, value) in dict.iter().take(limit) {
227 let Some(key_name) = expr_as_string(key) else {
228 continue;
229 };
230 let Some(metadata_value) = metadata_value_from_expr(value) else {
231 continue;
232 };
233
234 fields.insert(key_name, metadata_value);
235 }
236
237 build_package_from_metadata(fields)
238}
239
240fn get_metadata_string(fields: &HashMap<String, MetadataValue>, keys: &[&str]) -> Option<String> {
241 keys.iter().find_map(|key| match fields.get(*key) {
242 Some(MetadataValue::String(value)) => Some(value.clone()),
243 _ => None,
244 })
245}
246
247fn get_metadata_list(
248 fields: &HashMap<String, MetadataValue>,
249 keys: &[&str],
250) -> Option<Vec<String>> {
251 keys.iter().find_map(|key| match fields.get(*key) {
252 Some(MetadataValue::List(values)) => Some(values.clone()),
253 _ => None,
254 })
255}
256
257enum MetadataValue {
259 String(String),
260 List(Vec<String>),
261}
262
263fn split_buck_license_values(values: &[String]) -> (Vec<String>, Vec<String>) {
264 let mut statements = Vec::new();
265 let mut references = Vec::new();
266
267 for value in values {
268 if is_probable_local_license_reference(value) {
269 references.push(value.clone());
270 } else {
271 statements.push(value.clone());
272 }
273 }
274
275 (statements, references)
276}
277
278fn is_probable_local_license_reference(value: &str) -> bool {
279 let trimmed = value.trim();
280 if trimmed.is_empty() {
281 return false;
282 }
283
284 let lower = trimmed.to_ascii_lowercase();
285 lower.contains('/')
286 || lower.contains('\\')
287 || lower.starts_with("license")
288 || lower.starts_with("licence")
289 || lower.starts_with("copying")
290 || lower.starts_with("notice")
291 || lower.starts_with("copyright")
292 || lower.ends_with(".txt")
293 || lower.ends_with(".md")
294 || lower.ends_with(".rst")
295 || lower.ends_with(".html")
296}
297
298fn insert_license_reference_extra_data(
299 extra_data: &mut HashMap<String, serde_json::Value>,
300 references: &[String],
301) {
302 match references {
303 [] => {}
304 [reference] => {
305 extra_data.insert(
306 "license_file".to_string(),
307 serde_json::Value::String(reference.clone()),
308 );
309 }
310 _ => {
311 extra_data.insert(
312 "license_files".to_string(),
313 serde_json::Value::Array(
314 references
315 .iter()
316 .cloned()
317 .map(serde_json::Value::String)
318 .collect(),
319 ),
320 );
321 }
322 }
323}
324
325fn build_buck_purl(
329 namespace: Option<&str>,
330 name: Option<&str>,
331 version: Option<&str>,
332) -> Option<String> {
333 let name = name.filter(|value| !value.is_empty())?;
334 let mut package_url = match PackageUrl::new(PackageType::Buck.as_str(), name) {
335 Ok(purl) => purl,
336 Err(e) => {
337 warn!(
338 "Failed to create PackageUrl for buck package '{}': {}",
339 name, e
340 );
341 return None;
342 }
343 };
344
345 if let Some(namespace) = namespace.filter(|value| !value.is_empty())
346 && let Err(e) = package_url.with_namespace(namespace)
347 {
348 warn!(
349 "Failed to set namespace '{}' for buck package '{}': {}",
350 namespace, name, e
351 );
352 return None;
353 }
354
355 if let Some(version) = version.filter(|value| !value.is_empty())
356 && let Err(e) = package_url.with_version(version)
357 {
358 warn!(
359 "Failed to set version '{}' for buck package '{}': {}",
360 version, name, e
361 );
362 return None;
363 }
364
365 Some(truncate_field(package_url.to_string()))
366}
367
368fn build_package_from_metadata(fields: HashMap<String, MetadataValue>) -> PackageData {
370 let mut pkg = PackageData {
371 datasource_id: Some(DatasourceId::BuckMetadata),
372 ..Default::default()
373 };
374 let mut license_references = Vec::new();
375
376 if let Some(name) = get_metadata_string(&fields, &["name"]) {
378 pkg.name = Some(truncate_field(name));
379 }
380
381 if let Some(version) = get_metadata_string(&fields, &["version"]) {
383 pkg.version = Some(truncate_field(version));
384 }
385
386 if let Some(namespace) = get_metadata_string(&fields, &["namespace"]) {
388 pkg.namespace = Some(truncate_field(namespace));
389 }
390
391 if let Some(ecosystem) = get_metadata_string(&fields, &["ecosystem", "type", "package_type"])
394 && let Ok(package_type) = ecosystem.parse::<PackageType>()
395 {
396 pkg.package_type = Some(package_type);
397 }
398
399 if let Some(licenses) = get_metadata_list(&fields, &["licenses"]) {
401 let (license_statements, references) = split_buck_license_values(&licenses);
402 license_references = references;
403 let extracted_license_statement = if !license_statements.is_empty() {
404 Some(license_statements.join(", "))
405 } else if !license_references.is_empty() {
406 Some(license_references.join(", "))
407 } else {
408 None
409 };
410 pkg.extracted_license_statement = extracted_license_statement.map(truncate_field);
411 } else if let Some(license_expression) = get_metadata_string(&fields, &["license_expression"]) {
412 pkg.extracted_license_statement = Some(truncate_field(license_expression));
413 }
414
415 if let Some(copyright) = get_metadata_list(&fields, &["copyrights"]) {
416 if !copyright.is_empty() {
417 pkg.copyright = Some(truncate_field(copyright.join("\n")));
418 }
419 } else if let Some(copyright) = get_metadata_string(&fields, &["copyright"]) {
420 pkg.copyright = Some(truncate_field(copyright));
421 }
422
423 if let Some(homepage_url) = get_metadata_string(
425 &fields,
426 &["upstream_address", "upstream_url", "homepage_url"],
427 ) {
428 pkg.homepage_url = Some(truncate_field(homepage_url));
429 }
430
431 if let Some(download_url) = get_metadata_string(&fields, &["download_url"]) {
433 pkg.download_url = Some(truncate_field(download_url));
434 }
435
436 if let Some(vcs_url) = get_metadata_string(&fields, &["vcs_url"]) {
438 pkg.vcs_url = Some(truncate_field(vcs_url));
439 }
440
441 if let Some(sha1) = get_metadata_string(&fields, &["download_archive_sha1"]) {
443 pkg.sha1 = Sha1Digest::from_hex(&sha1).ok();
444 }
445
446 if let Some(maintainers) = get_metadata_list(&fields, &["maintainers"]) {
448 pkg.parties.extend(maintainers.iter().map(|name| Party {
449 r#type: Some(PartyType::Organization),
450 name: Some(name.clone()),
451 role: Some("maintainer".to_string()),
452 email: None,
453 url: None,
454 organization: None,
455 organization_url: None,
456 timezone: None,
457 }));
458 }
459
460 if let Some(vendor) = get_metadata_string(&fields, &["vendor", "publisher"]) {
461 pkg.parties.push(Party {
462 r#type: None,
463 name: Some(vendor),
464 role: Some("publisher".to_string()),
465 email: None,
466 url: None,
467 organization: None,
468 organization_url: None,
469 timezone: None,
470 });
471 }
472
473 let mut extra_data = HashMap::new();
475 if let Some(vcs_commit_hash) = get_metadata_string(&fields, &["vcs_commit_hash"]) {
476 extra_data.insert(
477 "vcs_commit_hash".to_string(),
478 serde_json::Value::String(vcs_commit_hash),
479 );
480 }
481 if let Some(upstream_hash) =
482 get_metadata_string(&fields, &["upstream_hash", "upstream_commit_hash"])
483 {
484 extra_data.insert(
485 "upstream_hash".to_string(),
486 serde_json::Value::String(upstream_hash),
487 );
488 }
489 if let Some(upstream_branch) = get_metadata_string(&fields, &["upstream_branch"]) {
490 extra_data.insert(
491 "upstream_branch".to_string(),
492 serde_json::Value::String(upstream_branch),
493 );
494 }
495 insert_license_reference_extra_data(&mut extra_data, &license_references);
496 if !extra_data.is_empty() {
497 pkg.extra_data = Some(extra_data);
498 }
499
500 if let Some(purl_str) = get_metadata_string(&fields, &["package_url"])
502 && let Ok(purl) = purl_str.parse::<PackageUrl>()
503 {
504 pkg.purl = Some(truncate_field(purl.to_string()));
505
506 if let Ok(package_type) = purl.ty().parse::<PackageType>() {
507 pkg.package_type = Some(package_type);
508 }
509 if let Some(ns) = purl.namespace() {
510 pkg.namespace = Some(truncate_field(ns.to_string()));
511 }
512 pkg.name = Some(truncate_field(purl.name().to_string()));
513 if let Some(ver) = purl.version() {
514 pkg.version = Some(truncate_field(ver.to_string()));
515 }
516 if !purl.qualifiers().is_empty() {
518 let quals: HashMap<String, String> = purl
519 .qualifiers()
520 .iter()
521 .map(|(k, v)| (k.to_string(), v.to_string()))
522 .collect();
523 pkg.qualifiers = Some(quals);
524 }
525 if let Some(sp) = purl.subpath() {
527 pkg.subpath = Some(sp.to_string());
528 }
529 }
530
531 if pkg.purl.is_none() && matches!(pkg.package_type, None | Some(PackageType::Buck)) {
537 pkg.purl = build_buck_purl(
538 pkg.namespace.as_deref(),
539 pkg.name.as_deref(),
540 pkg.version.as_deref(),
541 );
542 }
543
544 pkg
545}
546
547fn metadata_value_from_expr(expr: &ast::AstExpr) -> Option<MetadataValue> {
548 if let Some(string) = expr_as_string(expr) {
549 return Some(MetadataValue::String(string));
550 }
551
552 let items = match &expr.node {
553 ast::ExprP::List(items) | ast::ExprP::Tuple(items) => items,
554 _ => return None,
555 };
556 let limit = capped_iteration_limit(items.len(), "BUCK metadata list items");
557 let values: Vec<_> = items
558 .iter()
559 .take(limit)
560 .filter_map(expr_as_string)
561 .collect();
562 (!values.is_empty()).then_some(MetadataValue::List(values))
563}
564
565fn extract_build_package_from_statement(statement: &ast::AstStmt) -> Option<PackageData> {
567 let call = extract_call(statement)?;
568 let rule_name = match &call.func.node {
569 ast::ExprP::Identifier(identifier) => identifier.node.ident.as_str(),
570 _ => return None,
571 };
572
573 if !check_rule_name_ending(rule_name) {
574 return None;
575 }
576
577 let name = extract_named_kwarg_string(&call, "name");
578 let licenses = extract_named_kwarg_string_list(&call, "licenses");
579
580 let package_name = name?;
581 let (license_statements, license_references) = licenses
582 .as_deref()
583 .map(split_buck_license_values)
584 .unwrap_or_default();
585 let extracted_license_statement = if !license_statements.is_empty() {
586 Some(truncate_field(license_statements.join(", ")))
587 } else if !license_references.is_empty() {
588 Some(truncate_field(license_references.join(", ")))
589 } else {
590 None
591 };
592 let mut extra_data = HashMap::new();
593 insert_license_reference_extra_data(&mut extra_data, &license_references);
594
595 let purl = build_buck_purl(None, Some(&package_name), None);
596
597 Some(PackageData {
598 package_type: Some(BuckBuildParser::PACKAGE_TYPE),
599 name: Some(truncate_field(package_name)),
600 purl,
601 extracted_license_statement,
602 extra_data: (!extra_data.is_empty()).then_some(extra_data),
603 datasource_id: Some(DatasourceId::BuckFile),
604 ..Default::default()
605 })
606}
607
608fn extract_call(statement: &ast::AstStmt) -> Option<StarlarkCall<'_>> {
609 match &statement.node {
610 ast::StmtP::Expression(expr) => extract_call_expr(expr),
611 ast::StmtP::Assign(assign) => extract_call_expr(&assign.rhs),
612 _ => None,
613 }
614}
615
616fn extract_call_expr(expr: &ast::AstExpr) -> Option<StarlarkCall<'_>> {
617 match &expr.node {
618 ast::ExprP::Call(func, args) => Some(StarlarkCall { func, args }),
619 _ => None,
620 }
621}
622
623fn extract_named_kwarg<'a>(call: &'a StarlarkCall<'_>, key: &str) -> Option<&'a ast::AstExpr> {
624 call.args
625 .args
626 .iter()
627 .find_map(|argument| match &argument.node {
628 ast::ArgumentP::Named(name, value) if name.node == key => Some(value),
629 _ => None,
630 })
631}
632
633fn extract_named_kwarg_string(call: &StarlarkCall<'_>, key: &str) -> Option<String> {
634 extract_named_kwarg(call, key).and_then(expr_as_string)
635}
636
637fn extract_named_kwarg_string_list(call: &StarlarkCall<'_>, key: &str) -> Option<Vec<String>> {
638 let expr = extract_named_kwarg(call, key)?;
639 let items = match &expr.node {
640 ast::ExprP::List(items) | ast::ExprP::Tuple(items) => items,
641 _ => return None,
642 };
643 let limit = capped_iteration_limit(items.len(), "BUCK kwarg string-list items");
644 let values: Vec<_> = items
645 .iter()
646 .take(limit)
647 .filter_map(expr_as_string)
648 .collect();
649 (!values.is_empty()).then_some(values)
650}
651
652fn expr_as_string(expr: &ast::AstExpr) -> Option<String> {
653 match &expr.node {
654 ast::ExprP::Literal(ast::AstLiteral::String(value)) => Some(value.node.clone()),
655 _ => None,
656 }
657}
658
659fn check_rule_name_ending(rule_name: &str) -> bool {
661 rule_name.ends_with("binary") || rule_name.ends_with("library")
662}
663
664fn fallback_package_data(path: &Path) -> PackageData {
666 let name = path
667 .parent()
668 .and_then(|p| p.file_name())
669 .and_then(|n| n.to_str())
670 .map(|s| s.to_string());
671
672 let purl = build_buck_purl(None, name.as_deref(), None);
673
674 PackageData {
675 package_type: Some(BuckBuildParser::PACKAGE_TYPE),
676 name,
677 purl,
678 datasource_id: Some(DatasourceId::BuckFile),
679 ..Default::default()
680 }
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686 use std::path::PathBuf;
687
688 #[test]
689 fn test_buck_build_is_match() {
690 assert!(BuckBuildParser::is_match(&PathBuf::from("BUCK")));
691 assert!(BuckBuildParser::is_match(&PathBuf::from("path/to/BUCK")));
692 assert!(!BuckBuildParser::is_match(&PathBuf::from("BUILD")));
693 assert!(!BuckBuildParser::is_match(&PathBuf::from("buck")));
694 }
695
696 #[test]
697 fn test_metadata_bzl_is_match() {
698 assert!(BuckMetadataBzlParser::is_match(&PathBuf::from(
699 "METADATA.bzl"
700 )));
701 assert!(BuckMetadataBzlParser::is_match(&PathBuf::from(
702 "path/to/METADATA.bzl"
703 )));
704 assert!(!BuckMetadataBzlParser::is_match(&PathBuf::from(
705 "metadata.bzl"
706 )));
707 assert!(!BuckMetadataBzlParser::is_match(&PathBuf::from("METADATA")));
708 }
709
710 #[test]
711 fn test_check_rule_name_ending() {
712 assert!(check_rule_name_ending("android_binary"));
713 assert!(check_rule_name_ending("android_library"));
714 assert!(check_rule_name_ending("java_binary"));
715 assert!(!check_rule_name_ending("filegroup"));
716 }
717
718 #[test]
719 fn test_preprocess_starlark_content_handles_oss_guarded_alternatives() {
720 let content = r#"# @oss-disable[end= ]: load("@fbsource//tools/build_defs:rust_unittest.bzl", "rust_unittest")
721prelude = native
722
723# @oss-disable: rust_unittest(
724 rust_test( # @oss-enable
725 name = "test",
726 )
727
728platform_utils = None # @oss-enable
729"#;
730
731 let normalized = preprocess_starlark_content(content);
732
733 assert!(!normalized.contains("@oss-disable"));
734 assert!(!normalized.contains("@oss-enable"));
735 assert!(normalized.contains("rust_test("));
736 assert!(normalized.contains("platform_utils = None"));
737 assert!(!normalized.contains(" rust_test("));
738 }
739
740 #[test]
741 fn test_parse_buck_build_with_oss_guarded_rule() {
742 let content = r#"# @oss-disable[end= ]: load("@fbsource//tools/build_defs:rust_library.bzl", "rust_library")
743# @oss-disable[end= ]: load("@fbsource//tools/build_defs:rust_unittest.bzl", "rust_unittest")
744
745oncall("build_infra")
746
747rust_library(
748 name = "library",
749 srcs = ["src/lib.rs"],
750)
751
752# @oss-disable: rust_unittest(
753 rust_test( # @oss-enable
754 name = "test",
755 srcs = ["tests/test.rs"],
756)
757"#;
758
759 let temp_dir = tempfile::tempdir().unwrap();
760 let buck_path = temp_dir.path().join("BUCK");
761 std::fs::write(&buck_path, content).unwrap();
762
763 let packages = parse_buck_build(&buck_path).expect("BUCK file should parse");
764
765 assert_eq!(packages.len(), 1);
766 assert_eq!(packages[0].package_type, Some(PackageType::Buck));
767 assert_eq!(packages[0].name.as_deref(), Some("library"));
768 }
769
770 #[test]
771 fn test_parse_buck_build_recovers_from_missing_argument_comma() {
772 let content = r#"cxx_library(
775 name='errors',
776 visibility=['PUBLIC'],
777 exported_headers=[
778 'zstd_errors.h',
779 'common/error_private.h',
780 ]
781 srcs=['common/error_private.c'],
782)
783"#;
784
785 let temp_dir = tempfile::tempdir().unwrap();
786 let buck_path = temp_dir.path().join("BUCK");
787 std::fs::write(&buck_path, content).unwrap();
788
789 let packages = parse_buck_build(&buck_path).expect("BUCK file should parse after repair");
790 assert_eq!(packages.len(), 1);
791 assert_eq!(packages[0].name.as_deref(), Some("errors"));
792 let recovery = packages[0]
794 .extra_data
795 .as_ref()
796 .and_then(|map| map.get(super::starlark_parse::PARSE_RECOVERY_KEY));
797 assert!(
798 recovery.is_some(),
799 "recovered package should record a breadcrumb"
800 );
801 }
802}