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