1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct GeneratedFile {
9 pub path: PathBuf,
10 pub contents: String,
11}
12
13impl GeneratedFile {
14 pub fn new(path: impl AsRef<Path>, contents: impl Into<String>) -> Result<Self> {
15 let path = path.as_ref();
16 if path.is_absolute()
17 || path.components().any(|component| {
18 matches!(
19 component,
20 Component::ParentDir | Component::RootDir | Component::Prefix(_)
21 )
22 })
23 {
24 bail!("generated paths must be relative and cannot escape their output directory");
25 }
26 if path.as_os_str().is_empty() {
27 bail!("generated file paths cannot be empty");
28 }
29 Ok(Self {
30 path: path.to_path_buf(),
31 contents: contents.into(),
32 })
33 }
34}
35
36#[derive(Clone, Debug, Default, PartialEq, Eq)]
37pub struct GeneratedTree {
38 files: BTreeMap<PathBuf, String>,
39 preserve_existing: BTreeSet<PathBuf>,
40 owners: BTreeMap<PathBuf, String>,
41 retained_prefixes: BTreeSet<PathBuf>,
42}
43
44type OutputPlan = (
45 OutputChanges,
46 BTreeMap<PathBuf, String>,
47 Ownership,
48 Vec<PathBuf>,
49);
50
51impl GeneratedTree {
52 pub fn insert(&mut self, file: GeneratedFile) -> Result<()> {
53 if self.files.contains_key(&file.path) {
54 bail!("multiple generators emitted {}", file.path.display());
55 }
56 self.files.insert(file.path, file.contents);
57 Ok(())
58 }
59
60 pub fn insert_custom(&mut self, file: GeneratedFile) -> Result<()> {
66 if self.files.contains_key(&file.path) {
67 bail!("multiple generators emitted {}", file.path.display());
68 }
69 self.preserve_existing.insert(file.path.clone());
70 self.files.insert(file.path, file.contents);
71 Ok(())
72 }
73
74 pub fn replace(&mut self, file: GeneratedFile) -> Result<()> {
80 if !self.files.contains_key(&file.path) {
81 bail!(
82 "cannot replace missing generated file {}",
83 file.path.display()
84 );
85 }
86 self.files.insert(file.path, file.contents);
87 Ok(())
88 }
89
90 pub fn get(&self, path: impl AsRef<Path>) -> Option<&str> {
91 self.files.get(path.as_ref()).map(String::as_str)
92 }
93
94 pub fn preserves_existing(&self, path: impl AsRef<Path>) -> bool {
95 self.preserve_existing.contains(path.as_ref())
96 }
97
98 pub fn iter(&self) -> impl Iterator<Item = (&Path, &str)> {
99 self.files
100 .iter()
101 .map(|(path, contents)| (path.as_path(), contents.as_str()))
102 }
103
104 pub fn into_files(self) -> impl Iterator<Item = (GeneratedFile, bool)> {
107 self.into_owned_files()
108 .map(|(file, custom, _)| (file, custom))
109 }
110
111 pub fn into_owned_files(self) -> impl Iterator<Item = (GeneratedFile, bool, Option<String>)> {
112 let Self {
113 files,
114 preserve_existing,
115 mut owners,
116 retained_prefixes: _,
117 } = self;
118 files.into_iter().map(move |(path, contents)| {
119 let custom = preserve_existing.contains(&path);
120 let owner = owners.remove(&path);
121 (GeneratedFile { path, contents }, custom, owner)
122 })
123 }
124
125 pub fn set_owner(&mut self, path: impl AsRef<Path>, owner: impl Into<String>) -> Result<()> {
127 if !self.files.contains_key(path.as_ref()) {
128 bail!("cannot assign ownership to missing generated file");
129 }
130 let owner = owner.into();
131 if owner.trim().is_empty() {
132 bail!("generated owner cannot be empty");
133 }
134 self.owners.insert(path.as_ref().to_path_buf(), owner);
135 Ok(())
136 }
137
138 pub fn append(&mut self, other: GeneratedTree) -> Result<()> {
139 self.retained_prefixes
140 .extend(other.retained_prefixes.iter().cloned());
141 for (file, custom, owner) in other.into_owned_files() {
142 let path = file.path.clone();
143 if custom {
144 self.insert_custom(file)?;
145 } else {
146 self.insert(file)?;
147 }
148 if let Some(owner) = owner {
149 self.set_owner(path, owner)?;
150 }
151 }
152 Ok(())
153 }
154
155 pub fn preserve_owned_prefix(
159 &mut self,
160 root: impl AsRef<Path>,
161 prefix: impl AsRef<Path>,
162 ) -> Result<()> {
163 let prefix = prefix.as_ref();
164 GeneratedFile::new(prefix, "")?;
165 let prefix: PathBuf = prefix
166 .components()
167 .filter(|part| !matches!(part, Component::CurDir))
168 .collect();
169 if prefix.as_os_str().is_empty() {
170 bail!("retained package prefix cannot be empty or '.'");
171 }
172 safe_path(root.as_ref(), &prefix)?;
173 if self.files.keys().any(|path| path.starts_with(&prefix)) {
174 bail!(
175 "retained package prefix overlaps generated files: {}",
176 prefix.display()
177 );
178 }
179 self.retained_prefixes.insert(prefix);
180 Ok(())
181 }
182
183 pub fn check(&self, root: impl AsRef<Path>) -> Result<OutputChanges> {
185 Ok(self.plan(root.as_ref(), false)?.0)
186 }
187
188 pub fn write_to(&self, root: impl AsRef<Path>) -> Result<()> {
191 let root = root.as_ref();
192 let (_, output, manifest, removed) = self.plan(root, true)?;
193 fs::create_dir_all(root)?;
194 for (relative, contents) in output {
195 let destination = root.join(relative);
196 fs::create_dir_all(destination.parent().context("missing output parent")?)?;
197 fs::write(&destination, contents)?;
198 }
199 for relative in removed {
200 fs::remove_file(root.join(relative))?;
201 }
202 let destination = root.join(OWNERSHIP_PATH);
203 fs::create_dir_all(destination.parent().unwrap())?;
204 fs::write(destination, serde_json::to_string_pretty(&manifest)? + "\n")?;
205 Ok(())
206 }
207
208 fn plan(&self, root: &Path, enforce_edits: bool) -> Result<OutputPlan> {
209 safe_path(root, Path::new(OWNERSHIP_PATH))?;
210 let manifest_path = root.join(OWNERSHIP_PATH);
211 let previous: Ownership = if manifest_path.exists() {
212 serde_json::from_str(&fs::read_to_string(&manifest_path)?)
213 .context("invalid Poolster ownership manifest")?
214 } else {
215 Ownership::default()
216 };
217 if previous.version != 1 {
218 bail!("unsupported Poolster ownership manifest version");
219 }
220 for path in previous.files.keys() {
221 GeneratedFile::new(path, "")?;
222 if path == Path::new(OWNERSHIP_PATH) {
223 bail!("ownership manifest cannot own itself");
224 }
225 safe_path(root, path)?;
226 }
227 let mut changes = OutputChanges::default();
228 let mut output = BTreeMap::new();
229 let mut manifest = Ownership::default();
230 for (path, owned) in &previous.files {
231 if self
232 .retained_prefixes
233 .iter()
234 .any(|prefix| path.starts_with(prefix))
235 {
236 manifest.files.insert(path.clone(), owned.clone());
237 }
238 }
239 let mut removed = Vec::new();
240 for (path, generated) in &self.files {
241 if self
242 .retained_prefixes
243 .iter()
244 .any(|prefix| path.starts_with(prefix))
245 {
246 bail!(
247 "retained package prefix overlaps generated file {}",
248 path.display()
249 );
250 }
251 GeneratedFile::new(path, "")?;
252 if path == Path::new(OWNERSHIP_PATH) {
253 bail!("reserved Poolster ownership path");
254 }
255 safe_path(root, path)?;
256 let existing = if root.join(path).exists() {
257 Some(fs::read_to_string(root.join(path))?)
258 } else {
259 None
260 };
261 if self.preserve_existing.contains(path) && existing.is_some() {
262 continue;
263 }
264 let npm = path.file_name().is_some_and(|name| name == "package.json")
265 && !path
266 .components()
267 .any(|part| part.as_os_str() == ".poolster");
268 let contents = if npm {
269 match &existing {
270 Some(existing) => merge_npm_manifest(existing, generated)?,
271 None => merge_npm_manifest("{}", generated)?,
272 }
273 } else {
274 generated.clone()
275 };
276 if let Some(existing) = &existing {
277 if !npm {
278 if let Some(owned) = previous.files.get(path) {
279 if enforce_edits
280 && existing != &contents
281 && digest(existing) != owned.sha256
282 && !equal_package_metadata(path, existing, &contents)
283 {
284 bail!(
285 "locally modified generated file {}; preserve or restore it before regeneration",
286 path.display()
287 );
288 }
289 } else if existing != generated
290 && path != Path::new(".poolster/generation.lock.json")
291 {
292 bail!("refusing to overwrite unowned file {}", path.display());
293 }
294 }
295 if existing != &contents {
296 changes.modified.push(path.clone());
297 }
298 } else {
299 changes.added.push(path.clone());
300 }
301 manifest.files.insert(
302 path.clone(),
303 OwnedFile {
304 owner: self.owners.get(path).cloned().unwrap_or_else(|| {
305 format!(
306 "poolster:{}",
307 path.components()
308 .next()
309 .unwrap()
310 .as_os_str()
311 .to_string_lossy()
312 )
313 }),
314 sha256: digest(&contents),
315 create_once: self.preserve_existing.contains(path),
316 },
317 );
318 output.insert(path.clone(), contents);
319 }
320 for (path, owned) in &previous.files {
321 if self
322 .retained_prefixes
323 .iter()
324 .any(|prefix| path.starts_with(prefix))
325 || self.files.contains_key(path)
326 || owned.create_once
327 || (path.file_name().is_some_and(|name| name == "package.json")
328 && !path
329 .components()
330 .any(|part| part.as_os_str() == ".poolster"))
331 {
332 continue;
333 }
334 if root.join(path).exists() {
335 let existing = fs::read_to_string(root.join(path))?;
336 if enforce_edits && digest(&existing) != owned.sha256 {
337 bail!(
338 "refusing to remove locally modified generated file {}",
339 path.display()
340 );
341 }
342 changes.removed.push(path.clone());
343 removed.push(path.clone());
344 }
345 }
346 Ok((changes, output, manifest, removed))
347 }
348}
349
350fn equal_package_metadata(path: &Path, existing: &str, generated: &str) -> bool {
351 path.ends_with(".poolster/package.json")
352 && matches!(
353 (serde_json::from_str::<serde_json::Value>(existing), serde_json::from_str::<serde_json::Value>(generated)),
354 (Ok(existing), Ok(generated)) if existing == generated
355 )
356}
357
358pub const OWNERSHIP_PATH: &str = ".poolster/ownership.json";
359
360#[derive(Clone, Debug, Default, serde::Serialize, PartialEq, Eq)]
361pub struct OutputChanges {
362 pub added: Vec<PathBuf>,
363 pub modified: Vec<PathBuf>,
364 pub removed: Vec<PathBuf>,
365}
366impl OutputChanges {
367 pub fn is_empty(&self) -> bool {
368 self.added.is_empty() && self.modified.is_empty() && self.removed.is_empty()
369 }
370}
371#[derive(serde::Serialize, serde::Deserialize)]
372#[serde(deny_unknown_fields)]
373struct Ownership {
374 version: u8,
375 files: BTreeMap<PathBuf, OwnedFile>,
376}
377impl Default for Ownership {
378 fn default() -> Self {
379 Self {
380 version: 1,
381 files: BTreeMap::new(),
382 }
383 }
384}
385#[derive(Clone, serde::Serialize, serde::Deserialize)]
386#[serde(deny_unknown_fields)]
387struct OwnedFile {
388 owner: String,
389 sha256: String,
390 #[serde(default)]
391 create_once: bool,
392}
393fn digest(value: &str) -> String {
394 use sha2::{Digest, Sha256};
395 Sha256::digest(value.as_bytes())
396 .iter()
397 .map(|byte| format!("{byte:02x}"))
398 .collect()
399}
400fn safe_path(root: &Path, relative: &Path) -> Result<()> {
401 let mut ancestor = PathBuf::new();
406 for component in root.components() {
407 ancestor.push(component.as_os_str());
408 if fs::symlink_metadata(&ancestor).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
409 let system_alias = cfg!(target_os = "macos")
410 && matches!(ancestor.to_str(), Some("/var" | "/tmp" | "/etc"))
411 && fs::read_link(&ancestor).is_ok_and(|target| {
412 matches!(
413 target.to_str(),
414 Some(
415 "private/var"
416 | "private/tmp"
417 | "private/etc"
418 | "/private/var"
419 | "/private/tmp"
420 | "/private/etc"
421 )
422 )
423 });
424 if !system_alias {
425 bail!(
426 "refusing symlink ancestor of output root {}",
427 ancestor.display()
428 );
429 }
430 }
431 }
432 if fs::symlink_metadata(root).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
433 bail!("refusing symlink output root {}", root.display());
434 }
435 let mut current = root.to_path_buf();
436 for part in relative.components() {
437 current.push(part.as_os_str());
438 if let Ok(metadata) = fs::symlink_metadata(¤t) {
439 if metadata.file_type().is_symlink() {
440 bail!(
441 "refusing generated output through symlink {}",
442 current.display()
443 );
444 }
445 }
446 }
447 Ok(())
448}
449
450fn merge_npm_manifest(existing: &str, generated: &str) -> Result<String> {
453 use serde_json::Value;
454 let mut existing: Value = serde_json::from_str(existing)?;
455 let mut generated: Value = serde_json::from_str(generated)?;
456 let user = existing
457 .as_object_mut()
458 .context("existing package.json must be an object")?;
459 let requirements = generated
460 .as_object_mut()
461 .context("generated package.json must be an object")?;
462 let sections = [
463 "dependencies",
464 "devDependencies",
465 "peerDependencies",
466 "optionalDependencies",
467 ];
468 for section in sections {
469 if let Some(value) = user.get(section) {
470 let entries = value
471 .as_object()
472 .with_context(|| format!("{section} must be an object"))?;
473 if entries.values().any(|value| !value.is_string()) {
474 bail!("{section} dependency versions must be strings");
475 }
476 }
477 }
478 for section in sections {
480 if let Some(entries) = requirements.get_mut(section).and_then(Value::as_object_mut) {
481 entries.retain(|name, _| {
482 !sections.iter().any(|section| {
483 user.get(*section)
484 .and_then(Value::as_object)
485 .is_some_and(|entries| entries.contains_key(name))
486 })
487 });
488 }
489 }
490 fn append(user: &mut Value, required: Value) {
491 match (user, required) {
492 (Value::Object(user), Value::Object(required)) => {
493 for (key, value) in required {
494 if let Some(existing) = user.get_mut(&key) {
495 append(existing, value);
496 } else {
497 user.insert(key, value);
498 }
499 }
500 }
501 (Value::Array(user), Value::Array(required)) => {
502 for value in required {
503 if !user.contains(&value) {
504 user.push(value);
505 }
506 }
507 }
508 _ => {}
509 }
510 }
511 for field in ["name", "version"] {
512 if let Some(value) = requirements.remove(field) {
513 user.insert(field.into(), value);
514 }
515 }
516 if let Some(exports) = requirements.remove("exports") {
517 if let Some(exports) = exports.as_object() {
518 let target = user
519 .entry("exports")
520 .or_insert_with(|| serde_json::json!({}));
521 let target = target
522 .as_object_mut()
523 .context("existing exports must be an object to add generated exports")?;
524 for (key, value) in exports {
525 target.insert(key.clone(), value.clone());
526 }
527 } else {
528 user.insert("exports".into(), exports);
529 }
530 }
531 append(&mut existing, generated);
532 Ok(format!("{}\n", serde_json::to_string_pretty(&existing)?))
533}
534
535#[cfg(test)]
536mod tests {
537 use std::fs;
538
539 use super::{GeneratedFile, GeneratedTree, OWNERSHIP_PATH};
540
541 #[test]
542 fn skipped_package_preserves_bytes_and_prior_ownership_without_adopting_edits() {
543 let root = tempfile::tempdir().unwrap();
544 let mut initial = GeneratedTree::default();
545 initial
546 .insert(GeneratedFile::new("skipped/model.ts", "original").unwrap())
547 .unwrap();
548 initial
549 .set_owner("skipped/model.ts", "original-owner")
550 .unwrap();
551 initial
552 .insert(GeneratedFile::new("active/model.ts", "old").unwrap())
553 .unwrap();
554 initial.write_to(root.path()).unwrap();
555 let previous: serde_json::Value =
556 serde_json::from_str(&fs::read_to_string(root.path().join(OWNERSHIP_PATH)).unwrap())
557 .unwrap();
558 fs::write(root.path().join("skipped/model.ts"), "local edit").unwrap();
559 let mut next = GeneratedTree::default();
560 next.insert(GeneratedFile::new("active/model.ts", "new").unwrap())
561 .unwrap();
562 next.preserve_owned_prefix(root.path(), "skipped").unwrap();
563 assert!(next.check(root.path()).unwrap().removed.is_empty());
564 next.write_to(root.path()).unwrap();
565 assert_eq!(
566 fs::read_to_string(root.path().join("skipped/model.ts")).unwrap(),
567 "local edit"
568 );
569 let current: serde_json::Value =
570 serde_json::from_str(&fs::read_to_string(root.path().join(OWNERSHIP_PATH)).unwrap())
571 .unwrap();
572 assert_eq!(
573 previous["files"]["skipped/model.ts"],
574 current["files"]["skipped/model.ts"]
575 );
576 assert!(next.preserve_owned_prefix(root.path(), "active").is_err());
577 assert!(
578 next.preserve_owned_prefix(root.path(), "../outside")
579 .is_err()
580 );
581 }
582
583 #[test]
584 fn ownership_checks_drift_removes_stale_files_and_preserves_custom() {
585 let root = tempfile::tempdir().unwrap();
586 let mut first = GeneratedTree::default();
587 first
588 .insert(GeneratedFile::new("old.ts", "old").unwrap())
589 .unwrap();
590 first
591 .insert_custom(GeneratedFile::new("custom.ts", "starter").unwrap())
592 .unwrap();
593 first.write_to(root.path()).unwrap();
594 fs::write(root.path().join("custom.ts"), "user").unwrap();
595 let mut next = GeneratedTree::default();
596 next.insert(GeneratedFile::new("new.ts", "new").unwrap())
597 .unwrap();
598 let changes = next.check(root.path()).unwrap();
599 assert_eq!(changes.added, vec![std::path::PathBuf::from("new.ts")]);
600 assert_eq!(changes.removed, vec![std::path::PathBuf::from("old.ts")]);
601 assert!(!root.path().join("new.ts").exists());
602 next.write_to(root.path()).unwrap();
603 assert!(!root.path().join("old.ts").exists());
604 assert_eq!(
605 fs::read_to_string(root.path().join("custom.ts")).unwrap(),
606 "user"
607 );
608 assert!(next.check(root.path()).unwrap().is_empty());
609 fs::write(root.path().join("new.ts"), "local edit").unwrap();
610 assert!(next.write_to(root.path()).is_err());
611 }
612
613 #[test]
614 fn check_refuses_unowned_collisions_and_malicious_manifest_without_writes() {
615 let root = tempfile::tempdir().unwrap();
616 fs::write(root.path().join("handwritten.ts"), "handwritten").unwrap();
617 let mut tree = GeneratedTree::default();
618 tree.insert(GeneratedFile::new("handwritten.ts", "generated").unwrap())
619 .unwrap();
620 assert!(tree.check(root.path()).is_err());
621 fs::create_dir(root.path().join(".poolster")).unwrap();
622 fs::write(
623 root.path().join(super::OWNERSHIP_PATH),
624 r#"{"version":1,"files":{"../outside":{"owner":"x","sha256":"x"}}}"#,
625 )
626 .unwrap();
627 assert!(tree.check(root.path()).is_err());
628 assert_eq!(
629 fs::read_to_string(root.path().join("handwritten.ts")).unwrap(),
630 "handwritten"
631 );
632 }
633
634 #[cfg(unix)]
635 #[test]
636 fn check_and_write_reject_symlinks_without_touching_targets() {
637 use std::os::unix::fs::symlink;
638 let root = tempfile::tempdir().unwrap();
639 let outside = tempfile::tempdir().unwrap();
640 symlink(outside.path(), root.path().join("linked")).unwrap();
641 let mut tree = GeneratedTree::default();
642 tree.insert(GeneratedFile::new("linked/a.ts", "generated").unwrap())
643 .unwrap();
644 assert!(tree.check(root.path()).is_err());
645 assert!(tree.write_to(root.path()).is_err());
646 assert!(!outside.path().join("a.ts").exists());
647 }
648
649 #[cfg(unix)]
650 #[test]
651 fn missing_root_under_symlink_ancestor_is_rejected() {
652 use std::os::unix::fs::symlink;
653 let root = tempfile::tempdir().unwrap();
654 let outside = tempfile::tempdir().unwrap();
655 symlink(outside.path(), root.path().join("linked")).unwrap();
656 let mut tree = GeneratedTree::default();
657 tree.insert(GeneratedFile::new("file.ts", "code").unwrap())
658 .unwrap();
659 let missing_root = root.path().join("linked/missing/output");
660 assert!(tree.check(&missing_root).is_err());
661 assert!(tree.write_to(&missing_root).is_err());
662 assert!(!outside.path().join("missing").exists());
663 }
664
665 #[test]
666 fn npm_manifest_preserves_custom_settings_and_dependency_categories() {
667 let existing = r#"{
668 "name":"old", "version":"0.0.1", "private":true,
669 "scripts":{"build":"custom-build", "test":"test-command"},
670 "devDependencies":{"typescript":"5.9.3"},
671 "peerDependencies":{"@tanstack/react-query":"^5.100.0"},
672 "peerDependenciesMeta":{"@tanstack/react-query":{"optional":true}},
673 "exports":{"./custom":"./custom.js", ".":"./old.js"},
674 "files":["custom.js"], "repository":{"url":"custom"}
675 }"#;
676 let generated = r#"{
677 "name":"@relevate/sdk", "version":"0.2.0", "type":"module",
678 "scripts":{"build":"tsc", "generate":"poolster generate"},
679 "devDependencies":{"typescript":"^7.0.0"},
680 "dependencies":{"@tanstack/react-query":"^5.0.0", "zod":"^4.0.0"},
681 "exports":{".":{"import":"./dist/index.js"}}, "files":["dist"]
682 }"#;
683 let merged = super::merge_npm_manifest(existing, generated).unwrap();
684 let value: serde_json::Value = serde_json::from_str(&merged).unwrap();
685 assert_eq!(value["name"], "@relevate/sdk");
686 assert_eq!(value["version"], "0.2.0");
687 assert_eq!(value["scripts"]["build"], "custom-build");
688 assert_eq!(value["scripts"]["generate"], "poolster generate");
689 assert_eq!(value["devDependencies"]["typescript"], "5.9.3");
690 assert!(value["dependencies"].get("@tanstack/react-query").is_none());
691 assert_eq!(
692 value["peerDependencies"]["@tanstack/react-query"],
693 "^5.100.0"
694 );
695 assert_eq!(value["dependencies"]["zod"], "^4.0.0");
696 assert_eq!(value["exports"]["./custom"], "./custom.js");
697 assert_eq!(value["exports"]["."]["import"], "./dist/index.js");
698 assert_eq!(value["files"], serde_json::json!(["custom.js", "dist"]));
699 assert_eq!(value["repository"]["url"], "custom");
700 assert_eq!(
701 super::merge_npm_manifest(&merged, generated).unwrap(),
702 merged
703 );
704 }
705
706 #[test]
707 fn fresh_npm_manifest_matches_repeat_bytes_and_preserves_user_additions() {
708 let root = tempfile::tempdir().unwrap();
709 let mut tree = GeneratedTree::default();
710 tree.insert(
711 GeneratedFile::new(
712 "ts/package.json",
713 r#"{
714 "name": "probe", "version": "1.0.0", "files": ["dist", "README.md"],
715 "scripts": {"build": "tsc"}, "dependencies": {"commander": "^13"}
716}"#,
717 )
718 .unwrap(),
719 )
720 .unwrap();
721 tree.write_to(root.path()).unwrap();
722 let path = root.path().join("ts/package.json");
723 let fresh = fs::read_to_string(&path).unwrap();
724 assert!(tree.check(root.path()).unwrap().is_empty());
725 tree.write_to(root.path()).unwrap();
726 assert_eq!(fs::read_to_string(&path).unwrap(), fresh);
727 let mut edited: serde_json::Value = serde_json::from_str(&fresh).unwrap();
728 edited["scripts"]["test"] = serde_json::json!("customer-test");
729 edited["dependencies"]["commander"] = serde_json::json!("^14");
730 edited["files"]
731 .as_array_mut()
732 .unwrap()
733 .push(serde_json::json!("custom.js"));
734 fs::write(&path, serde_json::to_string(&edited).unwrap()).unwrap();
735 tree.write_to(root.path()).unwrap();
736 let merged = fs::read_to_string(&path).unwrap();
737 let value: serde_json::Value = serde_json::from_str(&merged).unwrap();
738 assert_eq!(value["scripts"]["test"], "customer-test");
739 assert_eq!(value["dependencies"]["commander"], "^14");
740 assert!(
741 value["files"]
742 .as_array()
743 .unwrap()
744 .contains(&serde_json::json!("custom.js"))
745 );
746 tree.write_to(root.path()).unwrap();
747 assert_eq!(fs::read_to_string(&path).unwrap(), merged);
748 assert!(tree.check(root.path()).unwrap().is_empty());
749 }
750
751 #[test]
752 fn npm_manifest_is_merged_when_materialized() {
753 let output = tempfile::tempdir().unwrap();
754 fs::create_dir(output.path().join("ts")).unwrap();
755 fs::write(
756 output.path().join("ts/package.json"),
757 r#"{"scripts":{"test":"test"}}"#,
758 )
759 .unwrap();
760 let mut tree = GeneratedTree::default();
761 tree.insert(
762 GeneratedFile::new(
763 "ts/package.json",
764 r#"{"name":"@scope/sdk","scripts":{"build":"tsc"}}"#,
765 )
766 .unwrap(),
767 )
768 .unwrap();
769 tree.write_to(output.path()).unwrap();
770 let value: serde_json::Value = serde_json::from_str(
771 &fs::read_to_string(output.path().join("ts/package.json")).unwrap(),
772 )
773 .unwrap();
774 assert_eq!(value["scripts"]["test"], "test");
775 assert_eq!(value["scripts"]["build"], "tsc");
776 }
777
778 #[test]
779 fn invalid_npm_manifest_fails_before_writing_generated_files() {
780 for invalid in [
781 "invalid JSON",
782 "[]",
783 r#"{"dependencies":[]}"#,
784 r#"{"dependencies":{"react":42}}"#,
785 ] {
786 let output = tempfile::tempdir().unwrap();
787 fs::write(output.path().join("package.json"), invalid).unwrap();
788 let mut tree = GeneratedTree::default();
789 tree.insert(GeneratedFile::new("a.ts", "new code").unwrap())
790 .unwrap();
791 tree.insert(GeneratedFile::new("package.json", "{}").unwrap())
792 .unwrap();
793 assert!(tree.write_to(output.path()).is_err());
794 assert!(!output.path().join("a.ts").exists());
795 assert_eq!(
796 fs::read_to_string(output.path().join("package.json")).unwrap(),
797 invalid
798 );
799 }
800 }
801
802 #[test]
803 fn append_merges_isolated_trees_and_rejects_collisions() {
804 let mut left = GeneratedTree::default();
805 left.insert(GeneratedFile::new("rust/lib.rs", "left").unwrap())
806 .unwrap();
807 let mut right = GeneratedTree::default();
808 right
809 .insert(GeneratedFile::new("python/__init__.py", "right").unwrap())
810 .unwrap();
811 left.append(right).unwrap();
812 assert_eq!(left.get("python/__init__.py"), Some("right"));
813
814 let mut collision = GeneratedTree::default();
815 collision
816 .insert(GeneratedFile::new("rust/lib.rs", "other").unwrap())
817 .unwrap();
818 assert!(left.append(collision).is_err());
819 }
820
821 #[test]
822 fn custom_files_are_created_once_and_preserved_on_regeneration() {
823 let output = tempfile::tempdir().unwrap();
824 let path = "typescript/custom/index.ts";
825 let mut first = GeneratedTree::default();
826 first
827 .insert_custom(GeneratedFile::new(path, "export const first = true\n").unwrap())
828 .unwrap();
829 first.write_to(output.path()).unwrap();
830 fs::write(output.path().join(path), "export const userOwned = true\n").unwrap();
831
832 let mut regenerated = GeneratedTree::default();
833 regenerated
834 .insert_custom(GeneratedFile::new(path, "export const replacement = true\n").unwrap())
835 .unwrap();
836 regenerated.write_to(output.path()).unwrap();
837
838 assert_eq!(
839 fs::read_to_string(output.path().join(path)).unwrap(),
840 "export const userOwned = true\n"
841 );
842 }
843
844 #[test]
845 fn replace_updates_an_owned_file_without_creating_new_ownership() {
846 let mut tree = GeneratedTree::default();
847 tree.insert(GeneratedFile::new("typescript/package.json", "{}\n").unwrap())
848 .unwrap();
849 tree.replace(
850 GeneratedFile::new("typescript/package.json", "{\"name\":\"sdk\"}\n").unwrap(),
851 )
852 .unwrap();
853 assert_eq!(
854 tree.get("typescript/package.json"),
855 Some("{\"name\":\"sdk\"}\n")
856 );
857 assert!(
858 tree.replace(GeneratedFile::new("typescript/missing.json", "{}\n").unwrap())
859 .is_err()
860 );
861 }
862}