#[derive(Debug, Clone, Default)]
pub struct PnpmCatalogData {
pub catalogs: Vec<PnpmCatalog>,
pub empty_named_catalog_groups: Vec<PnpmCatalogGroup>,
}
#[derive(Debug, Clone)]
pub struct PnpmCatalog {
pub name: String,
pub entries: Vec<PnpmCatalogEntry>,
}
#[derive(Debug, Clone)]
pub struct PnpmCatalogEntry {
pub package_name: String,
pub line: u32,
}
#[derive(Debug, Clone)]
pub struct PnpmCatalogGroup {
pub name: String,
pub line: u32,
}
#[must_use]
pub fn parse_pnpm_catalog_data(source: &str) -> PnpmCatalogData {
let value: serde_yaml_ng::Value = match serde_yaml_ng::from_str(source) {
Ok(v) => v,
Err(_) => return PnpmCatalogData::default(),
};
let Some(mapping) = value.as_mapping() else {
return PnpmCatalogData::default();
};
let line_index = build_line_index(source);
let mut catalogs = Vec::new();
let mut empty_named_catalog_groups = Vec::new();
collect_yaml_default_catalog(mapping.get("catalog"), &line_index, &mut catalogs);
collect_yaml_named_catalogs(
mapping.get("catalogs"),
&line_index,
&mut catalogs,
&mut empty_named_catalog_groups,
);
PnpmCatalogData {
catalogs,
empty_named_catalog_groups,
}
}
fn collect_yaml_default_catalog(
default_value: Option<&serde_yaml_ng::Value>,
line_index: &CatalogLineIndex,
catalogs: &mut Vec<PnpmCatalog>,
) {
let Some(default_map) = default_value.and_then(serde_yaml_ng::Value::as_mapping) else {
return;
};
let entries = collect_entries(default_map, line_index, "default");
if !entries.is_empty() {
catalogs.push(PnpmCatalog {
name: "default".to_string(),
entries,
});
}
}
fn collect_yaml_named_catalogs(
named_value: Option<&serde_yaml_ng::Value>,
line_index: &CatalogLineIndex,
catalogs: &mut Vec<PnpmCatalog>,
empty_named_catalog_groups: &mut Vec<PnpmCatalogGroup>,
) {
let Some(named_map) = named_value.and_then(serde_yaml_ng::Value::as_mapping) else {
return;
};
for (name_value, catalog_value) in named_map {
let Some(name) = name_value.as_str() else {
continue;
};
if let Some(catalog_map) = catalog_value.as_mapping() {
let entries = collect_entries(catalog_map, line_index, name);
if entries.is_empty() {
push_yaml_empty_catalog_group(name, line_index, empty_named_catalog_groups);
} else {
catalogs.push(PnpmCatalog {
name: name.to_string(),
entries,
});
}
} else if catalog_value.is_null() {
push_yaml_empty_catalog_group(name, line_index, empty_named_catalog_groups);
}
}
}
fn push_yaml_empty_catalog_group(
name: &str,
line_index: &CatalogLineIndex,
empty_named_catalog_groups: &mut Vec<PnpmCatalogGroup>,
) {
if let Some(line) = line_index.group_line_for(name) {
empty_named_catalog_groups.push(PnpmCatalogGroup {
name: name.to_string(),
line,
});
}
}
#[must_use]
pub fn parse_package_json_catalog_data(source: &str) -> PnpmCatalogData {
let value: serde_json::Value = match serde_json::from_str(source.trim_start_matches('\u{FEFF}'))
{
Ok(value) => value,
Err(_) => return PnpmCatalogData::default(),
};
let Some(root) = value.as_object() else {
return PnpmCatalogData::default();
};
let workspaces = root
.get("workspaces")
.and_then(serde_json::Value::as_object);
let workspace_default_value = workspaces.and_then(|workspace| workspace.get("catalog"));
let workspace_named_value = workspaces.and_then(|workspace| workspace.get("catalogs"));
let default_value = workspace_default_value.or_else(|| root.get("catalog"));
let named_value = workspace_named_value.or_else(|| root.get("catalogs"));
let default_line_key = if workspace_default_value.is_some() {
workspace_catalog_key("default")
} else {
"default".to_string()
};
let line_index = build_package_json_line_index(source);
let mut catalogs = Vec::new();
let mut empty_named_catalog_groups = Vec::new();
collect_json_default_catalog(default_value, &line_index, &default_line_key, &mut catalogs);
collect_json_named_catalogs(
named_value,
workspace_named_value.is_some(),
&line_index,
&mut catalogs,
&mut empty_named_catalog_groups,
);
PnpmCatalogData {
catalogs,
empty_named_catalog_groups,
}
}
fn collect_json_default_catalog(
default_value: Option<&serde_json::Value>,
line_index: &CatalogLineIndex,
default_line_key: &str,
catalogs: &mut Vec<PnpmCatalog>,
) {
if let Some(default_map) = default_value.and_then(serde_json::Value::as_object) {
let entries = collect_json_entries(default_map, line_index, default_line_key);
if !entries.is_empty() {
catalogs.push(PnpmCatalog {
name: "default".to_string(),
entries,
});
}
}
}
fn collect_json_named_catalogs(
named_value: Option<&serde_json::Value>,
named_from_workspace: bool,
line_index: &CatalogLineIndex,
catalogs: &mut Vec<PnpmCatalog>,
empty_named_catalog_groups: &mut Vec<PnpmCatalogGroup>,
) {
let Some(named_map) = named_value.and_then(serde_json::Value::as_object) else {
return;
};
for (name, catalog_value) in named_map {
let line_key = if named_from_workspace {
workspace_catalog_key(name)
} else {
name.clone()
};
if let Some(catalog_map) = catalog_value.as_object() {
let entries = collect_json_entries(catalog_map, line_index, &line_key);
if entries.is_empty() {
empty_named_catalog_groups.push(PnpmCatalogGroup {
name: name.clone(),
line: line_index.group_line_for(&line_key).unwrap_or(1),
});
} else {
catalogs.push(PnpmCatalog {
name: name.clone(),
entries,
});
}
} else if catalog_value.is_null() {
empty_named_catalog_groups.push(PnpmCatalogGroup {
name: name.clone(),
line: line_index.group_line_for(&line_key).unwrap_or(1),
});
}
}
}
fn collect_entries(
mapping: &serde_yaml_ng::Mapping,
line_index: &CatalogLineIndex,
catalog_name: &str,
) -> Vec<PnpmCatalogEntry> {
mapping
.iter()
.filter_map(|(k, _)| {
let pkg = k.as_str()?;
let line = line_index.line_for(catalog_name, pkg)?;
Some(PnpmCatalogEntry {
package_name: pkg.to_string(),
line,
})
})
.collect()
}
fn collect_json_entries(
mapping: &serde_json::Map<String, serde_json::Value>,
line_index: &CatalogLineIndex,
catalog_name: &str,
) -> Vec<PnpmCatalogEntry> {
mapping
.keys()
.map(|pkg| PnpmCatalogEntry {
package_name: pkg.clone(),
line: line_index.line_for(catalog_name, pkg).unwrap_or(1),
})
.collect()
}
fn workspace_catalog_key(name: &str) -> String {
format!("workspaces.{name}")
}
struct CatalogLineIndex {
entries: Vec<((String, String), u32)>,
groups: Vec<(String, u32)>,
}
impl CatalogLineIndex {
fn line_for(&self, catalog_name: &str, package_name: &str) -> Option<u32> {
self.entries
.iter()
.find(|((cat, pkg), _)| cat == catalog_name && pkg == package_name)
.map(|(_, line)| *line)
}
fn group_line_for(&self, catalog_name: &str) -> Option<u32> {
self.groups
.iter()
.find(|(name, _)| name == catalog_name)
.map(|(_, line)| *line)
}
}
fn build_line_index(source: &str) -> CatalogLineIndex {
let mut scan = YamlCatalogScan::default();
for (idx, raw_line) in source.lines().enumerate() {
let line_no = u32::try_from(idx).unwrap_or(u32::MAX).saturating_add(1);
scan.record_line(raw_line, line_no);
}
scan.finish()
}
#[derive(Default)]
struct YamlCatalogScan {
entries: Vec<((String, String), u32)>,
groups: Vec<(String, u32)>,
section: Section,
named_catalog: Option<(String, usize)>,
}
impl YamlCatalogScan {
fn record_line(&mut self, raw_line: &str, line_no: u32) {
let trimmed = strip_inline_comment(raw_line);
let trimmed_left = trimmed.trim_start();
let indent = trimmed.len() - trimmed_left.len();
if trimmed_left.is_empty() {
return;
}
if indent == 0 {
self.enter_top_level_section(trimmed_left);
return;
}
self.record_catalog_key(trimmed_left, indent, line_no);
}
fn enter_top_level_section(&mut self, trimmed_left: &str) {
self.section = if trimmed_left.starts_with("catalogs:") {
Section::NamedCatalogs
} else if trimmed_left.starts_with("catalog:") {
Section::DefaultCatalog
} else {
Section::None
};
self.named_catalog = None;
}
fn record_catalog_key(&mut self, trimmed_left: &str, indent: usize, line_no: u32) {
let Some(name) = parse_key(trimmed_left) else {
return;
};
match self.section {
Section::None => {}
Section::DefaultCatalog => {
self.entries.push((("default".to_string(), name), line_no));
}
Section::NamedCatalogs => self.record_named_catalog_key(name, indent, line_no),
}
}
fn record_named_catalog_key(&mut self, name: String, indent: usize, line_no: u32) {
if let Some((catalog_name, existing_indent)) = &self.named_catalog
&& indent > *existing_indent
{
self.entries.push(((catalog_name.clone(), name), line_no));
return;
}
self.groups.push((name.clone(), line_no));
self.named_catalog = Some((name, indent));
}
fn finish(self) -> CatalogLineIndex {
CatalogLineIndex {
entries: self.entries,
groups: self.groups,
}
}
}
#[derive(Default)]
struct JsonCatalogScan {
entries: Vec<((String, String), u32)>,
groups: Vec<(String, u32)>,
current_depth: u32,
workspaces_depth: Option<u32>,
current_section_prefix: Option<&'static str>,
section: Section,
section_depth: u32,
named_catalog: Option<(String, u32)>,
}
impl JsonCatalogScan {
fn record_key(&mut self, name: &str, parent_depth: u32, line_no: u32) {
match self.section {
Section::DefaultCatalog if parent_depth == self.section_depth => {
let catalog_name = self.current_section_prefix.map_or_else(
|| "default".to_string(),
|prefix| format!("{prefix}.default"),
);
self.entries
.push(((catalog_name, name.to_string()), line_no));
}
Section::NamedCatalogs if parent_depth == self.section_depth => {
let catalog_name = self
.current_section_prefix
.map_or_else(|| name.to_string(), |prefix| format!("{prefix}.{name}"));
self.groups.push((catalog_name.clone(), line_no));
self.named_catalog = Some((catalog_name, parent_depth));
}
Section::NamedCatalogs => {
if let Some((catalog_name, group_depth)) = &self.named_catalog
&& parent_depth == group_depth.saturating_add(1)
{
self.entries
.push(((catalog_name.clone(), name.to_string()), line_no));
}
}
Section::DefaultCatalog | Section::None => {}
}
}
fn enter_section(&mut self, name: &str, parent_depth: u32, opens: u32) {
let in_supported_parent = parent_depth == 1
|| self
.workspaces_depth
.is_some_and(|depth| parent_depth == depth);
if parent_depth == 1 && name == "workspaces" && opens > 0 {
self.workspaces_depth = Some(parent_depth.saturating_add(1));
}
if in_supported_parent && name == "catalog" && opens > 0 {
self.begin_catalog_section(Section::DefaultCatalog, parent_depth);
} else if in_supported_parent && name == "catalogs" && opens > 0 {
self.begin_catalog_section(Section::NamedCatalogs, parent_depth);
}
}
fn begin_catalog_section(&mut self, section: Section, parent_depth: u32) {
self.section = section;
self.section_depth = parent_depth.saturating_add(1);
self.current_section_prefix = self
.workspaces_depth
.is_some_and(|depth| parent_depth == depth)
.then_some("workspaces");
self.named_catalog = None;
}
fn close_exited_scopes(&mut self) {
if matches!(
self.section,
Section::DefaultCatalog | Section::NamedCatalogs
) && self.current_depth < self.section_depth
{
self.section = Section::None;
self.current_section_prefix = None;
self.named_catalog = None;
}
if let Some(depth) = self.workspaces_depth
&& self.current_depth < depth
{
self.workspaces_depth = None;
}
}
}
fn build_package_json_line_index(source: &str) -> CatalogLineIndex {
let mut scan = JsonCatalogScan::default();
for (idx, raw_line) in source.lines().enumerate() {
let line_no = u32::try_from(idx).unwrap_or(u32::MAX).saturating_add(1);
let trimmed = raw_line.trim();
if trimmed.is_empty() {
continue;
}
let key = parse_json_key(trimmed);
let parent_depth = scan.current_depth;
if let Some(name) = key {
scan.record_key(name, parent_depth, line_no);
}
let (opens, closes) = count_json_braces(raw_line);
let depth_after_opens = scan.current_depth.saturating_add(opens);
if let Some(name) = key {
scan.enter_section(name, parent_depth, opens);
}
scan.current_depth = depth_after_opens.saturating_sub(closes);
scan.close_exited_scopes();
}
CatalogLineIndex {
entries: scan.entries,
groups: scan.groups,
}
}
fn parse_json_key(trimmed: &str) -> Option<&str> {
let rest = trimmed.strip_prefix('"')?;
let end = rest.find('"')?;
let after = rest[end.saturating_add(1)..].trim_start();
after.starts_with(':').then_some(&rest[..end])
}
fn count_json_braces(line: &str) -> (u32, u32) {
let mut opens: u32 = 0;
let mut closes: u32 = 0;
let mut in_string = false;
let mut escaped = false;
for ch in line.chars() {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == '"' {
in_string = !in_string;
continue;
}
if in_string {
continue;
}
match ch {
'{' => opens = opens.saturating_add(1),
'}' => closes = closes.saturating_add(1),
_ => {}
}
}
(opens, closes)
}
#[derive(Debug, Clone, Copy, Default)]
enum Section {
#[default]
None,
DefaultCatalog,
NamedCatalogs,
}
pub(super) fn strip_inline_comment(line: &str) -> &str {
let bytes = line.as_bytes();
let mut in_single = false;
let mut in_double = false;
for (i, &b) in bytes.iter().enumerate() {
match b {
b'\'' if !in_double => in_single = !in_single,
b'"' if !in_single => in_double = !in_double,
b'#' if !in_single && !in_double => {
let head = &line[..i];
return head.trim_end();
}
_ => {}
}
}
line.trim_end()
}
pub(super) fn parse_key(line: &str) -> Option<String> {
let bytes = line.as_bytes();
if bytes.is_empty() {
return None;
}
let first = bytes[0];
if first == b'-' || first == b'#' {
return None;
}
if first == b'"' || first == b'\'' {
let quote = first;
let mut i = 1;
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
}
if b == quote {
let key = &line[1..i];
let rest = &line[i + 1..];
let trimmed = rest.trim_start();
if trimmed.starts_with(':') {
return Some(unescape_key(key));
}
return None;
}
i += 1;
}
return None;
}
let colon_pos = bytes.iter().position(|&b| b == b':')?;
let key = line[..colon_pos].trim();
if key.is_empty() {
return None;
}
if key.contains(['{', '[', '&', '*', '!']) {
return None;
}
Some(key.to_string())
}
fn unescape_key(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut chars = raw.chars();
while let Some(c) = chars.next() {
if c == '\\'
&& let Some(next) = chars.next()
{
match next {
'n' => out.push('\n'),
't' => out.push('\t'),
'"' => out.push('"'),
'\\' => out.push('\\'),
other => {
out.push('\\');
out.push(other);
}
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_default_catalog() {
let yaml = "packages:\n - 'packages/*'\n\ncatalog:\n react: ^18.2.0\n is-even: ^1.0.0\n";
let data = parse_pnpm_catalog_data(yaml);
assert_eq!(data.catalogs.len(), 1);
let default = &data.catalogs[0];
assert_eq!(default.name, "default");
assert_eq!(default.entries.len(), 2);
assert_eq!(default.entries[0].package_name, "react");
assert_eq!(default.entries[0].line, 5);
assert_eq!(default.entries[1].package_name, "is-even");
assert_eq!(default.entries[1].line, 6);
}
#[test]
fn parses_bun_workspaces_catalog() {
let json = r#"{
"name": "demo",
"workspaces": {
"packages": ["packages/*"],
"catalog": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"catalogs": {
"testing": {
"vitest": "^3.0.0"
},
"empty": {}
}
}
}
"#;
let data = parse_package_json_catalog_data(json);
assert_eq!(data.catalogs.len(), 2);
assert_eq!(data.catalogs[0].name, "default");
assert_eq!(data.catalogs[0].entries[0].package_name, "react");
assert_eq!(data.catalogs[0].entries[0].line, 6);
assert_eq!(data.catalogs[1].name, "testing");
assert_eq!(data.catalogs[1].entries[0].package_name, "vitest");
assert_eq!(data.catalogs[1].entries[0].line, 11);
let empty: Vec<_> = data
.empty_named_catalog_groups
.iter()
.map(|group| (group.name.as_str(), group.line))
.collect();
assert_eq!(empty, vec![("empty", 13)]);
}
#[test]
fn parses_bun_top_level_catalog_fallback() {
let json = r#"{
"name": "demo",
"workspaces": ["packages/*"],
"catalog": {
"bun-types": "^1.3.0"
},
"catalogs": {
"testing": {
"vitest": "^3.0.0"
}
}
}
"#;
let data = parse_package_json_catalog_data(json);
assert_eq!(data.catalogs.len(), 2);
assert_eq!(data.catalogs[0].name, "default");
assert_eq!(data.catalogs[0].entries[0].package_name, "bun-types");
assert_eq!(data.catalogs[0].entries[0].line, 5);
assert_eq!(data.catalogs[1].name, "testing");
assert_eq!(data.catalogs[1].entries[0].line, 9);
}
#[test]
fn workspaces_catalog_takes_precedence_over_top_level_catalog() {
let json = r#"{
"workspaces": {
"packages": ["packages/*"],
"catalog": {
"react": "^19.0.0"
}
},
"catalog": {
"react": "^18.0.0",
"vue": "^3.0.0"
}
}
"#;
let data = parse_package_json_catalog_data(json);
assert_eq!(data.catalogs.len(), 1);
let entries: Vec<_> = data.catalogs[0]
.entries
.iter()
.map(|entry| entry.package_name.as_str())
.collect();
assert_eq!(entries, vec!["react"]);
assert_eq!(data.catalogs[0].entries[0].line, 5);
}
#[test]
fn workspaces_catalog_line_wins_when_top_level_catalog_appears_first() {
let json = r#"{
"catalog": {
"react": "^18.0.0"
},
"workspaces": {
"packages": ["packages/*"],
"catalog": {
"react": "^19.0.0"
}
}
}
"#;
let data = parse_package_json_catalog_data(json);
assert_eq!(data.catalogs.len(), 1);
assert_eq!(data.catalogs[0].entries[0].package_name, "react");
assert_eq!(data.catalogs[0].entries[0].line, 8);
}
#[test]
fn parses_named_catalogs() {
let yaml = "catalogs:\n react17:\n react: ^17.0.2\n react-dom: ^17.0.2\n ui:\n headlessui: ^2.0.0\n";
let data = parse_pnpm_catalog_data(yaml);
assert_eq!(data.catalogs.len(), 2);
assert_eq!(data.catalogs[0].name, "react17");
assert_eq!(data.catalogs[0].entries.len(), 2);
assert_eq!(data.catalogs[0].entries[0].package_name, "react");
assert_eq!(data.catalogs[0].entries[0].line, 3);
assert_eq!(data.catalogs[1].name, "ui");
assert_eq!(data.catalogs[1].entries[0].package_name, "headlessui");
assert_eq!(data.catalogs[1].entries[0].line, 6);
assert!(data.empty_named_catalog_groups.is_empty());
}
#[test]
fn handles_default_and_named_together() {
let yaml = "catalog:\n react: ^18\n\ncatalogs:\n legacy:\n react: ^17\n";
let data = parse_pnpm_catalog_data(yaml);
assert_eq!(data.catalogs.len(), 2);
assert_eq!(data.catalogs[0].name, "default");
assert_eq!(data.catalogs[0].entries[0].line, 2);
assert_eq!(data.catalogs[1].name, "legacy");
assert_eq!(data.catalogs[1].entries[0].line, 6);
}
#[test]
fn handles_quoted_keys() {
let yaml = "catalog:\n \"@scope/lib\": ^1.0.0\n 'my-pkg': ^2.0.0\n";
let data = parse_pnpm_catalog_data(yaml);
let default = &data.catalogs[0];
assert_eq!(default.entries[0].package_name, "@scope/lib");
assert_eq!(default.entries[0].line, 2);
assert_eq!(default.entries[1].package_name, "my-pkg");
assert_eq!(default.entries[1].line, 3);
}
#[test]
fn handles_inline_comments() {
let yaml = "catalog:\n react: ^18 # pin until #1234\n is-even: ^1.0\n";
let data = parse_pnpm_catalog_data(yaml);
assert_eq!(data.catalogs[0].entries.len(), 2);
assert_eq!(data.catalogs[0].entries[0].package_name, "react");
assert_eq!(data.catalogs[0].entries[1].package_name, "is-even");
assert_eq!(data.catalogs[0].entries[1].line, 3);
}
#[test]
fn handles_four_space_indentation() {
let yaml = "catalog:\n react: ^18.2.0\n vue: ^3.4.0\n";
let data = parse_pnpm_catalog_data(yaml);
assert_eq!(data.catalogs[0].entries.len(), 2);
assert_eq!(data.catalogs[0].entries[0].line, 2);
assert_eq!(data.catalogs[0].entries[1].line, 3);
}
#[test]
fn empty_catalog_returns_no_catalogs() {
let yaml = "catalog: {}\n";
let data = parse_pnpm_catalog_data(yaml);
assert!(data.catalogs.is_empty());
assert!(data.empty_named_catalog_groups.is_empty());
}
#[test]
fn tracks_empty_named_catalog_groups() {
let yaml = "catalog:\n react: ^18\n\ncatalogs:\n react17: {}\n legacy:\n # retained note\n vue3:\n vue: ^3.4.0\n";
let data = parse_pnpm_catalog_data(yaml);
assert_eq!(data.catalogs.len(), 2);
let empty: Vec<_> = data
.empty_named_catalog_groups
.iter()
.map(|group| (group.name.as_str(), group.line))
.collect();
assert_eq!(empty, vec![("react17", 5), ("legacy", 6)]);
}
#[test]
fn no_catalog_keys_returns_no_catalogs() {
let yaml = "packages:\n - 'packages/*'\n";
let data = parse_pnpm_catalog_data(yaml);
assert!(data.catalogs.is_empty());
}
#[test]
fn malformed_yaml_returns_no_catalogs() {
let yaml = "{this is\nnot: valid: yaml: at: all";
let data = parse_pnpm_catalog_data(yaml);
assert!(data.catalogs.is_empty());
}
#[test]
fn empty_input_returns_no_catalogs() {
let data = parse_pnpm_catalog_data("");
assert!(data.catalogs.is_empty());
}
#[test]
fn handles_object_form_entries() {
let yaml = "catalog:\n react:\n specifier: ^18.2.0\n vue: ^3.4.0\n";
let data = parse_pnpm_catalog_data(yaml);
assert_eq!(data.catalogs[0].entries.len(), 2);
let names: Vec<_> = data.catalogs[0]
.entries
.iter()
.map(|e| e.package_name.as_str())
.collect();
assert!(names.contains(&"react"));
assert!(names.contains(&"vue"));
}
#[test]
fn skips_packages_section() {
let yaml = "packages:\n - 'apps/*'\n - 'libs/*'\ncatalog:\n react: ^18\n";
let data = parse_pnpm_catalog_data(yaml);
assert_eq!(data.catalogs.len(), 1);
assert_eq!(data.catalogs[0].entries[0].line, 5);
}
#[test]
fn strip_inline_comment_preserves_quoted_hash() {
assert_eq!(strip_inline_comment("foo: \"a#b\" # tail"), "foo: \"a#b\"");
assert_eq!(strip_inline_comment("# top-level"), "");
assert_eq!(strip_inline_comment("plain: value"), "plain: value");
}
#[test]
fn parse_key_handles_simple_and_quoted() {
assert_eq!(parse_key("react: ^18"), Some("react".to_string()));
assert_eq!(
parse_key("\"@scope/lib\": ^1"),
Some("@scope/lib".to_string())
);
assert_eq!(parse_key("'pkg': ^2"), Some("pkg".to_string()));
assert_eq!(parse_key("- item"), None);
assert_eq!(parse_key(""), None);
}
}