1use std::{collections::BTreeMap, path::Path};
2
3use saphyr::LoadableYamlNode;
4use semifold_core::{
5 DependencyKind, EcosystemId, EditSource, FileEdit, FileEditExpectation, FileHash, PackageId,
6 PackageSnapshot, VersionMap, VersionSource,
7};
8use serde::Deserialize;
9
10use crate::{
11 adapter::{
12 AdapterError, EcosystemAdapter, EcosystemPlanInput, ManifestDependency, PackageInspection,
13 PackageLocation, ParsedPackage,
14 },
15 config::{PackageConfig, ReleaseChannel},
16 error::ResolveError,
17};
18
19#[derive(Deserialize)]
20#[serde(rename_all = "camelCase")]
21struct PackageJson {
22 pub name: String,
23 #[serde(default = "default_node_version")]
24 pub version: String,
25 pub workspaces: Option<Vec<String>>,
26 pub dependencies: Option<BTreeMap<String, String>>,
27 pub dev_dependencies: Option<BTreeMap<String, String>>,
28 pub peer_dependencies: Option<BTreeMap<String, String>>,
29 pub optional_dependencies: Option<BTreeMap<String, String>>,
30 pub private: Option<bool>,
31}
32
33pub struct NodejsResolver;
34
35fn default_node_version() -> String {
36 "0.0.0".to_string()
37}
38
39impl NodejsResolver {
40 fn package_config(path: impl Into<std::path::PathBuf>) -> PackageConfig {
41 PackageConfig {
42 path: path.into(),
43 resolver: EcosystemId::NODE,
44 publish: None,
45 channel: ReleaseChannel::Stable,
46 channel_bump: None,
47 assets: Vec::new(),
48 github_release: None,
49 depends_on: vec![],
50 }
51 }
52
53 fn package_inspection(
54 id: PackageId,
55 package: ParsedPackage,
56 dependencies: Vec<ManifestDependency>,
57 ) -> Result<PackageInspection, AdapterError> {
58 let path = camino::Utf8PathBuf::from_path_buf(package.path).map_err(|path| {
59 AdapterError::InvalidInput {
60 reason: format!(
61 "Node.js package path is not valid UTF-8: {}",
62 path.display()
63 ),
64 }
65 })?;
66 Ok(PackageInspection {
67 id,
68 manifest_name: package.name,
69 version: package.version,
70 version_source: package.version_source,
71 ecosystem: EcosystemId::NODE,
72 path,
73 publishable: !package.private,
74 dependencies,
75 })
76 }
77
78 pub fn plan_file_edit(
80 root: &Path,
81 package: &PackageSnapshot,
82 versions: &VersionMap,
83 ) -> Result<FileEdit, ResolveError> {
84 let manifest_versions = versions
85 .iter()
86 .map(|(package, version)| {
87 NodejsResolver
88 .encode_version(version)
89 .map(|version| (package.to_string(), version))
90 .map_err(|error| ResolveError::InvalidVersion {
91 version: version.to_string(),
92 reason: error.to_string(),
93 })
94 })
95 .collect::<Result<BTreeMap<_, _>, _>>()?;
96 Self::plan_package_edit(root, package, versions, &manifest_versions)
97 }
98
99 fn plan_package_edit(
100 root: &Path,
101 package: &PackageSnapshot,
102 versions: &VersionMap,
103 manifest_versions: &BTreeMap<String, String>,
104 ) -> Result<FileEdit, ResolveError> {
105 let package_json_path = root.join(package.path.as_std_path()).join("package.json");
106 let original = std::fs::read_to_string(&package_json_path)?;
107 let next_version =
108 versions
109 .get(&package.id)
110 .ok_or_else(|| ResolveError::InvalidConfig {
111 path: package_json_path.clone(),
112 reason: format!("missing planned version for {}", package.id),
113 })?;
114 let _: PackageJson =
115 serde_json::from_str(&original).map_err(|error| ResolveError::ParseError {
116 path: package_json_path.clone(),
117 reason: error.to_string(),
118 })?;
119 let mut document: serde_json::Value =
120 serde_json::from_str(&original).map_err(|error| ResolveError::ParseError {
121 path: package_json_path.clone(),
122 reason: error.to_string(),
123 })?;
124 let object = document.as_object_mut().ok_or(ResolveError::ParseError {
125 path: package_json_path.clone(),
126 reason: "package.json root must be an object".to_string(),
127 })?;
128 object.insert(
129 "version".to_string(),
130 serde_json::Value::String(NodejsResolver.encode_version(next_version).map_err(
131 |error| ResolveError::InvalidVersion {
132 version: next_version.to_string(),
133 reason: error.to_string(),
134 },
135 )?),
136 );
137 for field in [
138 "dependencies",
139 "devDependencies",
140 "peerDependencies",
141 "optionalDependencies",
142 ] {
143 let Some(dependencies) = object
144 .get_mut(field)
145 .and_then(serde_json::Value::as_object_mut)
146 else {
147 continue;
148 };
149 for (name, requirement_value) in dependencies {
150 let Some(requirement) = requirement_value.as_str() else {
151 continue;
152 };
153 let Some(version) = manifest_versions.get(name) else {
154 continue;
155 };
156 let next_requirement = node_requirement(requirement, version);
157 *requirement_value = serde_json::Value::String(next_requirement);
158 }
159 }
160 let mut updated =
161 serde_json::to_string_pretty(&document).map_err(|error| ResolveError::ParseError {
162 path: package_json_path.clone(),
163 reason: error.to_string(),
164 })?;
165 updated.push('\n');
166
167 Ok(FileEdit {
168 path: package.path.join("package.json"),
169 expected: FileEditExpectation::Existing {
170 hash: FileHash::from_bytes(original.as_bytes()),
171 },
172 new_content: updated,
173 source: EditSource::PackageVersion {
174 package: package.id.clone(),
175 },
176 })
177 }
178
179 fn collect_dependencies(
180 target: &mut Vec<ManifestDependency>,
181 dependencies: Option<BTreeMap<String, String>>,
182 kind: DependencyKind,
183 ) {
184 target.extend(dependencies.unwrap_or_default().into_iter().map(
185 |(manifest_name, requirement)| ManifestDependency {
186 manifest_name,
187 kind,
188 requirement: Some(requirement),
189 },
190 ));
191 }
192
193 fn manifest_dependencies(
194 root: &Path,
195 pkg_config: &PackageConfig,
196 ) -> Result<Vec<ManifestDependency>, ResolveError> {
197 let package_json_path = root.join(&pkg_config.path).join("package.json");
198 let package_json: PackageJson =
199 serde_json::from_str(&std::fs::read_to_string(&package_json_path)?).map_err(|e| {
200 ResolveError::ParseError {
201 path: package_json_path,
202 reason: e.to_string(),
203 }
204 })?;
205 let mut dependencies = Vec::new();
206 Self::collect_dependencies(
207 &mut dependencies,
208 package_json.dependencies,
209 DependencyKind::Runtime,
210 );
211 Self::collect_dependencies(
212 &mut dependencies,
213 package_json.dev_dependencies,
214 DependencyKind::Development,
215 );
216 Self::collect_dependencies(
217 &mut dependencies,
218 package_json.peer_dependencies,
219 DependencyKind::Peer,
220 );
221 Self::collect_dependencies(
222 &mut dependencies,
223 package_json.optional_dependencies,
224 DependencyKind::Optional,
225 );
226 Ok(dependencies)
227 }
228}
229
230fn node_requirement(requirement: &str, version: &str) -> String {
231 if requirement == "workspace:*" {
232 return requirement.to_string();
233 }
234 for prefix in ["workspace:^", "workspace:~", "^", "~"] {
235 if requirement.starts_with(prefix) {
236 return format!("{prefix}{version}");
237 }
238 }
239 version.to_string()
240}
241
242impl EcosystemAdapter for NodejsResolver {
243 fn ecosystem(&self) -> EcosystemId {
244 EcosystemId::NODE
245 }
246
247 fn encode_version(&self, version: &semver::Version) -> Result<String, AdapterError> {
248 Ok(version.to_string())
249 }
250
251 fn discover(&self, root: &camino::Utf8Path) -> Result<Vec<PackageInspection>, AdapterError> {
252 let packages = self.discover_packages(root.as_std_path())?;
253 let mut inspections = packages
254 .into_iter()
255 .map(|package| {
256 let dependencies = Self::manifest_dependencies(
257 root.as_std_path(),
258 &Self::package_config(package.path.clone()),
259 )?;
260 Self::package_inspection(
261 PackageId::new(package.name.clone()),
262 package,
263 dependencies,
264 )
265 })
266 .collect::<Result<Vec<_>, AdapterError>>()?;
267 inspections.sort_by(|left, right| {
268 left.id
269 .cmp(&right.id)
270 .then_with(|| left.path.cmp(&right.path))
271 });
272 Ok(inspections)
273 }
274
275 fn inspect(&self, location: &PackageLocation) -> Result<PackageInspection, AdapterError> {
276 if location.path.is_absolute()
277 || location
278 .path
279 .components()
280 .any(|component| component == camino::Utf8Component::ParentDir)
281 {
282 return Err(AdapterError::InvalidInput {
283 reason: format!(
284 "Node.js package path must be relative to the project root: {}",
285 location.path
286 ),
287 });
288 }
289 let config = Self::package_config(location.path.as_std_path());
290 let package = self.parse_package(location.project_root.as_std_path(), &config)?;
291 let dependencies =
292 Self::manifest_dependencies(location.project_root.as_std_path(), &config)?;
293 Self::package_inspection(location.id.clone(), package, dependencies)
294 }
295
296 fn plan_edits(&self, input: EcosystemPlanInput<'_>) -> Result<Vec<FileEdit>, AdapterError> {
297 if input
298 .workspace_packages
299 .iter()
300 .any(|package| package.ecosystem != EcosystemId::NODE)
301 {
302 return Err(AdapterError::InvalidInput {
303 reason: "Node.js edit planning received a non-Node workspace package".to_string(),
304 });
305 }
306 let workspace_packages = input
307 .workspace_packages
308 .iter()
309 .map(|package| (package.id.clone(), package))
310 .collect::<BTreeMap<_, _>>();
311 let manifest_versions = input
312 .workspace_packages
313 .iter()
314 .filter_map(|package| {
315 input
316 .versions
317 .get(&package.id)
318 .filter(|version| *version != &package.version)
319 .map(|version| (package.manifest_name.clone(), version))
320 })
321 .map(|(name, version)| self.encode_version(version).map(|version| (name, version)))
322 .collect::<Result<BTreeMap<_, _>, _>>()?;
323 let released_packages = input
324 .released_packages
325 .iter()
326 .collect::<std::collections::BTreeSet<_>>();
327
328 released_packages
329 .into_iter()
330 .map(|id| {
331 let package = workspace_packages.get(id).copied().ok_or_else(|| {
332 AdapterError::InvalidInput {
333 reason: format!("released Node.js package {id} is not in the workspace"),
334 }
335 })?;
336 Ok(Self::plan_package_edit(
337 input.project_root.as_std_path(),
338 package,
339 input.versions,
340 &manifest_versions,
341 )?)
342 })
343 .collect()
344 }
345}
346
347impl NodejsResolver {
348 fn parse_package(
349 &self,
350 root: &Path,
351 pkg_config: &PackageConfig,
352 ) -> Result<ParsedPackage, ResolveError> {
353 let package_json_path = root.join(&pkg_config.path).join("package.json");
354 if !package_json_path.exists() {
355 return Err(ResolveError::FileOrDirNotFound {
356 path: package_json_path.clone(),
357 });
358 }
359 let package_json_str = std::fs::read_to_string(&package_json_path)?;
360 let package_json: PackageJson =
361 serde_json::from_str(&package_json_str).map_err(|e| ResolveError::ParseError {
362 path: package_json_path.clone(),
363 reason: e.to_string(),
364 })?;
365
366 let package = ParsedPackage {
367 name: package_json.name,
368 version: semver::Version::parse(&package_json.version)?,
369 version_source: VersionSource::PackageManifest,
370 path: pkg_config.path.clone(),
371 private: package_json.private.unwrap_or(false),
372 };
373 Ok(package)
374 }
375
376 fn discover_packages(&self, root: &Path) -> Result<Vec<ParsedPackage>, ResolveError> {
377 let package_json_path = root.join("package.json");
378 if !package_json_path.exists() {
379 log::warn!(
380 "Cannot resolve package in {}, package.json not found.",
381 root.display()
382 );
383 return Ok(vec![]);
384 }
385
386 let package_json_str = std::fs::read_to_string(&package_json_path)?;
387 let package_json: PackageJson =
388 serde_json::from_str(&package_json_str).map_err(|e| ResolveError::ParseError {
389 path: package_json_path.clone(),
390 reason: e.to_string(),
391 })?;
392
393 let pnpm_workspace_file_path = root.join("pnpm-workspace.yaml");
394 let pnpm_packages = if pnpm_workspace_file_path.exists() {
395 let pnpm_workspace_yaml =
396 saphyr::Yaml::load_from_str(&std::fs::read_to_string(&pnpm_workspace_file_path)?)
397 .map_err(|e| ResolveError::ParseError {
398 path: pnpm_workspace_file_path.clone(),
399 reason: e.to_string(),
400 })?;
401 pnpm_workspace_yaml
402 .first()
403 .and_then(|yaml| yaml.as_mapping_get("packages"))
404 .and_then(|yaml| yaml.as_vec())
405 .map(|vec| {
406 vec.iter()
407 .map(|item| item.as_str().unwrap_or_default().to_string())
408 .collect::<Vec<_>>()
409 })
410 } else {
411 None
412 };
413 let Some(workspaces) = pnpm_packages.or(package_json.workspaces) else {
414 if package_json.name.is_empty() {
415 log::warn!("Failed to resolve package in {}", root.display());
416 return Ok(vec![]);
417 }
418 let package = self.parse_package(
419 root,
420 &PackageConfig {
421 path: ".".into(),
422 resolver: EcosystemId::NODE,
423 publish: None,
424 channel: ReleaseChannel::Stable,
425 channel_bump: None,
426 assets: vec![],
427 github_release: None,
428 depends_on: vec![],
429 },
430 )?;
431 return Ok(vec![package]);
432 };
433 let mut packages = Vec::new();
434
435 let root_package = self.parse_package(
436 root,
437 &PackageConfig {
438 path: ".".into(),
439 resolver: EcosystemId::NODE,
440 publish: None,
441 channel: ReleaseChannel::Stable,
442 channel_bump: None,
443 assets: vec![],
444 github_release: None,
445 depends_on: vec![],
446 },
447 )?;
448 packages.push(root_package);
449
450 for workspace_pattern in workspaces {
451 let pattern = root.join(&workspace_pattern).display().to_string();
452 let paths = glob::glob(&pattern)
453 .map_err(|e| ResolveError::ParseError {
454 path: package_json_path.clone(),
455 reason: e.to_string(),
456 })?
457 .map(|path| {
458 path.map_err(|error| ResolveError::ParseError {
459 path: error.path().to_path_buf(),
460 reason: error.to_string(),
461 })
462 })
463 .collect::<Result<Vec<_>, _>>()?;
464
465 for path in paths {
466 if path == root {
467 continue;
468 }
469
470 if path.join("package.json").exists() {
471 let rel_path = pathdiff::diff_paths(&path, root).unwrap_or(path.clone());
472 packages.push(self.parse_package(
473 root,
474 &PackageConfig {
475 path: rel_path,
476 resolver: EcosystemId::NODE,
477 publish: None,
478 channel: ReleaseChannel::Stable,
479 channel_bump: None,
480 assets: vec![],
481 github_release: None,
482 depends_on: vec![],
483 },
484 )?);
485 }
486 }
487 }
488
489 Ok(packages)
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use std::{
496 fs,
497 path::{Path, PathBuf},
498 time::{SystemTime, UNIX_EPOCH},
499 };
500
501 use crate::{
502 adapter::{EcosystemAdapter, EcosystemPlanInput, PackageLocation},
503 config::{PackageConfig, ReleaseChannel},
504 resolver::ResolverType,
505 };
506 use semifold_core::{EcosystemId, PackageId, PackageSnapshot, VersionMap, VersionSource};
507
508 use super::NodejsResolver;
509
510 fn temp_dir(test_name: &str) -> PathBuf {
511 let nonce = SystemTime::now()
512 .duration_since(UNIX_EPOCH)
513 .unwrap()
514 .as_nanos();
515 let path = std::env::temp_dir().join(format!(
516 "semifold-nodejs-resolver-{test_name}-{}-{nonce}",
517 std::process::id()
518 ));
519 fs::create_dir_all(&path).unwrap();
520 path
521 }
522
523 fn package_config(path: impl Into<PathBuf>) -> PackageConfig {
524 PackageConfig {
525 path: path.into(),
526 resolver: ResolverType::Nodejs.into(),
527 publish: None,
528 channel: ReleaseChannel::Stable,
529 channel_bump: None,
530 assets: vec![],
531 github_release: None,
532 depends_on: vec![],
533 }
534 }
535
536 fn write_package(
537 root: &Path,
538 path: &str,
539 name: &str,
540 version: &str,
541 private: bool,
542 extra: &str,
543 ) {
544 let package_root = root.join(path);
545 fs::create_dir_all(&package_root).unwrap();
546 fs::write(
547 package_root.join("package.json"),
548 format!(
549 "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"private\": {private}{extra}\n}}\n"
550 ),
551 )
552 .unwrap();
553 }
554
555 #[test]
556 fn resolves_a_single_package() {
557 let root = temp_dir("single-package");
558 write_package(&root, ".", "single", "1.2.3", false, "");
559
560 let package = NodejsResolver
561 .parse_package(&root, &package_config("."))
562 .unwrap();
563
564 assert_eq!(package.name, "single");
565 assert_eq!(package.version, semver::Version::parse("1.2.3").unwrap());
566 assert!(!package.private);
567 fs::remove_dir_all(root).unwrap();
568 }
569
570 #[test]
571 fn resolves_a_package_without_a_version_as_zero() {
572 let root = temp_dir("missing-version");
573 fs::write(
574 root.join("package.json"),
575 "{\n \"name\": \"template\"\n}\n",
576 )
577 .unwrap();
578
579 let package = NodejsResolver
580 .parse_package(&root, &package_config("."))
581 .unwrap();
582
583 assert_eq!(package.name, "template");
584 assert_eq!(package.version, semver::Version::new(0, 0, 0));
585 fs::remove_dir_all(root).unwrap();
586 }
587
588 #[test]
589 fn discovers_pnpm_workspace_packages_and_private_members() {
590 let root = temp_dir("pnpm-workspace");
591 write_package(
592 &root,
593 ".",
594 "root",
595 "1.0.0",
596 true,
597 ",\n \"workspaces\": [\"ignored/*\"]",
598 );
599 fs::write(
600 root.join("pnpm-workspace.yaml"),
601 "packages:\n - 'packages/*'\n",
602 )
603 .unwrap();
604 write_package(&root, "packages/core", "core", "1.0.0", false, "");
605 write_package(&root, "packages/internal", "internal", "1.0.0", true, "");
606
607 let mut packages = NodejsResolver.discover_packages(&root).unwrap();
608 packages.sort_by(|left, right| left.name.cmp(&right.name));
609
610 assert_eq!(packages.len(), 3);
611 assert_eq!(packages[0].name, "core");
612 assert_eq!(packages[0].path, PathBuf::from("packages/core"));
613 assert!(!packages[0].private);
614 assert_eq!(packages[1].name, "internal");
615 assert_eq!(packages[1].path, PathBuf::from("packages/internal"));
616 assert!(packages[1].private);
617 assert_eq!(packages[2].name, "root");
618 assert_eq!(packages[2].path, PathBuf::from("."));
619 assert!(packages[2].private);
620 fs::remove_dir_all(root).unwrap();
621 }
622
623 #[test]
624 fn adapter_discovers_and_inspects_manifest_dependencies_before_id_binding() {
625 let root = temp_dir("adapter-inspection");
626 write_package(
627 &root,
628 ".",
629 "root",
630 "1.0.0",
631 true,
632 ",\n \"workspaces\": [\"packages/*\"]",
633 );
634 write_package(&root, "packages/core", "core", "1.0.0", false, "");
635 write_package(
636 &root,
637 "packages/app",
638 "app",
639 "1.0.0",
640 false,
641 ",\n \"dependencies\": { \"core\": \"^1\", \"react\": \"^19\" }",
642 );
643 let project_root = camino::Utf8PathBuf::from_path_buf(root.clone()).unwrap();
644
645 let discovered = NodejsResolver.discover(&project_root).unwrap();
646 assert_eq!(
647 discovered
648 .iter()
649 .map(|package| package.id.as_str())
650 .collect::<Vec<_>>(),
651 ["app", "core", "root"]
652 );
653 let app = NodejsResolver
654 .inspect(&PackageLocation {
655 id: PackageId::new("configured-app"),
656 project_root,
657 path: "packages/app".into(),
658 })
659 .unwrap();
660
661 assert_eq!(app.id, PackageId::new("configured-app"));
662 assert_eq!(app.manifest_name, "app");
663 assert_eq!(
664 app.dependencies
665 .iter()
666 .map(|dependency| dependency.manifest_name.as_str())
667 .collect::<Vec<_>>(),
668 ["core", "react"]
669 );
670 fs::remove_dir_all(root).unwrap();
671 }
672
673 #[test]
674 fn plans_a_package_json_edit_from_the_complete_version_map() {
675 let root = temp_dir("plan-file-edit");
676 let manifest_path = root.join("packages/app/package.json");
677 fs::create_dir_all(manifest_path.parent().unwrap()).unwrap();
678 let original = r#"{
679 "name": "app",
680 "dependencies": { "core": "^1.0.0", "workspace": "workspace:*" },
681 "devDependencies": { "dev": "~2.0.0" },
682 "peerDependencies": { "peer": "workspace:^3.0.0" },
683 "optionalDependencies": { "optional": "4.0.0" },
684 "custom": { "preserved": true, "escaped": "quote \\\" and slash \\\\" }
685}
686"#;
687 fs::write(&manifest_path, original).unwrap();
688 let package = PackageSnapshot {
689 id: PackageId::new("app-id"),
690 manifest_name: "app".to_string(),
691 version: semver::Version::new(0, 0, 0),
692 version_source: VersionSource::PackageManifest,
693 ecosystem: EcosystemId::NODE,
694 path: "packages/app".into(),
695 publishable: true,
696 dependencies: vec![],
697 };
698 let core = PackageSnapshot {
699 id: PackageId::new("core-id"),
700 manifest_name: "core".to_string(),
701 version: semver::Version::new(1, 0, 0),
702 version_source: VersionSource::PackageManifest,
703 ecosystem: EcosystemId::NODE,
704 path: "packages/core".into(),
705 publishable: true,
706 dependencies: vec![],
707 };
708 let dependency_package = |id: &str, version: semver::Version| PackageSnapshot {
709 id: PackageId::new(id),
710 manifest_name: id.to_string(),
711 version,
712 version_source: VersionSource::PackageManifest,
713 ecosystem: EcosystemId::NODE,
714 path: format!("packages/{id}").into(),
715 publishable: true,
716 dependencies: vec![],
717 };
718 let workspace_packages = vec![
719 package.clone(),
720 core,
721 dependency_package("workspace", semver::Version::new(1, 0, 0)),
722 dependency_package("dev", semver::Version::new(2, 0, 0)),
723 dependency_package("peer", semver::Version::new(3, 0, 0)),
724 dependency_package("optional", semver::Version::new(4, 0, 0)),
725 ];
726 let versions = VersionMap::from([
727 (PackageId::new("app-id"), semver::Version::new(1, 0, 1)),
728 (PackageId::new("core-id"), semver::Version::new(1, 1, 0)),
729 (PackageId::new("workspace"), semver::Version::new(1, 1, 0)),
730 (PackageId::new("dev"), semver::Version::new(2, 1, 0)),
731 (PackageId::new("peer"), semver::Version::new(3, 1, 0)),
732 (PackageId::new("optional"), semver::Version::new(4, 1, 0)),
733 ]);
734
735 let project_root = camino::Utf8Path::from_path(&root).unwrap();
736 let edit = NodejsResolver
737 .plan_edits(EcosystemPlanInput {
738 project_root,
739 workspace_packages: &workspace_packages,
740 released_packages: std::slice::from_ref(&package.id),
741 versions: &versions,
742 })
743 .unwrap()
744 .remove(0);
745
746 assert_eq!(edit.path.as_str(), "packages/app/package.json");
747 assert_eq!(
748 edit.expected,
749 semifold_core::FileEditExpectation::Existing {
750 hash: semifold_core::FileHash::from_bytes(original.as_bytes()),
751 }
752 );
753 assert!(edit.new_content.contains("\"version\": \"1.0.1\""));
754 assert!(edit.new_content.contains("\"core\": \"^1.1.0\""));
755 assert!(edit.new_content.contains("\"workspace\": \"workspace:*\""));
756 assert!(edit.new_content.contains("\"dev\": \"~2.1.0\""));
757 assert!(edit.new_content.contains("\"peer\": \"workspace:^3.1.0\""));
758 assert!(edit.new_content.contains("\"optional\": \"4.1.0\""));
759 assert!(edit.new_content.ends_with('\n'));
760 let rendered = serde_json::from_str::<serde_json::Value>(&edit.new_content).unwrap();
761 assert_eq!(rendered["custom"]["preserved"], true);
762 assert_eq!(
763 rendered["custom"]["escaped"],
764 serde_json::from_str::<serde_json::Value>(original).unwrap()["custom"]["escaped"]
765 );
766 assert!(
767 edit.new_content.find("\"name\"").unwrap()
768 < edit.new_content.find("\"dependencies\"").unwrap()
769 );
770 assert_eq!(fs::read_to_string(manifest_path).unwrap(), original);
771 fs::remove_dir_all(root).unwrap();
772 }
773}