1#[derive(Debug, Clone, Default)]
37pub struct PnpmCatalogData {
38 pub catalogs: Vec<PnpmCatalog>,
42 pub empty_named_catalog_groups: Vec<PnpmCatalogGroup>,
47}
48
49#[derive(Debug, Clone)]
51pub struct PnpmCatalog {
52 pub name: String,
55 pub entries: Vec<PnpmCatalogEntry>,
57}
58
59#[derive(Debug, Clone)]
61pub struct PnpmCatalogEntry {
62 pub package_name: String,
64 pub line: u32,
66}
67
68#[derive(Debug, Clone)]
70pub struct PnpmCatalogGroup {
71 pub name: String,
73 pub line: u32,
75}
76
77#[must_use]
84pub fn parse_pnpm_catalog_data(source: &str) -> PnpmCatalogData {
85 let value: serde_yaml_ng::Value = match serde_yaml_ng::from_str(source) {
86 Ok(v) => v,
87 Err(_) => return PnpmCatalogData::default(),
88 };
89 let Some(mapping) = value.as_mapping() else {
90 return PnpmCatalogData::default();
91 };
92
93 let line_index = build_line_index(source);
94 let mut catalogs = Vec::new();
95 let mut empty_named_catalog_groups = Vec::new();
96
97 if let Some(default_value) = mapping.get("catalog")
98 && let Some(default_map) = default_value.as_mapping()
99 {
100 let entries = collect_entries(default_map, &line_index, "default");
101 if !entries.is_empty() {
102 catalogs.push(PnpmCatalog {
103 name: "default".to_string(),
104 entries,
105 });
106 }
107 }
108
109 if let Some(named_value) = mapping.get("catalogs")
110 && let Some(named_map) = named_value.as_mapping()
111 {
112 for (name_value, catalog_value) in named_map {
113 let Some(name) = name_value.as_str() else {
114 continue;
115 };
116 if let Some(catalog_map) = catalog_value.as_mapping() {
117 let entries = collect_entries(catalog_map, &line_index, name);
118 if entries.is_empty() {
119 if let Some(line) = line_index.group_line_for(name) {
120 empty_named_catalog_groups.push(PnpmCatalogGroup {
121 name: name.to_string(),
122 line,
123 });
124 }
125 } else {
126 catalogs.push(PnpmCatalog {
127 name: name.to_string(),
128 entries,
129 });
130 }
131 } else if catalog_value.is_null()
132 && let Some(line) = line_index.group_line_for(name)
133 {
134 empty_named_catalog_groups.push(PnpmCatalogGroup {
135 name: name.to_string(),
136 line,
137 });
138 }
139 }
140 }
141
142 PnpmCatalogData {
143 catalogs,
144 empty_named_catalog_groups,
145 }
146}
147
148#[must_use]
154pub fn parse_package_json_catalog_data(source: &str) -> PnpmCatalogData {
155 let value: serde_json::Value = match serde_json::from_str(source.trim_start_matches('\u{FEFF}'))
156 {
157 Ok(value) => value,
158 Err(_) => return PnpmCatalogData::default(),
159 };
160 let Some(root) = value.as_object() else {
161 return PnpmCatalogData::default();
162 };
163
164 let workspaces = root
165 .get("workspaces")
166 .and_then(serde_json::Value::as_object);
167 let workspace_default_value = workspaces.and_then(|workspace| workspace.get("catalog"));
168 let workspace_named_value = workspaces.and_then(|workspace| workspace.get("catalogs"));
169 let default_value = workspace_default_value.or_else(|| root.get("catalog"));
170 let named_value = workspace_named_value.or_else(|| root.get("catalogs"));
171 let default_line_key = if workspace_default_value.is_some() {
172 workspace_catalog_key("default")
173 } else {
174 "default".to_string()
175 };
176 let line_index = build_package_json_line_index(source);
177
178 let mut catalogs = Vec::new();
179 let mut empty_named_catalog_groups = Vec::new();
180
181 if let Some(default_map) = default_value.and_then(serde_json::Value::as_object) {
182 let entries = collect_json_entries(default_map, &line_index, &default_line_key);
183 if !entries.is_empty() {
184 catalogs.push(PnpmCatalog {
185 name: "default".to_string(),
186 entries,
187 });
188 }
189 }
190
191 let named_from_workspace = workspace_named_value.is_some();
192 if let Some(named_map) = named_value.and_then(serde_json::Value::as_object) {
193 for (name, catalog_value) in named_map {
194 let line_key = if named_from_workspace {
195 workspace_catalog_key(name)
196 } else {
197 name.clone()
198 };
199 if let Some(catalog_map) = catalog_value.as_object() {
200 let entries = collect_json_entries(catalog_map, &line_index, &line_key);
201 if entries.is_empty() {
202 empty_named_catalog_groups.push(PnpmCatalogGroup {
203 name: name.clone(),
204 line: line_index.group_line_for(&line_key).unwrap_or(1),
205 });
206 } else {
207 catalogs.push(PnpmCatalog {
208 name: name.clone(),
209 entries,
210 });
211 }
212 } else if catalog_value.is_null() {
213 empty_named_catalog_groups.push(PnpmCatalogGroup {
214 name: name.clone(),
215 line: line_index.group_line_for(&line_key).unwrap_or(1),
216 });
217 }
218 }
219 }
220
221 PnpmCatalogData {
222 catalogs,
223 empty_named_catalog_groups,
224 }
225}
226
227fn collect_entries(
228 mapping: &serde_yaml_ng::Mapping,
229 line_index: &CatalogLineIndex,
230 catalog_name: &str,
231) -> Vec<PnpmCatalogEntry> {
232 mapping
233 .iter()
234 .filter_map(|(k, _)| {
235 let pkg = k.as_str()?;
236 let line = line_index.line_for(catalog_name, pkg)?;
237 Some(PnpmCatalogEntry {
238 package_name: pkg.to_string(),
239 line,
240 })
241 })
242 .collect()
243}
244
245fn collect_json_entries(
246 mapping: &serde_json::Map<String, serde_json::Value>,
247 line_index: &CatalogLineIndex,
248 catalog_name: &str,
249) -> Vec<PnpmCatalogEntry> {
250 mapping
251 .keys()
252 .map(|pkg| PnpmCatalogEntry {
253 package_name: pkg.clone(),
254 line: line_index.line_for(catalog_name, pkg).unwrap_or(1),
255 })
256 .collect()
257}
258
259fn workspace_catalog_key(name: &str) -> String {
260 format!("workspaces.{name}")
261}
262
263struct CatalogLineIndex {
270 entries: Vec<((String, String), u32)>,
271 groups: Vec<(String, u32)>,
272}
273
274impl CatalogLineIndex {
275 fn line_for(&self, catalog_name: &str, package_name: &str) -> Option<u32> {
276 self.entries
277 .iter()
278 .find(|((cat, pkg), _)| cat == catalog_name && pkg == package_name)
279 .map(|(_, line)| *line)
280 }
281
282 fn group_line_for(&self, catalog_name: &str) -> Option<u32> {
283 self.groups
284 .iter()
285 .find(|(name, _)| name == catalog_name)
286 .map(|(_, line)| *line)
287 }
288}
289
290fn build_line_index(source: &str) -> CatalogLineIndex {
296 let mut entries = Vec::new();
297 let mut groups = Vec::new();
298 let mut section: Section = Section::None;
299 let mut named_catalog: Option<(String, usize)> = None;
300
301 for (idx, raw_line) in source.lines().enumerate() {
302 let line_no = u32::try_from(idx).unwrap_or(u32::MAX).saturating_add(1);
303 let trimmed = strip_inline_comment(raw_line);
304 let trimmed_left = trimmed.trim_start();
305 let indent = trimmed.len() - trimmed_left.len();
306
307 if trimmed_left.is_empty() {
308 continue;
309 }
310
311 if indent == 0 {
312 section = if trimmed_left.starts_with("catalogs:") {
313 Section::NamedCatalogs
314 } else if trimmed_left.starts_with("catalog:") {
315 Section::DefaultCatalog
316 } else {
317 Section::None
318 };
319 named_catalog = None;
320 continue;
321 }
322
323 match section {
324 Section::None => {}
325 Section::DefaultCatalog => {
326 if let Some(name) = parse_key(trimmed_left) {
327 entries.push((("default".to_string(), name), line_no));
328 }
329 }
330 Section::NamedCatalogs => {
331 if let Some(name) = parse_key(trimmed_left) {
332 match &named_catalog {
333 Some((_, existing_indent)) if indent > *existing_indent => {
334 entries.push((
335 (
336 named_catalog
337 .as_ref()
338 .map_or_else(String::new, |(n, _)| n.clone()),
339 name,
340 ),
341 line_no,
342 ));
343 }
344 _ => {
345 groups.push((name.clone(), line_no));
346 named_catalog = Some((name, indent));
347 }
348 }
349 }
350 }
351 }
352 }
353
354 CatalogLineIndex { entries, groups }
355}
356
357fn build_package_json_line_index(source: &str) -> CatalogLineIndex {
358 let mut entries = Vec::new();
359 let mut groups = Vec::new();
360 let mut current_depth: u32 = 0;
361 let mut workspaces_depth: Option<u32> = None;
362 let mut current_section_prefix: Option<&'static str> = None;
363 let mut section: Section = Section::None;
364 let mut section_depth: u32 = 0;
365 let mut named_catalog: Option<(String, u32)> = None;
366
367 for (idx, raw_line) in source.lines().enumerate() {
368 let line_no = u32::try_from(idx).unwrap_or(u32::MAX).saturating_add(1);
369 let trimmed = raw_line.trim();
370 if trimmed.is_empty() {
371 continue;
372 }
373
374 let key = parse_json_key(trimmed);
375 let parent_depth = current_depth;
376 let in_supported_parent =
377 parent_depth == 1 || workspaces_depth.is_some_and(|depth| parent_depth == depth);
378
379 if let Some(name) = key {
380 match section {
381 Section::DefaultCatalog if parent_depth == section_depth => {
382 let catalog_name = current_section_prefix.map_or_else(
383 || "default".to_string(),
384 |prefix| format!("{prefix}.default"),
385 );
386 entries.push(((catalog_name, name.to_string()), line_no));
387 }
388 Section::NamedCatalogs if parent_depth == section_depth => {
389 let catalog_name = current_section_prefix
390 .map_or_else(|| name.to_string(), |prefix| format!("{prefix}.{name}"));
391 groups.push((catalog_name.clone(), line_no));
392 named_catalog = Some((catalog_name, parent_depth));
393 }
394 Section::NamedCatalogs => {
395 if let Some((catalog_name, group_depth)) = &named_catalog
396 && parent_depth == group_depth.saturating_add(1)
397 {
398 entries.push(((catalog_name.clone(), name.to_string()), line_no));
399 }
400 }
401 Section::DefaultCatalog | Section::None => {}
402 }
403 }
404
405 let (opens, closes) = count_json_braces(raw_line);
406 let depth_after_opens = current_depth.saturating_add(opens);
407
408 if let Some(name) = key {
409 if parent_depth == 1 && name == "workspaces" && opens > 0 {
410 workspaces_depth = Some(parent_depth.saturating_add(1));
411 }
412 if in_supported_parent && name == "catalog" && opens > 0 {
413 section = Section::DefaultCatalog;
414 section_depth = parent_depth.saturating_add(1);
415 current_section_prefix = workspaces_depth
416 .is_some_and(|depth| parent_depth == depth)
417 .then_some("workspaces");
418 named_catalog = None;
419 } else if in_supported_parent && name == "catalogs" && opens > 0 {
420 section = Section::NamedCatalogs;
421 section_depth = parent_depth.saturating_add(1);
422 current_section_prefix = workspaces_depth
423 .is_some_and(|depth| parent_depth == depth)
424 .then_some("workspaces");
425 named_catalog = None;
426 }
427 }
428
429 current_depth = depth_after_opens.saturating_sub(closes);
430
431 if matches!(section, Section::DefaultCatalog | Section::NamedCatalogs)
432 && current_depth < section_depth
433 {
434 section = Section::None;
435 current_section_prefix = None;
436 named_catalog = None;
437 }
438 if let Some(depth) = workspaces_depth
439 && current_depth < depth
440 {
441 workspaces_depth = None;
442 }
443 }
444
445 CatalogLineIndex { entries, groups }
446}
447
448fn parse_json_key(trimmed: &str) -> Option<&str> {
449 let rest = trimmed.strip_prefix('"')?;
450 let end = rest.find('"')?;
451 let after = rest[end.saturating_add(1)..].trim_start();
452 after.starts_with(':').then_some(&rest[..end])
453}
454
455fn count_json_braces(line: &str) -> (u32, u32) {
456 let mut opens: u32 = 0;
457 let mut closes: u32 = 0;
458 let mut in_string = false;
459 let mut escaped = false;
460 for ch in line.chars() {
461 if escaped {
462 escaped = false;
463 continue;
464 }
465 if ch == '\\' {
466 escaped = true;
467 continue;
468 }
469 if ch == '"' {
470 in_string = !in_string;
471 continue;
472 }
473 if in_string {
474 continue;
475 }
476 match ch {
477 '{' => opens = opens.saturating_add(1),
478 '}' => closes = closes.saturating_add(1),
479 _ => {}
480 }
481 }
482 (opens, closes)
483}
484
485#[derive(Debug, Clone, Copy)]
486enum Section {
487 None,
488 DefaultCatalog,
489 NamedCatalogs,
490}
491
492pub(super) fn strip_inline_comment(line: &str) -> &str {
496 let bytes = line.as_bytes();
497 let mut in_single = false;
498 let mut in_double = false;
499 for (i, &b) in bytes.iter().enumerate() {
500 match b {
501 b'\'' if !in_double => in_single = !in_single,
502 b'"' if !in_single => in_double = !in_double,
503 b'#' if !in_single && !in_double => {
504 let head = &line[..i];
505 return head.trim_end();
506 }
507 _ => {}
508 }
509 }
510 line.trim_end()
511}
512
513pub(super) fn parse_key(line: &str) -> Option<String> {
517 let bytes = line.as_bytes();
518 if bytes.is_empty() {
519 return None;
520 }
521 let first = bytes[0];
522 if first == b'-' || first == b'#' {
523 return None;
524 }
525
526 if first == b'"' || first == b'\'' {
527 let quote = first;
528 let mut i = 1;
529 while i < bytes.len() {
530 let b = bytes[i];
531 if b == b'\\' && i + 1 < bytes.len() {
532 i += 2;
533 continue;
534 }
535 if b == quote {
536 let key = &line[1..i];
537 let rest = &line[i + 1..];
538 let trimmed = rest.trim_start();
539 if trimmed.starts_with(':') {
540 return Some(unescape_key(key));
541 }
542 return None;
543 }
544 i += 1;
545 }
546 return None;
547 }
548
549 let colon_pos = bytes.iter().position(|&b| b == b':')?;
550 let key = line[..colon_pos].trim();
551 if key.is_empty() {
552 return None;
553 }
554 if key.contains(['{', '[', '&', '*', '!']) {
555 return None;
556 }
557 Some(key.to_string())
558}
559
560fn unescape_key(raw: &str) -> String {
561 let mut out = String::with_capacity(raw.len());
562 let mut chars = raw.chars();
563 while let Some(c) = chars.next() {
564 if c == '\\'
565 && let Some(next) = chars.next()
566 {
567 match next {
568 'n' => out.push('\n'),
569 't' => out.push('\t'),
570 '"' => out.push('"'),
571 '\\' => out.push('\\'),
572 other => {
573 out.push('\\');
574 out.push(other);
575 }
576 }
577 } else {
578 out.push(c);
579 }
580 }
581 out
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 #[test]
589 fn parses_default_catalog() {
590 let yaml = "packages:\n - 'packages/*'\n\ncatalog:\n react: ^18.2.0\n is-even: ^1.0.0\n";
591 let data = parse_pnpm_catalog_data(yaml);
592 assert_eq!(data.catalogs.len(), 1);
593 let default = &data.catalogs[0];
594 assert_eq!(default.name, "default");
595 assert_eq!(default.entries.len(), 2);
596 assert_eq!(default.entries[0].package_name, "react");
597 assert_eq!(default.entries[0].line, 5);
598 assert_eq!(default.entries[1].package_name, "is-even");
599 assert_eq!(default.entries[1].line, 6);
600 }
601
602 #[test]
603 fn parses_bun_workspaces_catalog() {
604 let json = r#"{
605 "name": "demo",
606 "workspaces": {
607 "packages": ["packages/*"],
608 "catalog": {
609 "react": "^19.0.0",
610 "react-dom": "^19.0.0"
611 },
612 "catalogs": {
613 "testing": {
614 "vitest": "^3.0.0"
615 },
616 "empty": {}
617 }
618 }
619}
620"#;
621 let data = parse_package_json_catalog_data(json);
622 assert_eq!(data.catalogs.len(), 2);
623 assert_eq!(data.catalogs[0].name, "default");
624 assert_eq!(data.catalogs[0].entries[0].package_name, "react");
625 assert_eq!(data.catalogs[0].entries[0].line, 6);
626 assert_eq!(data.catalogs[1].name, "testing");
627 assert_eq!(data.catalogs[1].entries[0].package_name, "vitest");
628 assert_eq!(data.catalogs[1].entries[0].line, 11);
629 let empty: Vec<_> = data
630 .empty_named_catalog_groups
631 .iter()
632 .map(|group| (group.name.as_str(), group.line))
633 .collect();
634 assert_eq!(empty, vec![("empty", 13)]);
635 }
636
637 #[test]
638 fn parses_bun_top_level_catalog_fallback() {
639 let json = r#"{
640 "name": "demo",
641 "workspaces": ["packages/*"],
642 "catalog": {
643 "bun-types": "^1.3.0"
644 },
645 "catalogs": {
646 "testing": {
647 "vitest": "^3.0.0"
648 }
649 }
650}
651"#;
652 let data = parse_package_json_catalog_data(json);
653 assert_eq!(data.catalogs.len(), 2);
654 assert_eq!(data.catalogs[0].name, "default");
655 assert_eq!(data.catalogs[0].entries[0].package_name, "bun-types");
656 assert_eq!(data.catalogs[0].entries[0].line, 5);
657 assert_eq!(data.catalogs[1].name, "testing");
658 assert_eq!(data.catalogs[1].entries[0].line, 9);
659 }
660
661 #[test]
662 fn workspaces_catalog_takes_precedence_over_top_level_catalog() {
663 let json = r#"{
664 "workspaces": {
665 "packages": ["packages/*"],
666 "catalog": {
667 "react": "^19.0.0"
668 }
669 },
670 "catalog": {
671 "react": "^18.0.0",
672 "vue": "^3.0.0"
673 }
674}
675"#;
676 let data = parse_package_json_catalog_data(json);
677 assert_eq!(data.catalogs.len(), 1);
678 let entries: Vec<_> = data.catalogs[0]
679 .entries
680 .iter()
681 .map(|entry| entry.package_name.as_str())
682 .collect();
683 assert_eq!(entries, vec!["react"]);
684 assert_eq!(data.catalogs[0].entries[0].line, 5);
685 }
686
687 #[test]
688 fn workspaces_catalog_line_wins_when_top_level_catalog_appears_first() {
689 let json = r#"{
690 "catalog": {
691 "react": "^18.0.0"
692 },
693 "workspaces": {
694 "packages": ["packages/*"],
695 "catalog": {
696 "react": "^19.0.0"
697 }
698 }
699}
700"#;
701 let data = parse_package_json_catalog_data(json);
702 assert_eq!(data.catalogs.len(), 1);
703 assert_eq!(data.catalogs[0].entries[0].package_name, "react");
704 assert_eq!(data.catalogs[0].entries[0].line, 8);
705 }
706
707 #[test]
708 fn parses_named_catalogs() {
709 let yaml = "catalogs:\n react17:\n react: ^17.0.2\n react-dom: ^17.0.2\n ui:\n headlessui: ^2.0.0\n";
710 let data = parse_pnpm_catalog_data(yaml);
711 assert_eq!(data.catalogs.len(), 2);
712 assert_eq!(data.catalogs[0].name, "react17");
713 assert_eq!(data.catalogs[0].entries.len(), 2);
714 assert_eq!(data.catalogs[0].entries[0].package_name, "react");
715 assert_eq!(data.catalogs[0].entries[0].line, 3);
716 assert_eq!(data.catalogs[1].name, "ui");
717 assert_eq!(data.catalogs[1].entries[0].package_name, "headlessui");
718 assert_eq!(data.catalogs[1].entries[0].line, 6);
719 assert!(data.empty_named_catalog_groups.is_empty());
720 }
721
722 #[test]
723 fn handles_default_and_named_together() {
724 let yaml = "catalog:\n react: ^18\n\ncatalogs:\n legacy:\n react: ^17\n";
725 let data = parse_pnpm_catalog_data(yaml);
726 assert_eq!(data.catalogs.len(), 2);
727 assert_eq!(data.catalogs[0].name, "default");
728 assert_eq!(data.catalogs[0].entries[0].line, 2);
729 assert_eq!(data.catalogs[1].name, "legacy");
730 assert_eq!(data.catalogs[1].entries[0].line, 6);
731 }
732
733 #[test]
734 fn handles_quoted_keys() {
735 let yaml = "catalog:\n \"@scope/lib\": ^1.0.0\n 'my-pkg': ^2.0.0\n";
736 let data = parse_pnpm_catalog_data(yaml);
737 let default = &data.catalogs[0];
738 assert_eq!(default.entries[0].package_name, "@scope/lib");
739 assert_eq!(default.entries[0].line, 2);
740 assert_eq!(default.entries[1].package_name, "my-pkg");
741 assert_eq!(default.entries[1].line, 3);
742 }
743
744 #[test]
745 fn handles_inline_comments() {
746 let yaml = "catalog:\n react: ^18 # pin until #1234\n is-even: ^1.0\n";
747 let data = parse_pnpm_catalog_data(yaml);
748 assert_eq!(data.catalogs[0].entries.len(), 2);
749 assert_eq!(data.catalogs[0].entries[0].package_name, "react");
750 assert_eq!(data.catalogs[0].entries[1].package_name, "is-even");
751 assert_eq!(data.catalogs[0].entries[1].line, 3);
752 }
753
754 #[test]
755 fn handles_four_space_indentation() {
756 let yaml = "catalog:\n react: ^18.2.0\n vue: ^3.4.0\n";
757 let data = parse_pnpm_catalog_data(yaml);
758 assert_eq!(data.catalogs[0].entries.len(), 2);
759 assert_eq!(data.catalogs[0].entries[0].line, 2);
760 assert_eq!(data.catalogs[0].entries[1].line, 3);
761 }
762
763 #[test]
764 fn empty_catalog_returns_no_catalogs() {
765 let yaml = "catalog: {}\n";
766 let data = parse_pnpm_catalog_data(yaml);
767 assert!(data.catalogs.is_empty());
768 assert!(data.empty_named_catalog_groups.is_empty());
769 }
770
771 #[test]
772 fn tracks_empty_named_catalog_groups() {
773 let yaml = "catalog:\n react: ^18\n\ncatalogs:\n react17: {}\n legacy:\n # retained note\n vue3:\n vue: ^3.4.0\n";
774 let data = parse_pnpm_catalog_data(yaml);
775 assert_eq!(data.catalogs.len(), 2);
776 let empty: Vec<_> = data
777 .empty_named_catalog_groups
778 .iter()
779 .map(|group| (group.name.as_str(), group.line))
780 .collect();
781 assert_eq!(empty, vec![("react17", 5), ("legacy", 6)]);
782 }
783
784 #[test]
785 fn no_catalog_keys_returns_no_catalogs() {
786 let yaml = "packages:\n - 'packages/*'\n";
787 let data = parse_pnpm_catalog_data(yaml);
788 assert!(data.catalogs.is_empty());
789 }
790
791 #[test]
792 fn malformed_yaml_returns_no_catalogs() {
793 let yaml = "{this is\nnot: valid: yaml: at: all";
794 let data = parse_pnpm_catalog_data(yaml);
795 assert!(data.catalogs.is_empty());
796 }
797
798 #[test]
799 fn empty_input_returns_no_catalogs() {
800 let data = parse_pnpm_catalog_data("");
801 assert!(data.catalogs.is_empty());
802 }
803
804 #[test]
805 fn handles_object_form_entries() {
806 let yaml = "catalog:\n react:\n specifier: ^18.2.0\n vue: ^3.4.0\n";
807 let data = parse_pnpm_catalog_data(yaml);
808 assert_eq!(data.catalogs[0].entries.len(), 2);
809 let names: Vec<_> = data.catalogs[0]
810 .entries
811 .iter()
812 .map(|e| e.package_name.as_str())
813 .collect();
814 assert!(names.contains(&"react"));
815 assert!(names.contains(&"vue"));
816 }
817
818 #[test]
819 fn skips_packages_section() {
820 let yaml = "packages:\n - 'apps/*'\n - 'libs/*'\ncatalog:\n react: ^18\n";
821 let data = parse_pnpm_catalog_data(yaml);
822 assert_eq!(data.catalogs.len(), 1);
823 assert_eq!(data.catalogs[0].entries[0].line, 5);
824 }
825
826 #[test]
827 fn strip_inline_comment_preserves_quoted_hash() {
828 assert_eq!(strip_inline_comment("foo: \"a#b\" # tail"), "foo: \"a#b\"");
829 assert_eq!(strip_inline_comment("# top-level"), "");
830 assert_eq!(strip_inline_comment("plain: value"), "plain: value");
831 }
832
833 #[test]
834 fn parse_key_handles_simple_and_quoted() {
835 assert_eq!(parse_key("react: ^18"), Some("react".to_string()));
836 assert_eq!(
837 parse_key("\"@scope/lib\": ^1"),
838 Some("@scope/lib".to_string())
839 );
840 assert_eq!(parse_key("'pkg': ^2"), Some("pkg".to_string()));
841 assert_eq!(parse_key("- item"), None);
842 assert_eq!(parse_key(""), None);
843 }
844}