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