Skip to main content

fallow_config/workspace/
pnpm_catalog.rs

1//! Parser for package manager catalog declarations.
2//!
3//! pnpm supports two catalog forms:
4//! - the top-level `catalog:` map (the "default" catalog)
5//! - the top-level `catalogs:` map of named catalogs
6//!
7//! Bun supports the same shapes in root `package.json`, usually under
8//! `workspaces.catalog` / `workspaces.catalogs`, with top-level `catalog` /
9//! `catalogs` accepted as an alternative.
10//!
11//! ```yaml
12//! catalog:
13//!   react: ^18.2.0
14//!   "@scope/lib": ^1.0.0
15//!
16//! catalogs:
17//!   react17:
18//!     react: ^17.0.2
19//!     react-dom: ^17.0.2
20//! ```
21//!
22//! Workspace packages reference catalog entries from their `dependencies`
23//! (and friends) with the `catalog:` protocol:
24//!
25//! ```json
26//! { "dependencies": { "react": "catalog:", "old-react": "catalog:react17" } }
27//! ```
28//!
29//! For the unused-catalog-entry detector we need both the structured catalog
30//! map and the 1-based line number of each entry in the source so findings
31//! can point users to the exact line. `serde_yaml_ng` gives us the structural
32//! parse; a second targeted scan over the raw source recovers the line
33//! numbers.
34
35/// Structured catalog data extracted from a package manager catalog source.
36#[derive(Debug, Clone, Default)]
37pub struct PnpmCatalogData {
38    /// Catalogs found in the file. The default catalog (top-level `catalog:`)
39    /// always appears first with `name = "default"` when present; named
40    /// catalogs follow in source order.
41    pub catalogs: Vec<PnpmCatalog>,
42    /// Named catalogs under `catalogs:` that declare no package entries.
43    ///
44    /// The top-level `catalog:` map is intentionally not represented here:
45    /// some repos keep it as a stable hook even when currently empty.
46    pub empty_named_catalog_groups: Vec<PnpmCatalogGroup>,
47}
48
49/// A single catalog (the default or a named one).
50#[derive(Debug, Clone)]
51pub struct PnpmCatalog {
52    /// Catalog name. `"default"` for the top-level `catalog:` map, or the
53    /// named catalog key for entries declared under `catalogs.<name>:`.
54    pub name: String,
55    /// Entries declared in this catalog, in source order.
56    pub entries: Vec<PnpmCatalogEntry>,
57}
58
59/// A single entry inside a catalog.
60#[derive(Debug, Clone)]
61pub struct PnpmCatalogEntry {
62    /// Package name declared in the catalog (e.g. `"react"`, `"@scope/lib"`).
63    pub package_name: String,
64    /// 1-based line number of the entry within the source file.
65    pub line: u32,
66}
67
68/// A named catalog group under `catalogs:` with no package entries.
69#[derive(Debug, Clone)]
70pub struct PnpmCatalogGroup {
71    /// Catalog group name (e.g. `"react17"` for `catalogs.react17`).
72    pub name: String,
73    /// 1-based line number of the group header within the source file.
74    pub line: u32,
75}
76
77/// Parse the catalog sections of a `pnpm-workspace.yaml` file.
78///
79/// Returns an empty `PnpmCatalogData` when the file has no catalog data or
80/// when the catalog sections are present but empty. All non-catalog top-level
81/// keys (`packages`, `catalog`, `catalogs`, etc.) are ignored. Malformed YAML
82/// is an `Err` carrying the parse error text so callers can surface a
83/// workspace diagnostic instead of silently dropping every entry.
84pub 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
109/// Push the default pnpm catalog when it contains package entries.
110fn 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
127/// Split named pnpm `catalogs:` entries into populated catalogs and empty groups.
128fn 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/// Parse Bun catalog sections from a root `package.json` file.
171///
172/// Bun accepts `workspaces.catalog` / `workspaces.catalogs` and the same
173/// `catalog` / `catalogs` keys at package.json top level. The nested
174/// `workspaces` form is preferred when both forms exist for the same section.
175#[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
218/// Push the default catalog (top-level or `workspaces.catalog`) when non-empty.
219fn 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
236/// Split named `catalogs:` entries into populated catalogs and empty groups.
237fn 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
311/// Maps `(catalog_name, package_name)` to its 1-based source line.
312///
313/// `catalog_name` is `"default"` for entries under the top-level `catalog`
314/// key, the named catalog key for entries under top-level `catalogs.<name>`,
315/// or `workspaces.<name>` for Bun package.json catalogs nested below
316/// `workspaces`.
317struct 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
338/// Walk the raw YAML source to map each catalog entry to its 1-based line
339/// number. This is a small section-aware scanner: it tracks whether the
340/// current line falls inside `catalog:` (the default catalog) or inside
341/// `catalogs.<name>:` (a named catalog), and records each key at the
342/// expected indentation level.
343fn 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/// Brace-depth scanner state for the package.json catalog line index.
426#[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    /// Record a catalog entry or group header for the current key, if the
440    /// active section and brace depth place it inside a catalog.
441    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    /// Enter the `workspaces`, `catalog`, or `catalogs` section when the current
471    /// key opens one at a supported parent depth.
472    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    /// Set the active catalog section, its depth, and the workspace prefix.
488    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    /// Drop section/workspace state once the brace depth exits their scope.
499    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
596/// Strip an unquoted trailing `# ...` comment from a single line. Preserves
597/// `#` characters inside quoted strings so `"# in quotes": "value"` is left
598/// alone.
599pub(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
617/// Parse a key declaration of the form `key:` or `key: value`, returning just
618/// the (unquoted) key. Returns `None` when the line is not a key declaration
619/// (e.g., a list item `- foo`, a block scalar marker, or malformed).
620pub(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}