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, when
80/// the YAML is malformed, or when the catalog sections are present but empty.
81/// All non-catalog top-level keys (`packages`, `catalog`, `catalogs`, etc.)
82/// are ignored.
83#[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
111/// Push the default pnpm catalog when it contains package entries.
112fn 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
129/// Split named pnpm `catalogs:` entries into populated catalogs and empty groups.
130fn 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/// Parse Bun catalog sections from a root `package.json` file.
173///
174/// Bun accepts `workspaces.catalog` / `workspaces.catalogs` and the same
175/// `catalog` / `catalogs` keys at package.json top level. The nested
176/// `workspaces` form is preferred when both forms exist for the same section.
177#[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
220/// Push the default catalog (top-level or `workspaces.catalog`) when non-empty.
221fn 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
238/// Split named `catalogs:` entries into populated catalogs and empty groups.
239fn 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
313/// Maps `(catalog_name, package_name)` to its 1-based source line.
314///
315/// `catalog_name` is `"default"` for entries under the top-level `catalog`
316/// key, the named catalog key for entries under top-level `catalogs.<name>`,
317/// or `workspaces.<name>` for Bun package.json catalogs nested below
318/// `workspaces`.
319struct 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
340/// Walk the raw YAML source to map each catalog entry to its 1-based line
341/// number. This is a small section-aware scanner: it tracks whether the
342/// current line falls inside `catalog:` (the default catalog) or inside
343/// `catalogs.<name>:` (a named catalog), and records each key at the
344/// expected indentation level.
345fn 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/// Brace-depth scanner state for the package.json catalog line index.
428#[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    /// Record a catalog entry or group header for the current key, if the
442    /// active section and brace depth place it inside a catalog.
443    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    /// Enter the `workspaces`, `catalog`, or `catalogs` section when the current
473    /// key opens one at a supported parent depth.
474    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    /// Set the active catalog section, its depth, and the workspace prefix.
490    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    /// Drop section/workspace state once the brace depth exits their scope.
501    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
598/// Strip an unquoted trailing `# ...` comment from a single line. Preserves
599/// `#` characters inside quoted strings so `"# in quotes": "value"` is left
600/// alone.
601pub(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
619/// Parse a key declaration of the form `key:` or `key: value`, returning just
620/// the (unquoted) key. Returns `None` when the line is not a key declaration
621/// (e.g., a list item `- foo`, a block scalar marker, or malformed).
622pub(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}