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