Skip to main content

poolster_core/
files.rs

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    /// Adds a starter file that is generated only on first materialization.
61    ///
62    /// This is the escape hatch for package-owned customization modules: the
63    /// generator supplies a valid initial file, then later generation passes
64    /// keep a developer's edits intact instead of overwriting them.
65    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    /// Replaces the contents of a file already owned by this generated tree.
75    ///
76    /// Composition layers can use this for a deliberate finalization pass,
77    /// such as extending a generated package manifest. It never creates a new
78    /// file, so accidental ownership conflicts remain visible through `insert`.
79    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    /// Moves file contents and create-once metadata without copying the entire
105    /// generated SDK between composition layers.
106    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    /// Assign a persistent package/plugin identity, never a process-local ID.
126    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    /// Retain prior ownership and bytes for a skipped package during mixed generation.
156    /// Files under this prefix are neither written nor removed, including local edits.
157    /// The prior hash is retained so skipping does not silently adopt those edits.
158    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    /// Compare the complete generated output without modifying its destination.
184    pub fn check(&self, root: impl AsRef<Path>) -> Result<OutputChanges> {
185        Ok(self.plan(root.as_ref(), false)?.0)
186    }
187
188    /// Validate all changes before writing. Only previously owned, unchanged
189    /// files may be removed; create-once files remain user-owned.
190    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    // Reject links even when they point inside the root; never follow them while
402    // checking, creating directories, reading, writing, or removing output.
403    // macOS exposes temporary storage through /var and /tmp aliases. Permit
404    // those system aliases, but reject user-controlled links above a missing root.
405    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(&current) {
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
450/// User-owned values win, while missing generated requirements are appended.
451/// Package identity/version and generated export destinations remain authoritative.
452fn 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    // Respect the user's chosen range and dependency category, including optional peers.
479    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}