Skip to main content

semifold_resolver/resolver/
nodejs.rs

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    /// Plans a package.json replacement from immutable package and version snapshots.
79    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}