Skip to main content

ignition_core/client/
resources.rs

1//! Project-resource ZIP-member surgery (05-02) — the resource family
2//! re-pointed onto project-export zips, closing the Phase 3
3//! cross-phase defect: the `/projects/{p}/resources/**` REST routes
4//! the family originally targeted DO NOT EXIST on real 8.3 gateways
5//! (openapi-evidenced twice — 575 paths, zero matches — plus the EAM
6//! probe and the gateway-scripting API audit; 05-RESEARCH). The
7//! native steer's honest endpoint: export/import round-trip. These
8//! helpers are the surgery half.
9//!
10//! PURE functions — no [`crate::client::GatewayApi`] surface, no I/O
11//! beyond the `zip` crate itself — so every mapping is unit-testable
12//! without a gateway. The orchestration (export → surgery → import)
13//! lives in `actions::resources`.
14//!
15//! Zip layout of an 8.3 project export (05-RESEARCH, live-extracted):
16//! `project.json` at the root plus `<collection>/resources/<rest>`
17//! file members (collections are single-segment module ids —
18//! `com.inductiveautomation.perspective`, `ignition`, …). The
19//! user-facing path form — the Phase-3 UX-unchanged contract — is
20//! `<collection>/<rest>`: the `resources/` segment is stripped on the
21//! way OUT and re-inserted on the way IN. A no-slash user path (a
22//! project-root file, e.g. `perspective-properties.json`) rides a
23//! module named after the path itself: `<X>` ↔ `<X>/resources/<X>`
24//! (06-08, live-proven — the only adoptable shape for root-level
25//! files; see [`member_path`]). `project.json` is never a resource.
26//! Directory entries (when a writer emits them) are
27//! skipped on list and preserved verbatim on rewrite.
28//!
29//! [`ResourceEntry`] keeps the Phase-3 list shape (`path` typed,
30//! passthrough extras) so the CLI's rendering contract is untouched;
31//! surgery results carry no extras.
32
33use std::collections::BTreeMap;
34use std::io::{Read, Write};
35
36use serde::{Deserialize, Serialize};
37
38use crate::error::CoreError;
39
40/// One list item — the Phase-3 shape, unchanged: `path` typed (the
41/// human renderer prints one per line), unknown keys round-trip.
42/// Surgery-sourced entries carry no extras (the zip member list is
43/// the whole truth).
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct ResourceEntry {
46    /// The resource path, e.g. `"ignition/script-python/e2e/scratch"`.
47    #[serde(default)]
48    pub path: Option<String>,
49    /// `scope`, `version`, … — unknown keys round-trip (surgery
50    /// entries leave this empty).
51    #[serde(flatten)]
52    pub extra: BTreeMap<String, serde_json::Value>,
53}
54
55/// Map a user-facing resource path to its zip member path:
56/// `<collection>/<rest>` → `<collection>/resources/<rest>`; a
57/// no-slash path (a project-root file in user terms, e.g.
58/// `perspective-properties.json`) round-trips through a module named
59/// after the path itself — `<X>` → `<X>/resources/<X>`.
60///
61/// The root-level mapping is LIVE-PROVEN surgery shape (06-08, virgin
62/// 8.3.3 rig): the file lands inside its own module's resources
63/// container with the container descriptor naming it — the gateway
64/// imports it exit-0 and re-exports it at exactly that member path.
65/// The intuitive alternative — a file member at the zip root or one
66/// literally named `<X>/resources` — is dead wire: root files are
67/// silently not adopted (no parent descriptor can exist), and a file
68/// named `resources` collides with the module's reserved resources
69/// container (HTTP 500, "module folder must have folder flag set").
70/// `pub(crate)` for 13-05: the edit pipeline locates a member's
71/// decoded file inside the raw-name decode tree with the SAME
72/// mapping (single source — never a re-implementation).
73pub(crate) fn member_path(user_path: &str) -> String {
74    match user_path.split_once('/') {
75        Some((collection, rest)) => format!("{collection}/resources/{rest}"),
76        None => format!("{user_path}/resources/{user_path}"),
77    }
78}
79
80/// Map a zip member path back to the user-facing form — `None` for
81/// every member that is not `<collection>/resources/<rest>` with a
82/// nonempty rest (`project.json`, misplaced root files, directory
83/// entries). The root-level inverse of [`member_path`]: a member
84/// `<X>/resources/<X>` maps to the no-slash user path `<X>` (the
85/// rest equals the collection), so put/get/list/delete round-trip
86/// through one spelling. Note the deliberate alias: the explicit
87/// user path `<X>/<X>` forwards to the same member and reads back
88/// as `<X>` — one member, the no-slash spelling wins.
89fn user_path(member: &str) -> Option<String> {
90    let mut segments = member.split('/');
91    let collection = segments.next()?;
92    if collection.is_empty() || segments.next()? != "resources" {
93        return None;
94    }
95    let rest = segments.collect::<Vec<_>>().join("/");
96    if rest.is_empty() {
97        return None;
98    }
99    if rest == collection {
100        return Some(collection.to_string());
101    }
102    Some(format!("{collection}/{rest}"))
103}
104
105/// Open an export zip for reading — malformed bytes are a gateway
106/// contract violation (the export endpoint answered non-zip), exit 1.
107fn open_archive(zip_bytes: &[u8]) -> Result<zip::ZipArchive<std::io::Cursor<&[u8]>>, CoreError> {
108    zip::ZipArchive::new(std::io::Cursor::new(zip_bytes))
109        .map_err(|err| CoreError::Internal(format!("project export is not a readable zip: {err}")))
110}
111
112/// The deterministic options every rewritten member rides: deflate
113/// (both directions — Ignition exports and our imports), no
114/// timestamps (`SimpleFileOptions` defaults are fixed), so identical
115/// surgeries produce identical zips.
116fn rewrite_options() -> zip::write::SimpleFileOptions {
117    zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated)
118}
119
120/// The folder-descriptor filename every resource folder carries in a
121/// gateway-produced export (live-extracted 8.3.3, 05-07 spike): the
122/// `ign-cli` export's every route folder (`.../cli/tags/`, …) and a
123/// fresh project's `ignition/global-props/` alike carry a
124/// `resource.json` whose `files` array lists the folder's file
125/// members. LIVE-PROVEN LANDING RULE: an overwrite-import LANDS a NEW
126/// file member only when its immediate parent folder's descriptor
127/// exists and lists the basename — a bare appended file is silently
128/// ignored (the import still answers `{"success":true}` while nothing
129/// lands; verified with AND without zip directory entries).
130/// Intermediate plain folders above the resource folder carry
131/// nothing (the webdev `cli/` precedent). `pub(crate)` for 13-02:
132/// the workspace `MemberSource::Tree` hashes members under the SAME
133/// descriptor rule without forking the constant.
134pub(crate) const FOLDER_DESCRIPTOR: &str = "resource.json";
135
136/// The parent directory of a member path (`a/b/c` → `a/b`); `None`
137/// for a root-level name.
138fn parent_of(member: &str) -> Option<&str> {
139    member.rsplit_once('/').map(|(parent, _)| parent)
140}
141
142/// Merge one basename into an EXISTING parent descriptor's `files`
143/// array (idempotent): parse, append when absent, re-serialize
144/// pretty. An unparseable descriptor is an export-contract violation
145/// — refusing beats recreating the exact bug this plan closes
146/// (`ok:true` while nothing lands).
147fn merge_descriptor_member(existing: &[u8], basename: &str) -> Result<Vec<u8>, CoreError> {
148    let mut value: serde_json::Value = serde_json::from_slice(existing).map_err(|err| {
149        CoreError::Internal(format!(
150            "parent resource descriptor is not valid JSON: {err}"
151        ))
152    })?;
153    let object = value.as_object_mut().ok_or_else(|| {
154        CoreError::Internal("parent resource descriptor is not a JSON object".to_string())
155    })?;
156    let files = object
157        .entry("files")
158        .or_insert_with(|| serde_json::Value::Array(Vec::new()));
159    if !files.is_array() {
160        *files = serde_json::Value::Array(Vec::new());
161    }
162    let listed = files
163        .as_array()
164        .expect("just normalized to an array")
165        .iter()
166        .any(|name| name.as_str() == Some(basename));
167    if !listed {
168        files
169            .as_array_mut()
170            .expect("just normalized to an array")
171            .push(serde_json::Value::String(basename.to_string()));
172    }
173    serde_json::to_vec_pretty(&value).map_err(|err| {
174        CoreError::Internal(format!(
175            "cannot serialize merged resource descriptor: {err}"
176        ))
177    })
178}
179
180/// Synthesize a NEW parent-folder descriptor in the live-proven
181/// shape (the 05-07 variant-D wire answer): scope G, version 1,
182/// unrestricted, overridable, `files` listing exactly the appended
183/// basename, empty attributes.
184fn synthesized_descriptor(basename: &str) -> Vec<u8> {
185    serde_json::to_vec_pretty(&serde_json::json!({
186        "scope": "G",
187        "version": 1,
188        "restricted": false,
189        "overridable": true,
190        "files": [basename],
191        "attributes": {},
192    }))
193    .expect("the descriptor shape always serializes")
194}
195
196/// THE list primitive: user-facing paths of every resource member in
197/// the export zip, in member order. `project.json`, directory
198/// entries, and non-`resources`-shaped members are skipped.
199pub fn resource_members(zip_bytes: &[u8]) -> Result<Vec<String>, CoreError> {
200    let mut archive = open_archive(zip_bytes)?;
201    let mut members = Vec::new();
202    for index in 0..archive.len() {
203        let file = archive
204            .by_index(index)
205            .map_err(|err| CoreError::Internal(format!("cannot walk project export zip: {err}")))?;
206        if file.is_dir() {
207            continue;
208        }
209        if let Some(user) = user_path(file.name()) {
210            members.push(user);
211        }
212    }
213    Ok(members)
214}
215
216/// THE read primitive: one member's bytes, verbatim. A missing
217/// member is the existing not-found error shape (exit 6) — the REST
218/// family's 404 semantics carried over the surgery transport.
219pub fn read_member(zip_bytes: &[u8], member: &str) -> Result<Vec<u8>, CoreError> {
220    let target = member_path(member);
221    let mut archive = open_archive(zip_bytes)?;
222    let mut file = archive.by_name(&target).map_err(|err| match err {
223        zip::result::ZipError::FileNotFound => CoreError::NotFound { endpoint: None },
224        err => CoreError::Internal(format!("cannot read zip member {target:?}: {err}")),
225    })?;
226    let mut bytes = Vec::new();
227    file.read_to_end(&mut bytes).map_err(|err| {
228        CoreError::Internal(format!("cannot decompress zip member {target:?}: {err}"))
229    })?;
230    Ok(bytes)
231}
232
233/// What [`rewrite_zip`] does to one target member.
234enum Surgery<'a> {
235    /// Replace the member's content — or APPEND it when absent (put
236    /// can create new resources).
237    Replace(&'a [u8]),
238    /// Drop the member — absent is an error the caller raises.
239    Remove,
240}
241
242/// The put-new descriptor action resolved before the copy loop
243/// (05-07): append-when-absent needs the parent folder's descriptor
244/// to list the new basename.
245enum DescriptorSurgery {
246    /// The archive already carries the parent descriptor — merge the
247    /// basename into its `files` (edited in place, position kept —
248    /// the live-proven variant-E ordering).
249    Merge(String),
250    /// No parent descriptor exists — synthesize one just before the
251    /// appended member (the live-proven variant-D ordering:
252    /// descriptor before file).
253    Synthesize(String),
254}
255
256/// Full-zip rewrite: copy every member (decompressed → recompressed,
257/// deflate, original order, directory entries preserved), applying
258/// the surgery to the target. Returns the new zip plus whether the
259/// target was seen (remove's not-found proof; replace appends when
260/// unseen).
261///
262/// Put-new (05-07): when a Replace target is ABSENT, the append also
263/// lands the parent-folder descriptor — merged when the archive
264/// already carries one, synthesized otherwise. A target that IS a
265/// descriptor (basename `resource.json`) authors it explicitly and
266/// gets no second one. [`Surgery::Remove`] never touches descriptors:
267/// the gateway reconciles a stale `files` list itself (live-proven —
268/// the deleted file's descriptor comes back with the entry pruned).
269fn rewrite_zip(
270    zip_bytes: &[u8],
271    target: &str,
272    surgery: Surgery<'_>,
273) -> Result<(Vec<u8>, bool), CoreError> {
274    let mut archive = open_archive(zip_bytes)?;
275    let names: Vec<String> = archive.file_names().map(str::to_string).collect();
276    let target_present = names.iter().any(|name| name == target);
277
278    // Resolve the descriptor surgery BEFORE copying (the merge must
279    // edit the descriptor member as it streams past).
280    let descriptor_surgery = match (&surgery, target_present) {
281        (Surgery::Replace(_), false)
282            if target
283                .rsplit('/')
284                .next()
285                .is_some_and(|base| base != FOLDER_DESCRIPTOR) =>
286        {
287            parent_of(target).map(|parent| {
288                let descriptor_path = format!("{parent}/{FOLDER_DESCRIPTOR}");
289                if names.iter().any(|name| name == &descriptor_path) {
290                    DescriptorSurgery::Merge(descriptor_path)
291                } else {
292                    DescriptorSurgery::Synthesize(descriptor_path)
293                }
294            })
295        }
296        _ => None,
297    };
298
299    let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
300    let options = rewrite_options();
301    let mut seen = false;
302
303    for index in 0..archive.len() {
304        let mut file = archive
305            .by_index(index)
306            .map_err(|err| CoreError::Internal(format!("cannot walk project export zip: {err}")))?;
307        let name = file.name().to_string();
308        let is_target = name == target;
309        seen |= is_target;
310        if is_target && matches!(surgery, Surgery::Remove) {
311            continue; // dropped — the rest of the zip carries on
312        }
313        if file.is_dir() {
314            writer
315                .add_directory(name, options)
316                .map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
317            continue;
318        }
319        let mut bytes = Vec::new();
320        file.read_to_end(&mut bytes).map_err(|err| {
321            CoreError::Internal(format!("cannot decompress zip member {name:?}: {err}"))
322        })?;
323        writer
324            .start_file(name.clone(), options)
325            .map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
326        let content = match (is_target, &surgery) {
327            (true, Surgery::Replace(content)) => *content,
328            (_, Surgery::Replace(_)) if matches!(&descriptor_surgery, Some(DescriptorSurgery::Merge(path)) if path == &name) =>
329            {
330                // The parent descriptor rides MERGED: the new basename
331                // joins its files list (idempotent), everything else
332                // about it kept verbatim.
333                &merge_descriptor_member(
334                    &bytes,
335                    target.rsplit('/').next().expect("non-root — checked above"),
336                )?
337            }
338            _ => &bytes,
339        };
340        writer
341            .write_all(content)
342            .map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
343    }
344
345    // Replace-appends-when-absent: the put-upsert semantics (a new
346    // resource joins the zip at the end, member order otherwise
347    // preserved) — the parent descriptor lands FIRST (the
348    // live-proven ordering: descriptor before file).
349    if !seen && let Surgery::Replace(content) = surgery {
350        if let Some(DescriptorSurgery::Synthesize(descriptor_path)) = &descriptor_surgery {
351            let basename = target.rsplit('/').next().expect("non-root — checked above");
352            let descriptor = synthesized_descriptor(basename);
353            writer
354                .start_file(descriptor_path.clone(), options)
355                .map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
356            writer
357                .write_all(&descriptor)
358                .map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
359        }
360        writer
361            .start_file(target, options)
362            .map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
363        writer
364            .write_all(content)
365            .map_err(|err| CoreError::Internal(format!("cannot rewrite zip: {err}")))?;
366    }
367
368    let cursor = writer
369        .finish()
370        .map_err(|err| CoreError::Internal(format!("cannot finalize rewritten zip: {err}")))?;
371    Ok((cursor.into_inner(), seen))
372}
373
374/// THE put primitive: replace the member's content — or append it
375/// when absent (upsert: created if missing). Every other member,
376/// their order, and directory entries ride across untouched.
377pub fn replace_member(
378    zip_bytes: &[u8],
379    member: &str,
380    content: &[u8],
381) -> Result<Vec<u8>, CoreError> {
382    let (zip, _) = rewrite_zip(zip_bytes, &member_path(member), Surgery::Replace(content))?;
383    Ok(zip)
384}
385
386/// THE delete primitive: the zip minus the member. A missing member
387/// is the existing not-found error shape (exit 6).
388pub fn remove_member(zip_bytes: &[u8], member: &str) -> Result<Vec<u8>, CoreError> {
389    let (zip, seen) = rewrite_zip(zip_bytes, &member_path(member), Surgery::Remove)?;
390    if !seen {
391        return Err(CoreError::NotFound { endpoint: None });
392    }
393    Ok(zip)
394}
395
396// ---- Pure diff engine (07-01, SYNC-01) -----------------------------------
397//
398// Cross-gateway project diff, member-level with resource.json
399// NORMALIZATION — the live-evidenced volatility guard (07-RESEARCH
400// Pitfall 1): every gateway-written descriptor carries
401// `attributes.lastModification` (+`…Signature`), so a byte-compare
402// flags identical content exported from two gateways as CHANGED.
403// Normalization strips exactly those two attribute fields, keeps
404// everything semantic (`scope`/`version`/`files`, the REST of
405// `attributes`), and re-serializes into a canonical form this module
406// OWNS — see [`normalize_descriptor`]. All pure, zero new
407// dependencies, unit-testable without a gateway (the 05-02 pattern).
408
409/// 64-bit FNV-1a — the member digest. No sha2 dependency: collision
410/// risk (2^-64 per pair on 64-bit hashes) is acceptable for diff UX;
411/// this is change detection, not security. `pub(crate)` for 13-02:
412/// the workspace `MemberSource::Tree` digests checked-out files with
413/// the SAME digest function — hash semantics stay single-source.
414pub(crate) fn fnv1a(bytes: &[u8]) -> u64 {
415    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
416    for &byte in bytes {
417        hash ^= u64::from(byte);
418        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
419    }
420    hash
421}
422
423/// Rebuild a JSON value with every object's entries sorted by key —
424/// the canonicalizer [`normalize_descriptor`] owns. serde_json's
425/// default map IS sorted (BTreeMap) today, but a workspace-wide
426/// `preserve_order` feature flip would make it insertion-ordered:
427/// sorting explicitly means canonical output never depends on the
428/// ambient map behavior (the cross-version guard — pinned by the
429/// key-order-independence unit test).
430fn canonicalize(value: serde_json::Value) -> serde_json::Value {
431    match value {
432        serde_json::Value::Object(map) => {
433            let mut entries: Vec<(String, serde_json::Value)> = map
434                .into_iter()
435                .map(|(key, value)| (key, canonicalize(value)))
436                .collect();
437            entries.sort_by(|a, b| a.0.cmp(&b.0));
438            serde_json::Value::Object(entries.into_iter().collect::<serde_json::Map<_, _>>())
439        }
440        serde_json::Value::Array(items) => {
441            serde_json::Value::Array(items.into_iter().map(canonicalize).collect())
442        }
443        other => other,
444    }
445}
446
447/// Normalize one `resource.json` descriptor for comparison: parse,
448/// strip the two live-evidenced volatility fields
449/// (`attributes.lastModification` and
450/// `attributes.lastModificationSignature` — keep every other
451/// attribute and all semantic keys), recursively sort object keys,
452/// re-serialize compact. `None` for non-JSON or a non-object root —
453/// the caller hashes the raw bytes instead (the descriptor is exotic
454/// or corrupt; content honesty over a false equality).
455pub fn normalize_descriptor(json: &[u8]) -> Option<Vec<u8>> {
456    let mut value: serde_json::Value = serde_json::from_slice(json).ok()?;
457    if !value.is_object() {
458        return None; // non-object roots hash raw (the caller's rule)
459    }
460    if let Some(attributes) = value
461        .as_object_mut()
462        .and_then(|object| object.get_mut("attributes"))
463        .and_then(|attributes| attributes.as_object_mut())
464    {
465        attributes.remove("lastModification");
466        attributes.remove("lastModificationSignature");
467    }
468    serde_json::to_vec(&canonicalize(value)).ok()
469}
470
471/// User path → FNV-1a digest for every resource member in the export
472/// zip. Members whose basename is `resource.json` hash their
473/// NORMALIZED form ([`normalize_descriptor`]); everything else hashes
474/// raw bytes. `project.json`, directory entries, and
475/// non-`resources`-shaped members carry no user path and are skipped
476/// (the same walk [`resource_members`] rides) — which is exactly how
477/// [`diff_members`] excludes the root project.json from resource
478/// entries.
479pub fn member_hashes(zip_bytes: &[u8]) -> Result<BTreeMap<String, u64>, CoreError> {
480    let mut archive = open_archive(zip_bytes)?;
481    let mut hashes = BTreeMap::new();
482    for index in 0..archive.len() {
483        let mut file = archive
484            .by_index(index)
485            .map_err(|err| CoreError::Internal(format!("cannot walk project export zip: {err}")))?;
486        if file.is_dir() {
487            continue;
488        }
489        let name = file.name().to_string();
490        let Some(user) = user_path(&name) else {
491            continue;
492        };
493        let mut bytes = Vec::new();
494        file.read_to_end(&mut bytes).map_err(|err| {
495            CoreError::Internal(format!("cannot decompress zip member {name:?}: {err}"))
496        })?;
497        let content = if name.rsplit('/').next() == Some(FOLDER_DESCRIPTOR) {
498            normalize_descriptor(&bytes).unwrap_or(bytes)
499        } else {
500            bytes
501        };
502        hashes.insert(user, fnv1a(&content));
503    }
504    Ok(hashes)
505}
506
507/// One member's diff status — B-relative-to-A semantics (the LOCKED
508/// direction): `added` = in B only, `removed` = in A only.
509#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
510#[serde(rename_all = "lowercase")]
511pub enum MemberStatus {
512    /// Present in B, absent in A.
513    Added,
514    /// Present in A, absent in B.
515    Removed,
516    /// Present in both, normalized hashes differ.
517    Changed,
518    /// Present in both, normalized hashes equal.
519    Same,
520}
521
522/// One row of the diff: the user path + its status.
523#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
524pub struct MemberDiffEntry {
525    /// The user-facing resource path.
526    pub path: String,
527    /// B-relative-to-A status.
528    pub status: MemberStatus,
529}
530
531/// The four counts — the summary line and the JSON `summary` object.
532#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
533pub struct DiffSummary {
534    /// Members equal (after normalization) in both.
535    pub same: usize,
536    /// Members only in B.
537    pub added: usize,
538    /// Members only in A.
539    pub removed: usize,
540    /// Members differing (after normalization) between A and B.
541    pub changed: usize,
542}
543
544/// The member-level diff result: counts + one entry per resource
545/// member, sorted by path. The root `project.json` is EXCLUDED (it is
546/// not a resource; [`project_meta_delta`] surfaces it separately).
547#[derive(Debug, Clone, PartialEq, Serialize)]
548pub struct MemberDiff {
549    /// The four counts.
550    pub summary: DiffSummary,
551    /// Every resource member with its status, path-sorted.
552    pub entries: Vec<MemberDiffEntry>,
553}
554
555/// THE compare primitive: B-relative-to-A member statuses over two
556/// export zips — `added` = path in B not A, `removed` = in A not B,
557/// `changed` = both with differing normalized hashes, `same` = both
558/// with equal ones. Entries ride path-sorted (the BTreeMap union
559/// iterates sorted). The root `project.json` never appears (the
560/// [`member_hashes`] walk skips it).
561pub fn diff_members(zip_a: &[u8], zip_b: &[u8]) -> Result<MemberDiff, CoreError> {
562    let hashes_a = member_hashes(zip_a)?;
563    let hashes_b = member_hashes(zip_b)?;
564    let paths: std::collections::BTreeSet<&String> =
565        hashes_a.keys().chain(hashes_b.keys()).collect();
566    let mut summary = DiffSummary::default();
567    let mut entries = Vec::with_capacity(paths.len());
568    for path in paths {
569        let status = match (hashes_a.get(path), hashes_b.get(path)) {
570            (Some(hash_a), Some(hash_b)) => {
571                if hash_a == hash_b {
572                    MemberStatus::Same
573                } else {
574                    MemberStatus::Changed
575                }
576            }
577            (None, Some(_)) => MemberStatus::Added,
578            (Some(_), None) => MemberStatus::Removed,
579            (None, None) => unreachable!("the union only carries present keys"),
580        };
581        match status {
582            MemberStatus::Same => summary.same += 1,
583            MemberStatus::Added => summary.added += 1,
584            MemberStatus::Removed => summary.removed += 1,
585            MemberStatus::Changed => summary.changed += 1,
586        }
587        entries.push(MemberDiffEntry {
588            path: (*path).clone(),
589            status,
590        });
591    }
592    Ok(MemberDiff { summary, entries })
593}
594
595/// The root `project.json` member, parsed — `Ok(None)` when the member
596/// is absent; a parse failure is ALSO `Ok(None)` (the caller treats a
597/// missing/unparseable project.json as "no meta to compare" — the
598/// diff is about resources).
599fn root_project_json(zip_bytes: &[u8]) -> Result<Option<serde_json::Value>, CoreError> {
600    let mut archive = open_archive(zip_bytes)?;
601    let Ok(mut file) = archive.by_name("project.json") else {
602        return Ok(None);
603    };
604    let mut bytes = Vec::new();
605    file.read_to_end(&mut bytes).map_err(|err| {
606        CoreError::Internal(format!(
607            "cannot decompress zip member \"project.json\": {err}"
608        ))
609    })?;
610    Ok(serde_json::from_slice(&bytes).ok())
611}
612
613/// A missing/null JSON value rendered as text for the delta triples.
614fn value_text(value: &serde_json::Value) -> String {
615    match value {
616        serde_json::Value::Null => "null".to_string(),
617        serde_json::Value::String(text) => text.clone(),
618        other => other.to_string(),
619    }
620}
621
622/// Compare the root `project.json`'s SEMANTIC fields — `title`,
623/// `enabled`, `parent` only — returning one `(field, a_value,
624/// b_value)` triple per differing field (stringified values; absent
625/// renders as `null`). Missing member or parse failure → empty vec:
626/// the diff is about resources, project meta rides separately.
627pub fn project_meta_delta(
628    zip_a: &[u8],
629    zip_b: &[u8],
630) -> Result<Vec<(String, String, String)>, CoreError> {
631    let a = root_project_json(zip_a)?.unwrap_or(serde_json::Value::Null);
632    let b = root_project_json(zip_b)?.unwrap_or(serde_json::Value::Null);
633    let mut deltas = Vec::new();
634    for field in ["title", "enabled", "parent"] {
635        let (value_a, value_b) = (&a[field], &b[field]);
636        if value_a != value_b {
637            deltas.push((field.to_string(), value_text(value_a), value_text(value_b)));
638        }
639    }
640    Ok(deltas)
641}
642
643#[cfg(test)]
644mod tests {
645    use std::io::Write as _;
646
647    use super::{ResourceEntry, read_member, remove_member, replace_member, resource_members};
648    use crate::error::CoreError;
649
650    /// Build a small in-test export zip: `project.json` + one member
651    /// per `(name, bytes)` pair, in order (the zip crate writer is
652    /// the same engine the surgery rides, so fixtures are honest).
653    fn fixture_zip(members: &[(&str, &[u8])]) -> Vec<u8> {
654        let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
655        let options = zip::write::SimpleFileOptions::default()
656            .compression_method(zip::CompressionMethod::Deflated);
657        writer
658            .start_file("project.json", options)
659            .expect("project.json starts");
660        writer
661            .write_all(br#"{"title":"T"}"#)
662            .expect("project.json writes");
663        for (name, bytes) in members {
664            writer.start_file(*name, options).expect("member starts");
665            writer.write_all(bytes).expect("member writes");
666        }
667        writer.finish().expect("zip finalizes").into_inner()
668    }
669
670    /// The two real-world members the round-trip units ride: a core
671    /// script and a Perspective view file.
672    const SCRIPT_MEMBER: &str = "ignition/resources/script-python/e2e/scratch";
673    const VIEW_MEMBER: &str = "com.example/resources/views/Dashboard/view.json";
674
675    fn sample_zip() -> Vec<u8> {
676        fixture_zip(&[
677            (
678                SCRIPT_MEMBER,
679                br#"{"scope":"G","code":"print('old')"}"#.as_slice(),
680            ),
681            (VIEW_MEMBER, br#"{"scope":"A"}"#.as_slice()),
682        ])
683    }
684
685    /// THE mapping pin: list strips the `resources/` segment
686    /// (user-facing form, UX-unchanged), skips `project.json`, and
687    /// preserves member order.
688    #[test]
689    fn resource_members_maps_user_paths_and_skips_project_json() {
690        let zip = sample_zip();
691        assert_eq!(
692            resource_members(&zip).expect("list parses"),
693            vec![
694                "ignition/script-python/e2e/scratch".to_string(),
695                "com.example/views/Dashboard/view.json".to_string(),
696            ],
697            "resources/ stripped, project.json skipped, order preserved"
698        );
699
700        // A zip with ONLY project.json lists empty (a fresh project).
701        assert!(
702            resource_members(&fixture_zip(&[]))
703                .expect("bare zip parses")
704                .is_empty()
705        );
706    }
707
708    /// Read returns the member bytes verbatim (user path in).
709    #[test]
710    fn read_member_returns_verbatim_bytes() {
711        let zip = sample_zip();
712        assert_eq!(
713            read_member(&zip, "ignition/script-python/e2e/scratch").expect("member reads"),
714            br#"{"scope":"G","code":"print('old')"}"#.to_vec()
715        );
716        assert_eq!(
717            read_member(&zip, "com.example/views/Dashboard/view.json").expect("member reads"),
718            br#"{"scope":"A"}"#.to_vec()
719        );
720    }
721
722    /// Missing member → the existing not-found shape (exit 6) — for
723    /// both a full miss and the no-slash root-level form (whose
724    /// member can exist only after a root-level put); a non-zip
725    /// input is an internal error (the export contract was violated).
726    #[test]
727    fn missing_member_and_garbage_zip_error_shapes() {
728        let zip = sample_zip();
729        let err =
730            read_member(&zip, "ignition/script-python/nope").expect_err("missing member must fail");
731        assert!(
732            matches!(err, CoreError::NotFound { endpoint: None }),
733            "wrong class: {err}"
734        );
735        assert_eq!(err.exit_code(), 6);
736        assert_eq!(err.code(), "not_found");
737
738        let root_level =
739            read_member(&zip, "ignition").expect_err("absent root-level member must fail");
740        assert!(
741            matches!(root_level, CoreError::NotFound { endpoint: None }),
742            "the root-level member form is absent until a put creates it: {root_level}"
743        );
744
745        let garbage = resource_members(b"not a zip at all").expect_err("garbage must fail");
746        assert!(
747            matches!(garbage, CoreError::Internal(_)),
748            "garbage: {garbage}"
749        );
750        assert_eq!(garbage.exit_code(), 1);
751    }
752
753    /// THE replace pin: content swapped, every other member intact,
754    /// member order preserved — and the result is itself a valid zip
755    /// the helpers can walk again (the surgery round-trip).
756    #[test]
757    fn replace_member_swaps_content_preserving_everything_else() {
758        let zip = sample_zip();
759        let new_body = br#"{"scope":"G","code":"print('new')"}"#.as_slice();
760        let out = replace_member(&zip, "ignition/script-python/e2e/scratch", new_body)
761            .expect("replace rewrites");
762        assert_eq!(
763            read_member(&out, "ignition/script-python/e2e/scratch").expect("re-read"),
764            new_body.to_vec(),
765            "the target carries the new content"
766        );
767        assert_eq!(
768            read_member(&out, "com.example/views/Dashboard/view.json").expect("re-read"),
769            br#"{"scope":"A"}"#.to_vec(),
770            "the neighbor is untouched"
771        );
772        assert_eq!(
773            resource_members(&out).expect("re-list"),
774            vec![
775                "ignition/script-python/e2e/scratch".to_string(),
776                "com.example/views/Dashboard/view.json".to_string(),
777            ],
778            "order and membership preserved"
779        );
780    }
781
782    /// THE upsert pin (05-07 re-pinned): replacing an ABSENT member
783    /// appends it (put can create new resources) AND lands the
784    /// parent-folder `resource.json` descriptor the live-proven
785    /// landing rule requires — the descriptor rides FIRST (the
786    /// gateway-accepted ordering), existing members keep their order.
787    #[test]
788    fn replace_member_appends_when_absent() {
789        let zip = sample_zip();
790        let out = replace_member(&zip, "ignition/script-python/e2e/brand-new", b"print('x')")
791            .expect("append rewrites");
792        assert_eq!(
793            read_member(&out, "ignition/script-python/e2e/brand-new").expect("appended reads"),
794            b"print('x')".to_vec()
795        );
796        // THE landing shape: the parent folder now carries a
797        // descriptor listing the new basename.
798        let descriptor =
799            read_member(&out, "ignition/script-python/e2e/resource.json").expect("descriptor");
800        let parsed: serde_json::Value = serde_json::from_slice(&descriptor).expect("json");
801        assert_eq!(parsed["files"], serde_json::json!(["brand-new"]));
802        assert_eq!(parsed["scope"], serde_json::json!("G"));
803        // Member order: originals first, then the synthesized
804        // descriptor, then the appended member.
805        assert_eq!(
806            resource_members(&out).expect("re-list"),
807            vec![
808                "ignition/script-python/e2e/scratch".to_string(),
809                "com.example/views/Dashboard/view.json".to_string(),
810                "ignition/script-python/e2e/resource.json".to_string(),
811                "ignition/script-python/e2e/brand-new".to_string(),
812            ],
813            "the descriptor rides LAST-but-one; the originals keep their order"
814        );
815    }
816
817    /// THE merge pin (05-07, variant-E wire truth): appending into a
818    /// folder that ALREADY carries a descriptor merges the basename
819    /// into its `files` (idempotently — an already-listed name does
820    /// not duplicate), every other descriptor key kept verbatim, the
821    /// descriptor's member position preserved.
822    #[test]
823    fn replace_member_appends_merging_existing_descriptor() {
824        let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
825        let options = zip::write::SimpleFileOptions::default()
826            .compression_method(zip::CompressionMethod::Deflated);
827        writer.start_file("project.json", options).expect("starts");
828        writer.write_all(br#"{"title":"T"}"#).expect("writes");
829        writer
830            .start_file(
831                "ignition/resources/script-python/uat/resource.json",
832                options,
833            )
834            .expect("starts");
835        writer
836            .write_all(br#"{"scope":"G","version":1,"restricted":false,"overridable":true,"files":["hello2.py"],"attributes":{"keep":"me"}}"#)
837            .expect("writes");
838        writer
839            .start_file("ignition/resources/script-python/uat/hello2.py", options)
840            .expect("starts");
841        writer.write_all(b"print('old')").expect("writes");
842        let zip = writer.finish().expect("finalize").into_inner();
843
844        // Append a sibling into the SAME resource folder.
845        let out = replace_member(
846            &zip,
847            "ignition/script-python/uat/hello3.py",
848            b"print('new')'",
849        )
850        .expect("append rewrites");
851        let descriptor: serde_json::Value = serde_json::from_slice(
852            &read_member(&out, "ignition/script-python/uat/resource.json").expect("descriptor"),
853        )
854        .expect("descriptor json");
855        assert_eq!(
856            descriptor["files"],
857            serde_json::json!(["hello2.py", "hello3.py"]),
858            "the new basename joins the files list"
859        );
860        assert_eq!(
861            descriptor["attributes"]["keep"],
862            serde_json::json!("me"),
863            "unknown descriptor keys ride verbatim"
864        );
865        assert_eq!(
866            read_member(&out, "ignition/script-python/uat/hello3.py").expect("appended reads"),
867            b"print('new')'".to_vec()
868        );
869
870        // Idempotent re-merge: replacing the SAME absent-member path
871        // again does not duplicate the files entry (and a SECOND new
872        // sibling appends after the first).
873        let again = replace_member(&out, "ignition/script-python/uat/hello4.py", b"x")
874            .expect("second append");
875        let descriptor: serde_json::Value = serde_json::from_slice(
876            &read_member(&again, "ignition/script-python/uat/resource.json").expect("d"),
877        )
878        .expect("json");
879        assert_eq!(
880            descriptor["files"],
881            serde_json::json!(["hello2.py", "hello3.py", "hello4.py"])
882        );
883    }
884
885    /// An appended member whose basename IS the descriptor authors it
886    /// explicitly — NO second (parent-of-parent) descriptor is
887    /// synthesized, and the member rides at exactly its path.
888    #[test]
889    fn replace_member_appending_a_descriptor_authors_it_explicitly() {
890        let zip = sample_zip();
891        let descriptor_body = br#"{"scope":"A","version":1,"files":["view.json"]}"#;
892        let out = replace_member(
893            &zip,
894            "com.example/views/Dashboard/resource.json",
895            descriptor_body,
896        )
897        .expect("append rewrites");
898        assert_eq!(
899            read_member(&out, "com.example/views/Dashboard/resource.json").expect("reads"),
900            descriptor_body.to_vec(),
901            "the authored descriptor rides verbatim"
902        );
903        // No descriptor-of-the-descriptor: the parent folder gained
904        // nothing but the member itself.
905        assert!(
906            read_member(&out, "com.example/views/resource.json").is_err(),
907            "no second descriptor is synthesized for an authored descriptor member"
908        );
909    }
910
911    /// An unparseable EXISTING parent descriptor on the append path
912    /// refuses (internal) rather than shipping an import the gateway
913    /// would silently ignore — the exact bug class 05-07 closes.
914    #[test]
915    fn replace_member_append_over_corrupt_descriptor_refuses() {
916        let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
917        let options = zip::write::SimpleFileOptions::default();
918        writer.start_file("project.json", options).expect("starts");
919        writer.write_all(br#"{"title":"T"}"#).expect("writes");
920        writer
921            .start_file(
922                "ignition/resources/script-python/uat/resource.json",
923                options,
924            )
925            .expect("starts");
926        writer.write_all(b"<<<not json>>>").expect("writes");
927        let zip = writer.finish().expect("finalize").into_inner();
928
929        let err = replace_member(&zip, "ignition/script-python/uat/new.py", b"x")
930            .expect_err("corrupt descriptor must refuse the append");
931        assert!(matches!(err, CoreError::Internal(_)), "{err}");
932        assert_eq!(err.exit_code(), 1);
933    }
934
935    /// THE delete pin (05-07, live-proven): remove leaves the parent
936    /// descriptor UNTOUCHED — the gateway itself reconciles the stale
937    /// `files` entry (the wire truth from the variant-G2 probe).
938    #[test]
939    fn remove_member_leaves_descriptor_to_gateway_reconciliation() {
940        let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
941        let options = zip::write::SimpleFileOptions::default();
942        writer.start_file("project.json", options).expect("starts");
943        writer.write_all(br#"{"title":"T"}"#).expect("writes");
944        writer
945            .start_file(
946                "ignition/resources/script-python/uat/resource.json",
947                options,
948            )
949            .expect("starts");
950        writer
951            .write_all(br#"{"scope":"G","version":1,"files":["scratch.py"],"attributes":{}}"#)
952            .expect("writes");
953        writer
954            .start_file("ignition/resources/script-python/uat/scratch.py", options)
955            .expect("starts");
956        writer.write_all(b"print('x')").expect("writes");
957        let zip = writer.finish().expect("finalize").into_inner();
958
959        let out =
960            remove_member(&zip, "ignition/script-python/uat/scratch.py").expect("remove rewrites");
961        assert_eq!(
962            read_member(&out, "ignition/script-python/uat/resource.json").expect("descriptor"),
963            br#"{"scope":"G","version":1,"files":["scratch.py"],"attributes":{}}"#.to_vec(),
964            "the descriptor rides verbatim — the gateway prunes the stale entry"
965        );
966    }
967
968    /// THE remove pin: the member is gone (a follow-up read is
969    /// not-found), neighbors survive; removing a missing member is
970    /// the not-found error.
971    #[test]
972    fn remove_member_drops_exactly_the_target() {
973        let zip = sample_zip();
974        let out =
975            remove_member(&zip, "com.example/views/Dashboard/view.json").expect("remove rewrites");
976        let err = read_member(&out, "com.example/views/Dashboard/view.json")
977            .expect_err("removed member must be gone");
978        assert!(matches!(err, CoreError::NotFound { .. }), "gone: {err}");
979        assert_eq!(
980            resource_members(&out).expect("re-list"),
981            vec!["ignition/script-python/e2e/scratch".to_string()]
982        );
983
984        let missing = remove_member(&zip, "com.example/views/Never").expect_err("must fail");
985        assert!(matches!(missing, CoreError::NotFound { .. }), "{missing}");
986        assert_eq!(missing.exit_code(), 6);
987    }
988
989    /// Surgery preserves directory entries across rewrites (a writer
990    /// that emits them gets them back — position kept). The member
991    /// `ignition/resources/a` is addressed by its USER path
992    /// (`ignition/a` — the `resources/` segment is the mapping's).
993    #[test]
994    fn rewrite_preserves_directory_entries() {
995        let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
996        let options = zip::write::SimpleFileOptions::default();
997        writer
998            .add_directory("ignition/resources/", options)
999            .expect("dir starts");
1000        writer
1001            .start_file("ignition/resources/a", options)
1002            .expect("file starts");
1003        writer.write_all(b"a").expect("file writes");
1004        let zip = writer.finish().expect("finalize").into_inner();
1005
1006        let out = remove_member(&zip, "ignition/a").expect("remove");
1007        // The directory entry survives as an entry (len counts it) and
1008        // does NOT appear as a resource.
1009        let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&out)).expect("re-open");
1010        assert_eq!(archive.len(), 1, "the directory entry survived the rewrite");
1011        assert!(archive.by_index(0).expect("entry").is_dir());
1012        assert!(resource_members(&out).expect("list").is_empty());
1013    }
1014
1015    /// The Phase-3 list item shape survives the re-point verbatim
1016    /// (surgery entries carry no extras; the passthrough contract
1017    /// stays for wire-sourced shapes).
1018    #[test]
1019    fn resource_entry_shape_unchanged() {
1020        let entry: ResourceEntry = serde_json::from_value(serde_json::json!({
1021            "path": "ignition/script-python/e2e/scratch",
1022            "scope": "G"
1023        }))
1024        .expect("plausible item must parse");
1025        assert_eq!(
1026            entry.path.as_deref(),
1027            Some("ignition/script-python/e2e/scratch")
1028        );
1029        assert_eq!(entry.extra.get("scope"), Some(&serde_json::json!("G")));
1030
1031        let bare: ResourceEntry =
1032            serde_json::from_value(serde_json::json!({"path": "x"})).expect("extras-free parses");
1033        assert!(bare.extra.is_empty());
1034    }
1035
1036    // ---- Pure diff engine units (07-01) ----
1037
1038    use super::{
1039        DiffSummary, MemberDiff, MemberDiffEntry, MemberStatus, diff_members, member_hashes,
1040        normalize_descriptor, project_meta_delta,
1041    };
1042
1043    /// The diff-side fixture builder: a custom `project.json` (the
1044    /// meta-delta tests need differing titles) + one member per pair.
1045    fn diff_zip(project_json: &[u8], members: &[(&str, &[u8])]) -> Vec<u8> {
1046        let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
1047        let options = zip::write::SimpleFileOptions::default()
1048            .compression_method(zip::CompressionMethod::Deflated);
1049        writer
1050            .start_file("project.json", options)
1051            .expect("project.json starts");
1052        writer.write_all(project_json).expect("project.json writes");
1053        for (name, bytes) in members {
1054            writer.start_file(*name, options).expect("member starts");
1055            writer.write_all(bytes).expect("member writes");
1056        }
1057        writer.finish().expect("zip finalizes").into_inner()
1058    }
1059
1060    /// A live-shaped folder descriptor: scope/version/files plus the
1061    /// two volatility attributes every gateway-written resource.json
1062    /// carries (07-RESEARCH Pitfall 1).
1063    fn descriptor(plain_body: &str, modified_a: &str) -> Vec<u8> {
1064        format!(
1065            r#"{{"scope":"G","version":1,"files":["script.py"],"attributes":{{"lastModification":{{"actor":"admin","timestamp":"{modified_a}","signature":"sig-{modified_a}"}},"lastModificationSignature":"sig-{modified_a}","notes":"{plain_body}"}}}}"#
1066        )
1067        .into_bytes()
1068    }
1069
1070    const DESC_MEMBER: &str = "ignition/resources/script-python/uat/resource.json";
1071    const SCRIPT_MEMBER_2: &str = "ignition/resources/script-python/uat/script.py";
1072    const VIEW_MEMBER_2: &str = "com.example/resources/views/Dash/view.json";
1073
1074    /// (a) THE volatility pin: identical descriptor CONTENT with
1075    /// DIFFERING `lastModification` attributes reports `same` — the
1076    /// normalized compare, not a byte compare.
1077    #[test]
1078    fn diff_same_content_differing_modification_attributes_is_same() {
1079        let a = diff_zip(
1080            br#"{"title":"T","enabled":true}"#,
1081            &[(
1082                DESC_MEMBER,
1083                descriptor("kept", "2026-08-28T10:00:00Z").as_slice(),
1084            )],
1085        );
1086        let b = diff_zip(
1087            br#"{"title":"T","enabled":true}"#,
1088            &[(
1089                DESC_MEMBER,
1090                descriptor("kept", "2026-08-28T11:30:00Z").as_slice(),
1091            )],
1092        );
1093        let diff = diff_members(&a, &b).expect("diff parses");
1094        assert_eq!(
1095            diff,
1096            MemberDiff {
1097                summary: DiffSummary {
1098                    same: 1,
1099                    added: 0,
1100                    removed: 0,
1101                    changed: 0
1102                },
1103                entries: vec![MemberDiffEntry {
1104                    path: "ignition/script-python/uat/resource.json".to_string(),
1105                    status: MemberStatus::Same,
1106                }],
1107            }
1108        );
1109    }
1110
1111    /// (b)+(c)+(d) THE direction pin: a member only in B is `added`,
1112    /// one only in A is `removed`, differing script BYTES are
1113    /// `changed` — B-relative-to-A, entries path-sorted.
1114    #[test]
1115    fn diff_direction_semantics_added_removed_changed() {
1116        let a = diff_zip(
1117            br#"{"title":"T"}"#,
1118            &[
1119                (SCRIPT_MEMBER_2, b"print('old')"),
1120                (VIEW_MEMBER_2, br#"{"scope":"A"}"#.as_slice()),
1121            ],
1122        );
1123        let b = diff_zip(
1124            br#"{"title":"T"}"#,
1125            &[
1126                (SCRIPT_MEMBER_2, b"print('new')"),
1127                (
1128                    "com.example/resources/views/Fresh/view.json",
1129                    br#"{"scope":"G"}"#.as_slice(),
1130                ),
1131            ],
1132        );
1133        let diff = diff_members(&a, &b).expect("diff parses");
1134        assert_eq!(
1135            diff.entries,
1136            vec![
1137                MemberDiffEntry {
1138                    path: "com.example/views/Dash/view.json".to_string(),
1139                    status: MemberStatus::Removed,
1140                },
1141                MemberDiffEntry {
1142                    path: "com.example/views/Fresh/view.json".to_string(),
1143                    status: MemberStatus::Added,
1144                },
1145                MemberDiffEntry {
1146                    path: "ignition/script-python/uat/script.py".to_string(),
1147                    status: MemberStatus::Changed,
1148                },
1149            ],
1150            "B-relative-to-A, path-sorted"
1151        );
1152        assert_eq!(
1153            diff.summary,
1154            DiffSummary {
1155                same: 0,
1156                added: 1,
1157                removed: 1,
1158                changed: 1
1159            }
1160        );
1161    }
1162
1163    /// (e) Empty-vs-populated: everything in A reports `removed`
1164    /// (and symmetrically everything in B would be `added`).
1165    #[test]
1166    fn diff_empty_vs_populated_is_all_removed() {
1167        let a = diff_zip(br#"{"title":"T"}"#, &[(SCRIPT_MEMBER_2, b"print('old')")]);
1168        let b = diff_zip(br#"{"title":"T"}"#, &[]);
1169        let diff = diff_members(&a, &b).expect("diff parses");
1170        assert_eq!(diff.summary.removed, 1);
1171        assert_eq!(
1172            diff.summary.added + diff.summary.changed + diff.summary.same,
1173            0
1174        );
1175        assert_eq!(diff.entries[0].status, MemberStatus::Removed);
1176
1177        // The mirror: populated-vs-empty is all `added`.
1178        let mirror = diff_members(&b, &a).expect("diff parses");
1179        assert_eq!(mirror.summary.added, 1);
1180        assert_eq!(mirror.entries[0].status, MemberStatus::Added);
1181    }
1182
1183    /// (f) THE exclusion pin: a differing `project.json` title rides
1184    /// the META delta as one triple and NEVER appears in the member
1185    /// entries — `diff_members` excludes the root project.json (it is
1186    /// not a resource).
1187    #[test]
1188    fn diff_excludes_root_project_json_and_surfaces_meta_delta() {
1189        let a = diff_zip(
1190            br#"{"title":"Old","enabled":true}"#,
1191            &[(SCRIPT_MEMBER_2, b"x")],
1192        );
1193        let b = diff_zip(
1194            br#"{"title":"New","enabled":true}"#,
1195            &[(SCRIPT_MEMBER_2, b"x")],
1196        );
1197        let diff = diff_members(&a, &b).expect("diff parses");
1198        assert_eq!(diff.summary.same, 1, "the only resource member is same");
1199        assert!(
1200            !diff
1201                .entries
1202                .iter()
1203                .any(|entry| entry.path == "project.json"),
1204            "the root project.json is never a resource entry"
1205        );
1206        assert_eq!(
1207            project_meta_delta(&a, &b).expect("meta delta parses"),
1208            vec![("title".to_string(), "Old".to_string(), "New".to_string())],
1209            "the title difference rides the meta delta exactly"
1210        );
1211
1212        // Semantic fields beyond the trio never ride: a differing
1213        // description is NOT a delta (scope discipline).
1214        let c = diff_zip(
1215            br#"{"title":"New","enabled":true,"description":"differs"}"#,
1216            &[(SCRIPT_MEMBER_2, b"x")],
1217        );
1218        assert!(
1219            project_meta_delta(&b, &c)
1220                .expect("meta delta parses")
1221                .is_empty(),
1222            "only title/enabled/parent compare"
1223        );
1224    }
1225
1226    /// (g) THE key-order-independence pin (the cross-version
1227    /// canonicalization guard): the SAME logical descriptor
1228    /// serialized with two DIFFERENT key orders — top-level AND
1229    /// nested-object keys shuffled — normalizes to byte-identical
1230    /// output, so the members hash equal and report `same`. Canonical
1231    /// output must never depend on input key order or serde_json's
1232    /// ambient map behavior (a future `preserve_order` flip).
1233    #[test]
1234    fn normalize_descriptor_is_key_order_independent() {
1235        let order_a = br#"{"scope":"G","version":1,"files":["a.py","b.py"],"attributes":{"lastModification":{"actor":"x","timestamp":"t"},"keep":{"z":1,"a":2}}}"#;
1236        let order_b = br#"{"attributes":{"keep":{"a":2,"z":1},"lastModification":{"timestamp":"t","actor":"x"}},"files":["a.py","b.py"],"version":1,"scope":"G"}"#;
1237        let normalized_a = normalize_descriptor(order_a).expect("a normalizes");
1238        let normalized_b = normalize_descriptor(order_b).expect("b normalizes");
1239        assert_eq!(
1240            normalized_a, normalized_b,
1241            "canonical output depends only on content, never key order"
1242        );
1243        // …and the member-level consequence: the two zips diff `same`.
1244        let zip_a = diff_zip(br#"{"title":"T"}"#, &[(DESC_MEMBER, &normalized_a)]);
1245        let zip_b = diff_zip(br#"{"title":"T"}"#, &[(DESC_MEMBER, &normalized_b)]);
1246        let diff = diff_members(&zip_a, &zip_b).expect("diff parses");
1247        assert_eq!(diff.summary.same, 1);
1248        assert_eq!(diff.summary.changed, 0);
1249
1250        // The strip is EXACTLY the two volatility fields — other
1251        // attribute content differing still means `changed`.
1252        let keep_a = br#"{"scope":"G","attributes":{"notes":"one"}}"#;
1253        let keep_b = br#"{"scope":"G","attributes":{"notes":"two"}}"#;
1254        assert_ne!(
1255            normalize_descriptor(keep_a).expect("normalizes"),
1256            normalize_descriptor(keep_b).expect("normalizes"),
1257            "non-volatility attribute differences survive normalization"
1258        );
1259    }
1260
1261    /// Non-JSON / non-object descriptors return `None` — the caller
1262    /// hashes the RAW bytes (exotic descriptors compare bytewise,
1263    /// honest over a false equality).
1264    #[test]
1265    fn normalize_descriptor_refuses_non_json_and_non_object() {
1266        assert!(normalize_descriptor(b"print('not json')").is_none());
1267        assert!(normalize_descriptor(br#"[1,2,3]"#).is_none());
1268        assert!(normalize_descriptor(b"").is_none());
1269        // A plain object without attributes normalizes fine (the
1270        // strip is conditional).
1271        assert!(normalize_descriptor(br#"{"scope":"G"}"#).is_some());
1272    }
1273
1274    /// `member_hashes` maps USER paths (project.json skipped,
1275    /// directory entries skipped) and normalizes descriptor members —
1276    /// the map's key set is exactly `resource_members`' list.
1277    #[test]
1278    fn member_hashes_key_set_matches_resource_members() {
1279        let a = diff_zip(
1280            br#"{"title":"T"}"#,
1281            &[
1282                (DESC_MEMBER, descriptor("x", "t1").as_slice()),
1283                (SCRIPT_MEMBER_2, b"print('x')"),
1284                (VIEW_MEMBER_2, br#"{"scope":"A"}"#.as_slice()),
1285            ],
1286        );
1287        let hashes = member_hashes(&a).expect("hashes parse");
1288        assert_eq!(
1289            hashes.keys().map(String::as_str).collect::<Vec<_>>(),
1290            vec![
1291                "com.example/views/Dash/view.json",
1292                "ignition/script-python/uat/resource.json",
1293                "ignition/script-python/uat/script.py",
1294            ],
1295            "user paths, project.json skipped, path-sorted"
1296        );
1297        assert!(hashes.values().all(|hash| *hash != 0));
1298    }
1299
1300    /// The serialized agent shapes: statuses render as lowercase
1301    /// strings and the summary carries all four keys always.
1302    #[test]
1303    fn diff_shapes_serialize_stably() {
1304        let entry = MemberDiffEntry {
1305            path: "x/y".to_string(),
1306            status: MemberStatus::Added,
1307        };
1308        assert_eq!(
1309            serde_json::to_value(&entry).expect("serializes"),
1310            serde_json::json!({"path": "x/y", "status": "added"})
1311        );
1312        let summary = serde_json::to_value(DiffSummary {
1313            same: 1,
1314            added: 2,
1315            removed: 3,
1316            changed: 4,
1317        })
1318        .expect("serializes");
1319        assert_eq!(
1320            summary,
1321            serde_json::json!({"same": 1, "added": 2, "removed": 3, "changed": 4})
1322        );
1323        for (status, word) in [
1324            (MemberStatus::Added, "added"),
1325            (MemberStatus::Removed, "removed"),
1326            (MemberStatus::Changed, "changed"),
1327            (MemberStatus::Same, "same"),
1328        ] {
1329            assert_eq!(
1330                serde_json::to_value(status).expect("serializes"),
1331                serde_json::Value::String(word.to_string())
1332            );
1333        }
1334    }
1335}