1use std::path::Path;
39
40use super::pnpm_catalog::{parse_key, strip_inline_comment};
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum OverrideSource {
46 PnpmWorkspaceYaml,
48 PnpmPackageJson,
50}
51
52#[derive(Debug, Clone, Default)]
54pub struct PnpmOverrideData {
55 pub entries: Vec<PnpmOverrideEntry>,
57}
58
59#[derive(Debug, Clone)]
61pub struct PnpmOverrideEntry {
62 pub raw_key: String,
66 pub parsed_key: Option<ParsedOverrideKey>,
70 pub raw_value: Option<String>,
73 pub line: u32,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ParsedOverrideKey {
80 pub parent_package: Option<String>,
82 pub parent_version_selector: Option<String>,
85 pub target_package: String,
87 pub target_version_selector: Option<String>,
90}
91
92#[must_use]
96pub fn parse_pnpm_workspace_overrides(source: &str) -> PnpmOverrideData {
97 let value: serde_yaml_ng::Value = match serde_yaml_ng::from_str(source) {
98 Ok(v) => v,
99 Err(_) => return PnpmOverrideData::default(),
100 };
101 let Some(mapping) = value.as_mapping() else {
102 return PnpmOverrideData::default();
103 };
104 let Some(overrides_value) = mapping.get("overrides") else {
105 return PnpmOverrideData::default();
106 };
107 let Some(overrides_map) = overrides_value.as_mapping() else {
108 return PnpmOverrideData::default();
109 };
110
111 let line_index = build_yaml_line_index(source);
112 let entries = overrides_map
113 .iter()
114 .filter_map(|(k, v)| {
115 let raw_key = k.as_str()?.to_string();
116 let raw_value = match v {
117 serde_yaml_ng::Value::String(s) => Some(s.clone()),
118 serde_yaml_ng::Value::Null => None,
119 other => Some(yaml_value_to_string(other)),
120 };
121 let line = line_index.line_for(&raw_key)?;
122 let parsed_key = parse_override_key(&raw_key);
123 Some(PnpmOverrideEntry {
124 raw_key,
125 parsed_key,
126 raw_value,
127 line,
128 })
129 })
130 .collect();
131
132 PnpmOverrideData { entries }
133}
134
135#[must_use]
139pub fn parse_pnpm_package_json_overrides(source: &str) -> PnpmOverrideData {
140 let value: serde_json::Value = match serde_json::from_str(source) {
141 Ok(v) => v,
142 Err(_) => return PnpmOverrideData::default(),
143 };
144 let Some(overrides) = value.get("pnpm").and_then(|p| p.get("overrides")) else {
145 return PnpmOverrideData::default();
146 };
147 let Some(overrides_obj) = overrides.as_object() else {
148 return PnpmOverrideData::default();
149 };
150
151 let line_index = build_package_json_line_index(source);
152 let entries = overrides_obj
153 .iter()
154 .filter_map(|(raw_key, v)| {
155 let raw_value = match v {
156 serde_json::Value::String(s) => Some(s.clone()),
157 serde_json::Value::Null => None,
158 other => Some(other.to_string()),
159 };
160 let line = line_index.line_for(raw_key)?;
161 let parsed_key = parse_override_key(raw_key);
162 Some(PnpmOverrideEntry {
163 raw_key: raw_key.clone(),
164 parsed_key,
165 raw_value,
166 line,
167 })
168 })
169 .collect();
170
171 PnpmOverrideData { entries }
172}
173
174#[must_use]
178pub fn parse_override_key(key: &str) -> Option<ParsedOverrideKey> {
179 let trimmed = key.trim();
180 if trimmed.is_empty() {
181 return None;
182 }
183
184 let (parent_part, target_part) = if let Some(idx) = trimmed.rfind('>') {
190 (Some(trimmed[..idx].trim()), trimmed[idx + 1..].trim())
191 } else {
192 (None, trimmed)
193 };
194
195 let (target_package, target_version_selector) = split_pkg_and_selector(target_part)?;
196
197 let (parent_package, parent_version_selector) = match parent_part {
198 Some(parent) if !parent.is_empty() => {
199 let (pkg, selector) = split_pkg_and_selector(parent)?;
200 (Some(pkg), selector)
201 }
202 Some(_) => return None,
205 None => (None, None),
206 };
207
208 Some(ParsedOverrideKey {
209 parent_package,
210 parent_version_selector,
211 target_package,
212 target_version_selector,
213 })
214}
215
216fn split_pkg_and_selector(segment: &str) -> Option<(String, Option<String>)> {
220 let trimmed = segment.trim();
221 if trimmed.is_empty() {
222 return None;
223 }
224
225 let bytes = trimmed.as_bytes();
226 let scoped = bytes.first().copied() == Some(b'@');
227 let start = usize::from(scoped);
228 let at_pos = trimmed[start..].find('@').map(|i| i + start);
229
230 let (pkg, selector) = match at_pos {
231 Some(pos) => (
232 trimmed[..pos].to_string(),
233 Some(trimmed[pos + 1..].to_string()),
234 ),
235 None => (trimmed.to_string(), None),
236 };
237
238 if pkg.is_empty() {
239 return None;
240 }
241 Some((pkg, selector))
242}
243
244#[must_use]
248pub fn is_valid_override_value(value: &str) -> bool {
249 let trimmed = value.trim();
250 if trimmed.is_empty() {
251 return false;
252 }
253 if trimmed.contains('\n') {
254 return false;
255 }
256 true
260}
261
262#[must_use]
265pub fn override_misconfig_reason(entry: &PnpmOverrideEntry) -> Option<MisconfigReason> {
266 if entry.parsed_key.is_none() {
267 return Some(MisconfigReason::UnparsableKey);
268 }
269 match &entry.raw_value {
270 None => Some(MisconfigReason::EmptyValue),
271 Some(v) if !is_valid_override_value(v) => Some(MisconfigReason::EmptyValue),
272 _ => None,
273 }
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
278#[serde(rename_all = "kebab-case")]
279pub enum MisconfigReason {
280 UnparsableKey,
282 EmptyValue,
284}
285
286impl MisconfigReason {
287 #[must_use]
289 pub const fn describe(self) -> &'static str {
290 match self {
291 Self::UnparsableKey => "override key cannot be parsed",
292 Self::EmptyValue => "override value is missing or empty",
293 }
294 }
295}
296
297struct YamlLineIndex {
298 entries: Vec<(String, u32)>,
299}
300
301impl YamlLineIndex {
302 fn line_for(&self, key: &str) -> Option<u32> {
303 self.entries
304 .iter()
305 .find(|(k, _)| k == key)
306 .map(|(_, line)| *line)
307 }
308}
309
310fn build_yaml_line_index(source: &str) -> YamlLineIndex {
313 let mut entries = Vec::new();
314 let mut in_overrides = false;
315
316 for (idx, raw_line) in source.lines().enumerate() {
317 let line_no = u32::try_from(idx).unwrap_or(u32::MAX).saturating_add(1);
318 let trimmed = strip_inline_comment(raw_line);
319 let trimmed_left = trimmed.trim_start();
320 let indent = trimmed.len() - trimmed_left.len();
321
322 if trimmed_left.is_empty() {
323 continue;
324 }
325
326 if indent == 0 {
327 in_overrides = trimmed_left.starts_with("overrides:");
328 continue;
329 }
330
331 if in_overrides && let Some(key) = parse_key(trimmed_left) {
332 entries.push((key, line_no));
333 }
334 }
335
336 YamlLineIndex { entries }
337}
338
339fn build_package_json_line_index(source: &str) -> YamlLineIndex {
344 let mut entries = Vec::new();
345 let mut depth: i32 = 0;
346 let mut pnpm_depth: Option<i32> = None;
347 let mut in_overrides_depth: Option<i32> = None;
348 let mut in_string = false;
349 let mut escape = false;
350 let mut current_line = 1u32;
351 let mut last_key: Option<String> = None;
352 let mut key_buf = String::new();
353 let mut collecting_key = false;
354
355 for ch in source.chars() {
356 if ch == '\n' {
357 current_line += 1;
358 }
359
360 if in_string {
361 if escape {
362 if collecting_key {
363 key_buf.push(ch);
364 }
365 escape = false;
366 continue;
367 }
368 if ch == '\\' {
369 escape = true;
370 if collecting_key {
371 key_buf.push(ch);
372 }
373 continue;
374 }
375 if ch == '"' {
376 in_string = false;
377 if collecting_key {
378 last_key = Some(std::mem::take(&mut key_buf));
379 collecting_key = false;
380 }
381 continue;
382 }
383 if collecting_key {
384 key_buf.push(ch);
385 }
386 continue;
387 }
388
389 match ch {
390 '"' => {
391 in_string = true;
392 collecting_key = true;
395 key_buf.clear();
396 }
397 '{' => depth += 1,
398 '}' => {
399 if Some(depth) == in_overrides_depth {
400 in_overrides_depth = None;
401 }
402 if Some(depth) == pnpm_depth {
403 pnpm_depth = None;
404 }
405 depth -= 1;
406 }
407 ':' => {
408 if let Some(key) = last_key.take() {
409 if pnpm_depth.is_none() && depth == 1 && key == "pnpm" {
413 pnpm_depth = Some(depth);
414 } else if in_overrides_depth.is_none()
415 && pnpm_depth.is_some()
416 && depth == pnpm_depth.unwrap_or(0) + 1
417 && key == "overrides"
418 {
419 in_overrides_depth = Some(depth);
420 } else if let Some(d) = in_overrides_depth
421 && depth == d + 1
422 {
423 entries.push((key, current_line));
425 }
426 }
427 }
428 ',' => {
429 last_key = None;
430 }
431 _ => {}
432 }
433 }
434
435 YamlLineIndex { entries }
436}
437
438fn yaml_value_to_string(value: &serde_yaml_ng::Value) -> String {
439 match value {
440 serde_yaml_ng::Value::String(s) => s.clone(),
441 serde_yaml_ng::Value::Number(n) => n.to_string(),
442 serde_yaml_ng::Value::Bool(b) => b.to_string(),
443 serde_yaml_ng::Value::Null => String::new(),
444 _ => serde_yaml_ng::to_string(value).unwrap_or_default(),
445 }
446}
447
448#[must_use]
450pub fn override_source_label(source: OverrideSource, path: &Path) -> String {
451 match source {
452 OverrideSource::PnpmWorkspaceYaml => "pnpm-workspace.yaml".to_string(),
453 OverrideSource::PnpmPackageJson => path.display().to_string(),
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 #[test]
462 fn parse_bare_target() {
463 let parsed = parse_override_key("axios").unwrap();
464 assert_eq!(parsed.target_package, "axios");
465 assert!(parsed.parent_package.is_none());
466 assert!(parsed.target_version_selector.is_none());
467 }
468
469 #[test]
470 fn parse_scoped_target() {
471 let parsed = parse_override_key("@types/react").unwrap();
472 assert_eq!(parsed.target_package, "@types/react");
473 assert!(parsed.target_version_selector.is_none());
474 }
475
476 #[test]
477 fn parse_target_with_version_selector() {
478 let parsed = parse_override_key("@types/react@<18").unwrap();
479 assert_eq!(parsed.target_package, "@types/react");
480 assert_eq!(parsed.target_version_selector.as_deref(), Some("<18"));
481 }
482
483 #[test]
484 fn parse_parent_chain() {
485 let parsed = parse_override_key("react>react-dom").unwrap();
486 assert_eq!(parsed.parent_package.as_deref(), Some("react"));
487 assert_eq!(parsed.target_package, "react-dom");
488 }
489
490 #[test]
491 fn parse_parent_chain_with_selectors() {
492 let parsed = parse_override_key("react@1>zoo").unwrap();
493 assert_eq!(parsed.parent_package.as_deref(), Some("react"));
494 assert_eq!(parsed.parent_version_selector.as_deref(), Some("1"));
495 assert_eq!(parsed.target_package, "zoo");
496 }
497
498 #[test]
499 fn parse_scoped_parent_and_target() {
500 let parsed = parse_override_key("@react-spring/web>@react-spring/core").unwrap();
501 assert_eq!(parsed.parent_package.as_deref(), Some("@react-spring/web"));
502 assert_eq!(parsed.target_package, "@react-spring/core");
503 }
504
505 #[test]
506 fn parse_empty_returns_none() {
507 assert!(parse_override_key("").is_none());
508 assert!(parse_override_key(" ").is_none());
509 }
510
511 #[test]
512 fn parse_dangling_separator_returns_none() {
513 assert!(parse_override_key("react>").is_none());
514 assert!(parse_override_key(">react-dom").is_none());
515 }
516
517 #[test]
518 fn is_valid_override_value_accepts_pnpm_idioms() {
519 assert!(is_valid_override_value("^1.6.0"));
520 assert!(is_valid_override_value("-"));
521 assert!(is_valid_override_value("$foo"));
522 assert!(is_valid_override_value("npm:@scope/alias@^1.0.0"));
523 assert!(is_valid_override_value("workspace:*"));
524 }
525
526 #[test]
527 fn is_valid_override_value_rejects_empty_and_newline() {
528 assert!(!is_valid_override_value(""));
529 assert!(!is_valid_override_value(" "));
530 assert!(!is_valid_override_value("^1\n^2"));
531 }
532
533 #[test]
534 fn parses_workspace_yaml_overrides() {
535 let yaml = "packages:\n - 'packages/*'\n\noverrides:\n axios: ^1.6.0\n \"@types/react@<18\": '18.0.0'\n \"react>react-dom\": ^17\n";
536 let data = parse_pnpm_workspace_overrides(yaml);
537 assert_eq!(data.entries.len(), 3);
538 assert_eq!(data.entries[0].raw_key, "axios");
539 assert_eq!(data.entries[0].line, 5);
540 assert_eq!(data.entries[0].raw_value.as_deref(), Some("^1.6.0"));
541
542 assert_eq!(data.entries[1].raw_key, "@types/react@<18");
543 assert_eq!(data.entries[1].line, 6);
544 assert_eq!(data.entries[1].raw_value.as_deref(), Some("18.0.0"));
545 assert_eq!(
546 data.entries[1]
547 .parsed_key
548 .as_ref()
549 .and_then(|p| p.target_version_selector.as_deref()),
550 Some("<18")
551 );
552
553 assert_eq!(data.entries[2].raw_key, "react>react-dom");
554 assert_eq!(data.entries[2].line, 7);
555 assert_eq!(
556 data.entries[2]
557 .parsed_key
558 .as_ref()
559 .map(|p| p.target_package.as_str()),
560 Some("react-dom")
561 );
562 }
563
564 #[test]
565 fn parses_package_json_overrides() {
566 let json = r#"{
567 "name": "root",
568 "pnpm": {
569 "overrides": {
570 "axios": "^1.6.0",
571 "react>react-dom": "^17"
572 }
573 },
574 "dependenciesMeta": {
575 "shouldNotMatch": { "injected": true }
576 }
577}"#;
578 let data = parse_pnpm_package_json_overrides(json);
579 assert_eq!(data.entries.len(), 2);
580 assert_eq!(data.entries[0].raw_key, "axios");
581 assert_eq!(data.entries[0].raw_value.as_deref(), Some("^1.6.0"));
582 assert_eq!(data.entries[0].line, 5);
583 assert_eq!(data.entries[1].raw_key, "react>react-dom");
584 assert_eq!(data.entries[1].line, 6);
585 }
586
587 #[test]
588 fn empty_workspace_overrides_returns_no_entries() {
589 let data = parse_pnpm_workspace_overrides("overrides: {}\n");
590 assert!(data.entries.is_empty());
591 }
592
593 #[test]
594 fn malformed_yaml_returns_no_entries() {
595 let data = parse_pnpm_workspace_overrides("{this is\nnot: valid: yaml");
596 assert!(data.entries.is_empty());
597 }
598
599 #[test]
600 fn package_json_without_pnpm_overrides_returns_no_entries() {
601 let data = parse_pnpm_package_json_overrides(r#"{"dependencies": {"axios": "^1"}}"#);
602 assert!(data.entries.is_empty());
603 }
604
605 #[test]
606 fn malformed_json_returns_no_entries() {
607 let data = parse_pnpm_package_json_overrides("{not valid json");
608 assert!(data.entries.is_empty());
609 }
610
611 #[test]
612 fn unparsable_key_carries_misconfig_signal() {
613 let yaml = "overrides:\n \">@bad-key>\": ^1.0.0\n";
614 let data = parse_pnpm_workspace_overrides(yaml);
615 assert_eq!(data.entries.len(), 1);
616 assert!(data.entries[0].parsed_key.is_none());
617 assert_eq!(
618 override_misconfig_reason(&data.entries[0]),
619 Some(MisconfigReason::UnparsableKey)
620 );
621 }
622}