Skip to main content

ignition_core/client/
scripts_codec.rs

1//! The Ignition Flint script codec (07-04, INTR-01) — decode/encode
2//! of the scripts EMBEDDED inside JSON resource members (Perspective
3//! `view.json` component scripts, tag event scripts, …) into editable
4//! `.py` sidecars, with a byte-exact unedited round-trip.
5//!
6//! PURE — the `client/resources.rs` discipline: zero
7//! [`crate::client::GatewayApi`] surface, unit-testable without a
8//! gateway. `ignition/script-python` members are ALREADY plain `.py`
9//! text in the export (live-proven, 07-RESEARCH) and never decode —
10//! this module targets the escaped string values under
11//! [`SCRIPT_KEYS`] only (scope honesty, README "Script decode/
12//! encode").
13//!
14//! ## The codec (ignition-nvim's exact contract, dual-ported there)
15//!
16//! - [`flint_encode`] — the ordered multi-pass replacement table,
17//!   BACKSLASH FIRST.
18//! - [`flint_decode`] — a SINGLE-PASS state machine (multi-pass
19//!   cannot distinguish `\\t` from `\t`); unknown `\uXXXX` escapes
20//!   keep the backslash.
21//! - The invariant `flint_encode(flint_decode(x)) == x` is SACRED
22//!   (over strings in the table's image — what Ignition writes).
23//! - [`dedent`]/[`reindent`] strip/restore the common leading-TAB
24//!   prefix (only non-empty lines reindent; whitespace-only lines
25//!   normalize to empty — scripts with such lines re-encode with
26//!   that one normalization, an accepted ignition-nvim semantic).
27//!
28//! ## Addressing = counter-named sidecars + JSON-pointer manifest
29//!
30//! `--decode-scripts` writes the export's members PLUS
31//! `<member>.<n>.py` sidecar siblings PLUS a `scripts-manifest.json`
32//! at the tree root mapping each member's JSON-pointer addresses →
33//! `{sidecar, indent_prefix}`. The exported JSON stays MARKER-FREE
34//! (gateway-clean — the manifest-aside beats markers, 07-RESEARCH
35//! anti-patterns).
36//!
37//! ## Round-trip = raw byte-span splicing (NO preserve_order)
38//!
39//! [`decode_member`]/[`encode_member`] walk the member's RAW bytes
40//! with ONE shared position-tracking scanner: decode resolves each
41//! script string to its byte span, encode RE-RESOLVES each manifest
42//! pointer in the CURRENT bytes (hand-edits stay valid) and splices
43//! the re-encoded replacement at that span. serde_json's
44//! `preserve_order` feature is deliberately NOT enabled — feature
45//! unification is workspace-wide and would flip every
46//! `serde_json::Value` map to insertion order, churning key order in
47//! the existing Value-re-serializing goldens (`tags export -o -`,
48//! doctor/webdev passthroughs). serde_json is used READ-ONLY here
49//! (manifest parse/serialize; parse-to-Value walks are
50//! order-agnostic); NO code path re-serializes a member `Value`.
51//! Acceptance = byte-equality of UNEDITED re-encoded members (the
52//! sacred invariant at file level).
53
54use std::collections::{BTreeMap, BTreeSet, HashMap};
55use std::io::{Read, Write};
56use std::path::{Path, PathBuf};
57
58use serde::{Deserialize, Serialize};
59
60use crate::error::CoreError;
61
62/// The JSON keys whose string values carry embedded scripts — the
63/// ignition-nvim list verbatim (nine keys; the plan sketch said ten,
64/// the dual-ported source is the authority); kept in sync by comment
65/// reference (lua/ignition/json_parser.lua SCRIPT_KEYS /
66/// ignition_lsp/json_scanner.py SCRIPT_KEYS).
67pub const SCRIPT_KEYS: [&str; 9] = [
68    "script",
69    "code",
70    "eventScript",
71    "transform",
72    "onActionPerformed",
73    "onChange",
74    "onStartup",
75    "onShutdown",
76    "expression",
77];
78
79/// The manifest file a decoded export tree carries at its root —
80/// consumed + stripped on re-encode (it never enters an uploaded zip).
81pub const MANIFEST_NAME: &str = "scripts-manifest.json";
82
83// ---- The codec -------------------------------------------------------------
84
85/// Encode plain text into the Ignition Flint JSON-string form — the
86/// EXACT ordered multi-pass table (backslash FIRST so later passes
87/// cannot double-escape), cross-validated Lua + Python in
88/// ignition-nvim.
89pub fn flint_encode(s: &str) -> String {
90    // Backslash first (must be!), then the remaining pairs in the
91    // table's order. Each later pass's pattern contains no backslash,
92    // so order after the first is stable.
93    let out = s.replace('\\', "\\\\");
94    let out = out.replace('"', "\\\"");
95    let out = out.replace('\t', "\\t");
96    let out = out.replace('\u{8}', "\\b");
97    let out = out.replace('\n', "\\n");
98    let out = out.replace('\r', "\\r");
99    let out = out.replace('\u{c}', "\\f");
100    let out = out.replace('<', "\\u003c");
101    let out = out.replace('>', "\\u003e");
102    let out = out.replace('&', "\\u0026");
103    let out = out.replace('=', "\\u003d");
104    out.replace('\'', "\\u0027")
105}
106
107/// The single-char escapes the decoder maps (everything after a
108/// backslash except `u`).
109fn escape_char(next: char) -> Option<char> {
110    match next {
111        '\\' => Some('\\'),
112        '"' => Some('"'),
113        't' => Some('\t'),
114        'b' => Some('\u{8}'),
115        'n' => Some('\n'),
116        'r' => Some('\r'),
117        'f' => Some('\u{c}'),
118        _ => None,
119    }
120}
121
122/// The `\uXXXX` escapes the decoder maps (the Flint table's HTML
123/// five; anything else keeps its backslash).
124fn unicode_escape(hex: &str) -> Option<char> {
125    match hex {
126        "003c" => Some('<'),
127        "003e" => Some('>'),
128        "0026" => Some('&'),
129        "003d" => Some('='),
130        "0027" => Some('\''),
131        _ => None,
132    }
133}
134
135/// Decode the Ignition Flint JSON-string form back to plain text —
136/// SINGLE-PASS (multi-pass cannot distinguish `\\t` — literal
137/// backslash + t — from `\t` — a tab). Unknown `\uXXXX` and unknown
138/// single escapes KEEP the backslash (the ignition-nvim semantics:
139/// the escape sequence rides through verbatim for the re-encode).
140pub fn flint_decode(s: &str) -> String {
141    let chars: Vec<char> = s.chars().collect();
142    let mut out = String::with_capacity(s.len());
143    let mut i = 0usize;
144    while i < chars.len() {
145        let c = chars[i];
146        if c != '\\' {
147            out.push(c);
148            i += 1;
149            continue;
150        }
151        // A backslash with nothing after it rides verbatim.
152        let Some(next) = chars.get(i + 1).copied() else {
153            out.push(c);
154            i += 1;
155            continue;
156        };
157        if next == 'u' {
158            if i + 5 < chars.len() {
159                let hex: String = chars[i + 2..=i + 5].iter().collect();
160                if let Some(decoded) = unicode_escape(&hex) {
161                    out.push(decoded);
162                    i += 6;
163                    continue;
164                }
165            }
166            // Unknown/truncated unicode escape: keep the backslash
167            // (the rest re-scans as plain chars).
168            out.push('\\');
169            i += 1;
170        } else if let Some(decoded) = escape_char(next) {
171            out.push(decoded);
172            i += 2;
173        } else {
174            // Unknown escape: keep the backslash.
175            out.push('\\');
176            i += 1;
177        }
178    }
179    out
180}
181
182/// Strip the common leading-TAB prefix Ignition stores scripts with —
183/// the ignition-nvim semantics verbatim: the minimum leading-tab
184/// count over non-empty lines decides the prefix; stray spaces in
185/// the leading whitespace strip alongside the tabs. Returns
186/// `(dedented_text, indent_prefix)` so [`reindent`] can restore it.
187pub fn dedent(text: &str) -> (String, String) {
188    if text.is_empty() {
189        return (String::new(), String::new());
190    }
191    let lines: Vec<&str> = text.split('\n').collect();
192    // The minimum leading-TAB count across non-empty lines (tabs at
193    // any position in the leading whitespace count — mixed stray
194    // spaces are the ignition-nvim tolerance).
195    let mut min_tabs: Option<usize> = None;
196    for line in &lines {
197        if line.trim().is_empty() {
198            continue;
199        }
200        let leading = line.len() - line.trim_start().len();
201        let tab_count = line[..leading].matches('\t').count();
202        min_tabs = Some(match min_tabs {
203            None => tab_count,
204            Some(min) => min.min(tab_count),
205        });
206    }
207    let min_tabs = match min_tabs {
208        None | Some(0) => return (text.to_string(), String::new()),
209        Some(tabs) => tabs,
210    };
211    let stripped: Vec<String> = lines
212        .iter()
213        .map(|line| {
214            if line.trim().is_empty() {
215                return String::new();
216            }
217            let mut rest: &str = line;
218            let mut tabs_removed = 0usize;
219            while tabs_removed < min_tabs && !rest.is_empty() {
220                if let Some(stripped) = rest.strip_prefix('\t') {
221                    rest = stripped;
222                    tabs_removed += 1;
223                } else if let Some(stripped) = rest.strip_prefix(' ') {
224                    rest = stripped; // stray spaces remove alongside
225                } else {
226                    break;
227                }
228            }
229            rest.to_string()
230        })
231        .collect();
232    (stripped.join("\n"), "\t".repeat(min_tabs))
233}
234
235/// Restore the prefix [`dedent`] stripped — ONLY non-empty lines
236/// reindent (the ignition-nvim semantics verbatim).
237pub fn reindent(text: &str, prefix: &str) -> String {
238    if prefix.is_empty() {
239        return text.to_string();
240    }
241    text.split('\n')
242        .map(|line| {
243            if line.trim().is_empty() {
244                String::new()
245            } else {
246                format!("{prefix}{line}")
247            }
248        })
249        .collect::<Vec<_>>()
250        .join("\n")
251}
252
253/// The decode heuristic (07-RESEARCH Pitfall 5): a SCRIPT_KEYS value
254/// decodes only when it LOOKS like a script — the raw text carries a
255/// script-ish escape marker AND the decoded text is multi-line.
256/// Single-line Ignition expressions (even ones carrying `\u003c`-class
257/// escapes) pass through untouched.
258fn looks_like_script(raw_inner: &str) -> bool {
259    let has_marker = raw_inner.contains("\\n")
260        || raw_inner.contains("\\t")
261        || raw_inner.contains("\\\"")
262        || raw_inner.contains("\\u00");
263    has_marker && flint_decode(raw_inner).contains('\n')
264}
265
266// ---- The shared position-tracking scanner ----------------------------------
267
268/// A byte span of one JSON string value's INNER content (the quotes
269/// excluded) inside the raw member bytes.
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271struct Span {
272    start: usize,
273    end: usize,
274}
275
276/// One script-candidate the walk found: the JSON-pointer address of
277/// the string VALUE (ending in its script-key token) and the
278/// inner-content span.
279#[derive(Debug, Clone)]
280struct Found {
281    pointer: String,
282    span: Span,
283}
284
285/// JSON-pointer token escaping: `~` → `~0`, `/` → `~1`.
286fn escape_pointer_token(token: &str) -> String {
287    token.replace('~', "~0").replace('/', "~1")
288}
289
290/// Standard JSON string unescape for OBJECT KEYS (pointer building
291/// needs the decoded key text; keys are ASCII in these members but
292/// correctness beats assumption).
293fn json_unescape(raw: &str) -> Result<String, CoreError> {
294    let mut out = String::with_capacity(raw.len());
295    let mut chars = raw.chars();
296    while let Some(c) = chars.next() {
297        if c != '\\' {
298            out.push(c);
299            continue;
300        }
301        match chars.next() {
302            Some('"') => out.push('"'),
303            Some('\\') => out.push('\\'),
304            Some('/') => out.push('/'),
305            Some('b') => out.push('\u{8}'),
306            Some('f') => out.push('\u{c}'),
307            Some('n') => out.push('\n'),
308            Some('r') => out.push('\r'),
309            Some('t') => out.push('\t'),
310            Some('u') => {
311                let hex: String = chars.by_ref().take(4).collect();
312                let code = u32::from_str_radix(&hex, 16).map_err(|_| {
313                    CoreError::Internal(format!("bad \\u{hex} escape in a JSON key"))
314                })?;
315                // Lone surrogates are not representable in Rust
316                // strings — replace (keys, display-only context).
317                out.push(char::from_u32(code).unwrap_or(char::REPLACEMENT_CHARACTER));
318            }
319            other => {
320                return Err(CoreError::Internal(format!(
321                    "bad escape in a JSON key: {other:?}"
322                )));
323            }
324        }
325    }
326    Ok(out)
327}
328
329/// THE shared scanner: a recursive-descent walk over the member's
330/// RAW bytes recording the span of every string value whose key is in
331/// [`SCRIPT_KEYS`], at any nesting depth (scripts nest arbitrarily
332/// deep in view JSON — 07-RESEARCH "Don't Hand-Roll"). No
333/// `serde_json::Value` materialization anywhere on this path: spans
334/// address the raw bytes the splice writes back into.
335struct Scanner<'a> {
336    bytes: &'a [u8],
337    pos: usize,
338}
339
340impl<'a> Scanner<'a> {
341    fn err(&self, why: &str) -> CoreError {
342        CoreError::Internal(format!(
343            "member JSON scan failed at byte {}: {why}",
344            self.pos
345        ))
346    }
347
348    fn skip_ws(&mut self) {
349        while matches!(self.bytes.get(self.pos), Some(b' ' | b'\t' | b'\n' | b'\r')) {
350            self.pos += 1;
351        }
352    }
353
354    /// Scan a JSON string (self.pos at the opening quote); returns
355    /// the inner-content span, pos lands just past the closing quote.
356    fn string(&mut self) -> Result<Span, CoreError> {
357        self.pos += 1; // opening quote (caller checked)
358        let start = self.pos;
359        loop {
360            match self.bytes.get(self.pos) {
361                None => return Err(self.err("unterminated string")),
362                Some(b'"') => {
363                    let end = self.pos;
364                    self.pos += 1;
365                    return Ok(Span { start, end });
366                }
367                // An escape pair skips verbatim (the inner content is
368                // opaque to the walk — the codec owns its meaning).
369                Some(b'\\') => self.pos += 2,
370                Some(_) => self.pos += 1,
371            }
372        }
373    }
374
375    /// Scan one literal token (true/false/null).
376    fn literal(&mut self, token: &str) -> Result<(), CoreError> {
377        if self.bytes[self.pos..].starts_with(token.as_bytes()) {
378            self.pos += token.len();
379            Ok(())
380        } else {
381            Err(self.err("unexpected token"))
382        }
383    }
384
385    /// Scan a number token.
386    fn number(&mut self) -> Result<(), CoreError> {
387        while matches!(
388            self.bytes.get(self.pos),
389            Some(b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9')
390        ) {
391            self.pos += 1;
392        }
393        Ok(())
394    }
395
396    /// Walk one value under `pointer`; a string whose key was a
397    /// SCRIPT_KEY records its span into `found`.
398    fn value(
399        &mut self,
400        pointer: &str,
401        script_key: Option<&'static str>,
402        found: &mut Vec<Found>,
403    ) -> Result<(), CoreError> {
404        self.skip_ws();
405        match self.bytes.get(self.pos) {
406            Some(b'{') => self.object(pointer, found),
407            Some(b'[') => self.array(pointer, found),
408            Some(b'"') => {
409                let span = self.string()?;
410                if script_key.is_some()
411                    && std::str::from_utf8(&self.bytes[span.start..span.end]).is_ok()
412                {
413                    found.push(Found {
414                        pointer: pointer.to_string(),
415                        span,
416                    });
417                }
418                Ok(())
419            }
420            Some(b't') => self.literal("true"),
421            Some(b'f') => self.literal("false"),
422            Some(b'n') => self.literal("null"),
423            Some(b'-' | b'0'..=b'9') => self.number(),
424            _ => Err(self.err("unexpected byte for a value")),
425        }
426    }
427
428    fn object(&mut self, pointer: &str, found: &mut Vec<Found>) -> Result<(), CoreError> {
429        self.pos += 1; // '{'
430        self.skip_ws();
431        if self.bytes.get(self.pos) == Some(&b'}') {
432            self.pos += 1;
433            return Ok(());
434        }
435        loop {
436            self.skip_ws();
437            if self.bytes.get(self.pos) != Some(&b'"') {
438                return Err(self.err("expected an object key string"));
439            }
440            let key_span = self.string()?;
441            let key = json_unescape(
442                std::str::from_utf8(&self.bytes[key_span.start..key_span.end])
443                    .map_err(|_| self.err("object key is not UTF-8"))?,
444            )?;
445            self.skip_ws();
446            if self.bytes.get(self.pos) != Some(&b':') {
447                return Err(self.err("expected ':' after an object key"));
448            }
449            self.pos += 1;
450            let child_pointer = format!("{pointer}/{}", escape_pointer_token(&key));
451            let script_key = SCRIPT_KEYS
452                .iter()
453                .copied()
454                .find(|candidate| *candidate == key);
455            self.value(&child_pointer, script_key, found)?;
456            self.skip_ws();
457            match self.bytes.get(self.pos) {
458                Some(b',') => self.pos += 1,
459                Some(b'}') => {
460                    self.pos += 1;
461                    return Ok(());
462                }
463                _ => return Err(self.err("expected ',' or '}' in an object")),
464            }
465        }
466    }
467
468    fn array(&mut self, pointer: &str, found: &mut Vec<Found>) -> Result<(), CoreError> {
469        self.pos += 1; // '['
470        self.skip_ws();
471        if self.bytes.get(self.pos) == Some(&b']') {
472            self.pos += 1;
473            return Ok(());
474        }
475        let mut index = 0usize;
476        loop {
477            let child_pointer = format!("{pointer}/{index}");
478            self.value(&child_pointer, None, found)?;
479            self.skip_ws();
480            match self.bytes.get(self.pos) {
481                Some(b',') => {
482                    self.pos += 1;
483                    index += 1;
484                }
485                Some(b']') => {
486                    self.pos += 1;
487                    return Ok(());
488                }
489                _ => return Err(self.err("expected ',' or ']' in an array")),
490            }
491        }
492    }
493}
494
495/// Walk the raw member bytes for SCRIPT_KEYS string values — the
496/// shared entry both decode and encode ride (span resolution is ONE
497/// mechanism, never two).
498fn scan_script_strings(json: &[u8]) -> Result<Vec<Found>, CoreError> {
499    let mut scanner = Scanner {
500        bytes: json,
501        pos: 0,
502    };
503    let mut found = Vec::new();
504    scanner.value("", None, &mut found)?;
505    scanner.skip_ws();
506    if scanner.pos != json.len() {
507        return Err(scanner.err("trailing bytes after the JSON document"));
508    }
509    Ok(found)
510}
511
512// ---- Member-level decode/encode ---------------------------------------------
513
514/// One manifest entry: the JSON-pointer address of a script string
515/// value, its counter-named sidecar sibling (`<member>.<n>.py`), and
516/// the indent prefix [`dedent`] stripped.
517#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
518pub struct ManifestEntry {
519    /// JSON pointer to the string value (e.g.
520    /// `/children/0/eventScripts/actionPerformed/script`).
521    pub pointer: String,
522    /// The sidecar file's basename, a sibling of the member file
523    /// (e.g. `view.json.1.py`).
524    pub sidecar: String,
525    /// The common leading-tab prefix to restore on encode.
526    pub indent_prefix: String,
527}
528
529/// The decoded-export manifest carried at the tree root — consumed +
530/// stripped by [`encode_export_tree`] (it never rides an upload).
531#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
532pub struct Manifest {
533    /// Manifest format version (1).
534    pub version: u32,
535    /// Member path → its entries (pointer/sidecar/indent triples).
536    pub members: BTreeMap<String, Vec<ManifestEntry>>,
537}
538
539/// One decoded member: the manifest entries plus each sidecar's
540/// text (decoded + dedented) ready to write.
541#[derive(Debug, Clone, PartialEq)]
542pub struct DecodedMember {
543    /// The zip member path the entries belong to.
544    pub member_path: String,
545    /// The entries, document order.
546    pub entries: Vec<DecodedEntry>,
547}
548
549/// One entry with its sidecar content.
550#[derive(Debug, Clone, PartialEq)]
551pub struct DecodedEntry {
552    /// The manifest record (pointer/sidecar/indent).
553    pub entry: ManifestEntry,
554    /// The sidecar text: decoded + dedented.
555    pub text: String,
556}
557
558/// Decode one member's embedded scripts: walk the RAW bytes for
559/// SCRIPT_KEYS string values that [`looks_like_script`] accepts,
560/// producing sidecar texts (decoded + dedented) and manifest entries
561/// with counter-named sidecars (`<member-basename>.<n>.py`). `None`
562/// when the member holds no decodable scripts (or does not scan —
563/// the caller leaves such members untouched, byte-verbatim).
564pub fn decode_member(member_json: &[u8], member_path: &str) -> Option<DecodedMember> {
565    let found = scan_script_strings(member_json).ok()?;
566    let basename = member_path.rsplit('/').next().unwrap_or(member_path);
567    let mut entries = Vec::new();
568    for f in found {
569        let Ok(raw_inner) = std::str::from_utf8(&member_json[f.span.start..f.span.end]) else {
570            continue; // non-UTF-8 script string — leave verbatim
571        };
572        if !looks_like_script(raw_inner) {
573            continue; // expression-shaped / single-line — pass through
574        }
575        let (text, indent_prefix) = dedent(&flint_decode(raw_inner));
576        entries.push(DecodedEntry {
577            entry: ManifestEntry {
578                pointer: f.pointer,
579                sidecar: format!("{basename}.{}.py", entries.len() + 1),
580                indent_prefix,
581            },
582            text,
583        });
584    }
585    if entries.is_empty() {
586        return None;
587    }
588    Some(DecodedMember {
589        member_path: member_path.to_string(),
590        entries,
591    })
592}
593
594/// Encode one member's scripts back: re-resolve each manifest
595/// pointer to its raw byte span in the CURRENT member bytes (the
596/// same shared scanner — re-scanning at encode time keeps splices
597/// valid even when the user hand-edited the member JSON), then
598/// splice the re-encoded replacement (reindent + [`flint_encode`])
599/// at that span. Rules:
600///
601/// - a manifest entry whose sidecar is ABSENT from `sidecar_texts`
602///   keeps the JSON's current value (never silently drop edits);
603/// - a pointer that no longer resolves keeps the current value;
604/// - unedited members re-encode BYTE-IDENTICAL (the sacred
605///   invariant at file level — untouched spans copy verbatim).
606pub fn encode_member(
607    member_json: &[u8],
608    entries: &[ManifestEntry],
609    sidecar_texts: &HashMap<String, String>,
610) -> Result<Vec<u8>, CoreError> {
611    let found = scan_script_strings(member_json).map_err(|err| CoreError::InvalidInput {
612        reason: format!(
613            "the edited member no longer parses as JSON — cannot splice its \
614             scripts back ({err})"
615        ),
616    })?;
617    // Resolve spans for the entries that have sidecar text.
618    let mut splices: Vec<(Span, Vec<u8>)> = Vec::new();
619    for entry in entries {
620        let Some(text) = sidecar_texts.get(&entry.sidecar) else {
621            continue; // missing sidecar — keep the current value
622        };
623        let Some(f) = found.iter().find(|f| f.pointer == entry.pointer) else {
624            continue; // value gone from the member — keep the current bytes
625        };
626        let encoded = flint_encode(&reindent(text, &entry.indent_prefix));
627        splices.push((f.span, encoded.into_bytes()));
628    }
629    splices.sort_by_key(|(span, _)| span.start);
630    let mut out = Vec::with_capacity(member_json.len());
631    let mut cursor = 0usize;
632    for (span, replacement) in &splices {
633        if span.start < cursor {
634            return Err(CoreError::Internal(
635                "overlapping script spans — refusing to splice".to_string(),
636            ));
637        }
638        out.extend_from_slice(&member_json[cursor..span.start]);
639        out.extend_from_slice(replacement);
640        cursor = span.end;
641    }
642    out.extend_from_slice(&member_json[cursor..]);
643    Ok(out)
644}
645
646// ---- Tree-level wrappers -----------------------------------------------------
647
648/// Count a zip's FILE members (directory entries excluded) — the
649/// export-decode result's member count.
650pub fn count_file_members(zip_bytes: &[u8]) -> Result<usize, CoreError> {
651    let mut archive = open_archive(zip_bytes)?;
652    let mut count = 0usize;
653    for index in 0..archive.len() {
654        let file = archive
655            .by_index(index)
656            .map_err(|err| CoreError::Internal(format!("cannot walk export zip: {err}")))?;
657        if !file.is_dir() {
658            count += 1;
659        }
660    }
661    Ok(count)
662}
663
664/// Open an export zip for reading — the resources.rs classification
665/// (a non-zip export is a gateway-contract violation, exit 1).
666fn open_archive(zip_bytes: &[u8]) -> Result<zip::ZipArchive<std::io::Cursor<&[u8]>>, CoreError> {
667    zip::ZipArchive::new(std::io::Cursor::new(zip_bytes))
668        .map_err(|err| CoreError::Internal(format!("project export is not a readable zip: {err}")))
669}
670
671/// The deterministic options every re-encoded member rides (the
672/// resources.rs rewrite convention).
673fn rewrite_options() -> zip::write::SimpleFileOptions {
674    zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated)
675}
676
677/// Decode an export zip into a DIRECTORY: every member written at
678/// its path, `<member>.<n>.py` sidecars beside the `.json` members
679/// that carry embedded scripts, and [`MANIFEST_NAME`] at the tree
680/// root. Returns the sidecar count. The exported JSON stays
681/// MARKER-FREE (gateway-clean).
682pub fn decode_export_tree(zip_bytes: &[u8], out_dir: &Path) -> Result<usize, CoreError> {
683    let mut archive = open_archive(zip_bytes)?;
684    let names: BTreeSet<String> = archive.file_names().map(str::to_string).collect();
685    if names.contains(MANIFEST_NAME) {
686        return Err(CoreError::Internal(format!(
687            "the export already carries a {MANIFEST_NAME} member — refusing to \
688             shadow it with the decode manifest"
689        )));
690    }
691    std::fs::create_dir_all(out_dir).map_err(|err| {
692        CoreError::Internal(format!(
693            "cannot create decode directory {}: {err}",
694            out_dir.display()
695        ))
696    })?;
697    let mut manifest = Manifest {
698        version: 1,
699        members: BTreeMap::new(),
700    };
701    let mut scripts = 0usize;
702    for index in 0..archive.len() {
703        let mut file = archive
704            .by_index(index)
705            .map_err(|err| CoreError::Internal(format!("cannot walk export zip: {err}")))?;
706        let name = file.name().to_string();
707        if file.is_dir() {
708            std::fs::create_dir_all(out_dir.join(&name))
709                .map_err(|err| CoreError::Internal(format!("cannot create {}: {err}", name)))?;
710            continue;
711        }
712        let mut bytes = Vec::new();
713        file.read_to_end(&mut bytes).map_err(|err| {
714            CoreError::Internal(format!("cannot decompress zip member {name:?}: {err}"))
715        })?;
716        let dest = out_dir.join(&name);
717        if let Some(parent) = dest.parent() {
718            std::fs::create_dir_all(parent).map_err(|err| {
719                CoreError::Internal(format!("cannot create {}: {err}", parent.display()))
720            })?;
721        }
722        std::fs::write(&dest, &bytes).map_err(|err| {
723            CoreError::Internal(format!("cannot write {}: {err}", dest.display()))
724        })?;
725        // The decode pass: `.json` members only, sidecars as
726        // siblings (counter-named), entries recorded in the manifest.
727        if name.ends_with(".json")
728            && let Some(decoded) = decode_member(&bytes, &name)
729        {
730            for e in &decoded.entries {
731                let sidecar_member_path = match name.rsplit_once('/') {
732                    Some((parent, _)) => format!("{parent}/{}", e.entry.sidecar),
733                    None => e.entry.sidecar.clone(),
734                };
735                if names.contains(&sidecar_member_path) {
736                    return Err(CoreError::Internal(format!(
737                        "sidecar {sidecar_member_path:?} collides with a real export \
738                         member — refusing to shadow it"
739                    )));
740                }
741                let sidecar_path = dest.with_file_name(&e.entry.sidecar);
742                std::fs::write(&sidecar_path, &e.text).map_err(|err| {
743                    CoreError::Internal(format!("cannot write {}: {err}", sidecar_path.display()))
744                })?;
745                scripts += 1;
746            }
747            manifest.members.insert(
748                name.clone(),
749                decoded.entries.iter().map(|e| e.entry.clone()).collect(),
750            );
751        }
752    }
753    let manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|err| {
754        CoreError::Internal(format!("cannot serialize the decode manifest: {err}"))
755    })?;
756    let manifest_path = out_dir.join(MANIFEST_NAME);
757    std::fs::write(
758        &manifest_path,
759        format!("{}\n", String::from_utf8_lossy(&manifest_bytes)),
760    )
761    .map_err(|err| {
762        CoreError::Internal(format!("cannot write {}: {err}", manifest_path.display()))
763    })?;
764    Ok(scripts)
765}
766
767/// Re-zip a decoded export DIRECTORY back into importable zip bytes:
768/// the manifest is consumed + stripped, every sidecar referenced by
769/// it is stripped, members with manifest entries ride
770/// [`encode_member`] (span-level splice), everything else copies
771/// verbatim. Missing sidecars keep the member's current value (the
772/// decode rule). A directory without [`MANIFEST_NAME`] is not a
773/// decoded export tree (usage-class refusal).
774pub fn encode_export_tree(dir: &Path) -> Result<Vec<u8>, CoreError> {
775    let manifest_path = dir.join(MANIFEST_NAME);
776    let manifest: Manifest = std::fs::read(&manifest_path)
777        .map_err(|err| CoreError::InvalidInput {
778            reason: format!(
779                "{} is not a decoded export directory (cannot read {MANIFEST_NAME}: {err})",
780                dir.display()
781            ),
782        })
783        .and_then(|bytes| {
784            serde_json::from_slice(&bytes).map_err(|err| CoreError::InvalidInput {
785                reason: format!("{MANIFEST_NAME} is not valid JSON: {err}"),
786            })
787        })?;
788    // The sidecar set to strip: (member's parent dir, sidecar name).
789    let mut sidecar_set: BTreeSet<(String, String)> = BTreeSet::new();
790    for (member, entries) in &manifest.members {
791        let parent = member
792            .rsplit_once('/')
793            .map_or(String::new(), |(p, _)| p.to_string());
794        for entry in entries {
795            sidecar_set.insert((parent.clone(), entry.sidecar.clone()));
796        }
797    }
798    // Deterministic walk: entries sorted by name per directory.
799    let mut files: Vec<PathBuf> = Vec::new();
800    walk_files(dir, Path::new(""), &mut files)?;
801    let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
802    let options = rewrite_options();
803    for rel in files {
804        let rel_str = rel.to_string_lossy().into_owned();
805        if rel_str == MANIFEST_NAME {
806            continue; // consumed + stripped
807        }
808        let (parent, basename) = rel_str
809            .rsplit_once('/')
810            .map_or(("", rel_str.as_str()), |(p, b)| (p, b));
811        if sidecar_set.contains(&(parent.to_string(), basename.to_string())) {
812            continue; // stripped — their text rides via the splice
813        }
814        let bytes = std::fs::read(dir.join(&rel)).map_err(|err| CoreError::InvalidInput {
815            reason: format!("cannot read {}: {err}", rel.display()),
816        })?;
817        let content = if let Some(entries) = manifest.members.get(&rel_str) {
818            let mut texts: HashMap<String, String> = HashMap::new();
819            let member_abs = dir.join(&rel);
820            for entry in entries {
821                if let Ok(text) = std::fs::read_to_string(member_abs.with_file_name(&entry.sidecar))
822                {
823                    texts.insert(entry.sidecar.clone(), text);
824                }
825            }
826            encode_member(&bytes, entries, &texts)?
827        } else {
828            bytes
829        };
830        writer
831            .start_file(rel_str.clone(), options)
832            .map_err(|err| CoreError::Internal(format!("cannot re-zip {rel_str:?}: {err}")))?;
833        writer
834            .write_all(&content)
835            .map_err(|err| CoreError::Internal(format!("cannot re-zip {rel_str:?}: {err}")))?;
836    }
837    writer
838        .finish()
839        .map_err(|err| CoreError::Internal(format!("cannot finalize re-encoded zip: {err}")))
840        .map(|cursor| cursor.into_inner())
841}
842
843/// Recursive, name-sorted file listing under `dir`/`prefix`.
844fn walk_files(dir: &Path, prefix: &Path, out: &mut Vec<PathBuf>) -> Result<(), CoreError> {
845    let mut entries: Vec<_> = std::fs::read_dir(dir.join(prefix))
846        .map_err(|err| CoreError::InvalidInput {
847            reason: format!("cannot walk {}: {err}", dir.join(prefix).display()),
848        })?
849        .collect::<Result<Vec<_>, _>>()
850        .map_err(|err| CoreError::InvalidInput {
851            reason: format!("cannot walk {}: {err}", dir.join(prefix).display()),
852        })?;
853    entries.sort_by_key(|entry| entry.file_name());
854    for entry in entries {
855        let rel = prefix.join(entry.file_name());
856        if entry.path().is_dir() {
857            walk_files(dir, &rel, out)?;
858        } else {
859            out.push(rel);
860        }
861    }
862    Ok(())
863}
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868
869    // ---- The sacred codec invariant (ported ignition-nvim vectors) ----
870
871    /// THE invariant corpus: every string Ignition's writer can
872    /// produce round-trips `flint_encode(flint_decode(x)) == x` —
873    /// all 12 escapes, the `\\t` ambiguity, quotes, HTML five,
874    /// unicode text, and real script shapes.
875    #[test]
876    fn encode_decode_round_trip_is_sacred() {
877        let corpus = [
878            // all twelve table entries
879            "back\\\\slash",
880            "quote \" inside",
881            "tab\there",
882            "backspace\u{8}",
883            "new\nline",
884            "carriage\rreturn",
885            "form\u{c}feed",
886            "less < than",
887            "greater > than",
888            "amp & ersand",
889            "equals = sign",
890            "apostrophe ' here",
891            // the ambiguity: literal backslash + t vs the tab escape
892            "\\\\t is not a tab",
893            "\ttab is a tab",
894            // mixed real-world script shapes
895            "if x < 3 && y > 2:\n\tprint 'it\\'s <>&='\n\treturn {value}",
896            "print(\"quoted \\\"inside\\\"\")",
897            // multi-line with all markers
898            "\tfor i in range(10):\n\t\tif i & 1 == 0:\n\t\t\tprint i, '<', '=', '>'",
899            // unicode rides verbatim (the table never escapes it)
900            "café ☕ naïve",
901            "",
902            "no escapes at all",
903        ];
904        for text in corpus {
905            let encoded = flint_encode(text);
906            assert_eq!(flint_decode(&encoded), text, "decode(encode({text:?}))");
907            // the sacred direction the plan pins:
908            assert_eq!(
909                flint_encode(&flint_decode(&encoded)),
910                encoded,
911                "encode(decode(x)) == x for {encoded:?}"
912            );
913        }
914    }
915
916    /// `\\t` (literal backslash + t) decodes differently from `\t`
917    /// (tab) — the multi-pass impossibility, single-pass proof.
918    #[test]
919    fn decode_distinguishes_escaped_backslash_t_from_tab() {
920        assert_eq!(flint_decode(r"\\t"), r"\t");
921        assert_eq!(flint_decode(r"\t"), "\t");
922        // backslash, backslash, tab-escape → backslash + tab.
923        assert_eq!(flint_decode(r"\\\t"), "\\\t");
924    }
925
926    /// Unknown `\uXXXX` escapes KEEP the backslash (and the escape
927    /// rides verbatim through re-encode-as-text); the HTML five map.
928    #[test]
929    fn decode_maps_the_html_five_and_keeps_unknown_unicode_escapes() {
930        assert_eq!(flint_decode(r"\u003c"), "<");
931        assert_eq!(flint_decode(r"\u003e"), ">");
932        assert_eq!(flint_decode(r"\u0026"), "&");
933        assert_eq!(flint_decode(r"\u003d"), "=");
934        assert_eq!(flint_decode(r"\u0027"), "'");
935        // Unknown — backslash kept, sequence verbatim:
936        assert_eq!(flint_decode(r"\u0041"), r"\u0041");
937        assert_eq!(flint_decode(r"\u00zz"), r"\u00zz");
938        // Truncated — backslash kept:
939        assert_eq!(flint_decode(r"\u00"), r"\u00");
940        // Unknown single escapes keep the backslash too:
941        assert_eq!(flint_decode(r"\/"), r"\/");
942    }
943
944    /// dedent/reindent: the common leading-TAB prefix strips and
945    /// restores (only non-empty lines reindent; no-indent text is a
946    /// no-op with an empty prefix).
947    #[test]
948    fn dedent_reindent_inverse_on_tab_indented_scripts() {
949        let script = "\t\tfor i in range(3):\n\t\t\tprint i\n\t\tprint 'end'";
950        let (dedented, prefix) = dedent(script);
951        assert_eq!(dedented, "for i in range(3):\n\tprint i\nprint 'end'");
952        assert_eq!(prefix, "\t\t");
953        assert_eq!(reindent(&dedented, &prefix), script);
954
955        // No common indent: unchanged, empty prefix.
956        let flat = "print('x')\nprint('y')";
957        let (same, empty) = dedent(flat);
958        assert_eq!((same.as_str(), empty.as_str()), (flat, ""));
959        assert_eq!(reindent(&same, &empty), flat);
960
961        // Empty-string edge.
962        assert_eq!(dedent(""), (String::new(), String::new()));
963
964        // Trailing newline: the empty last line stays empty.
965        let trailing = "\tdo()\n";
966        let (dedented, prefix) = dedent(trailing);
967        assert_eq!((dedented.as_str(), prefix.as_str()), ("do()\n", "\t"));
968        assert_eq!(reindent(&dedented, &prefix), trailing);
969    }
970
971    /// SCRIPT_KEYS is the ignition-nvim list, all ten, in order.
972    #[test]
973    fn script_keys_match_ignition_nvim() {
974        assert_eq!(
975            SCRIPT_KEYS,
976            [
977                "script",
978                "code",
979                "eventScript",
980                "transform",
981                "onActionPerformed",
982                "onChange",
983                "onStartup",
984                "onShutdown",
985                "expression",
986            ]
987        );
988    }
989
990    // ---- decode_member / encode_member ---------------------------------
991
992    /// A live-shaped view member: two embedded scripts at different
993    /// depths, an expression value under a SCRIPT_KEY that must PASS
994    /// THROUGH, and plain text fields.
995    const VIEW_JSON: &str = r#"{
996  "scope": "G",
997  "children": [
998    {
999      "type": "ia.display.label",
1000      "meta": {
1001        "name": "lbl"
1002      },
1003      "props": {
1004        "text": "plain <>&=' text"
1005      },
1006      "eventScripts": {
1007        "actionPerformed": {
1008          "config": {
1009            "script": "\tprint \u0027clicked\u0027\n\tprint \u0027done \u003c\u003e\u0026\u003d\u0027"
1010          }
1011        }
1012      }
1013    },
1014    {
1015      "type": "ia.chart",
1016      "transform": {
1017        "script": "\t\tfor i in range(3):\n\t\t\tprint i\n\t\tprint \u0027end\u0027"
1018      },
1019      "props": {
1020        "expression": "toStr({view.args.x} * 2)"
1021      }
1022    }
1023  ]
1024}"#;
1025
1026    #[test]
1027    fn decode_member_finds_nested_scripts_and_passes_expressions_through() {
1028        let decoded = decode_member(VIEW_JSON.as_bytes(), "c/views/Dashboard/view.json")
1029            .expect("two scripts decode");
1030        assert_eq!(decoded.entries.len(), 2, "the expression does not decode");
1031        assert_eq!(
1032            decoded.entries[0].entry.pointer,
1033            "/children/0/eventScripts/actionPerformed/config/script"
1034        );
1035        assert_eq!(decoded.entries[0].entry.sidecar, "view.json.1.py");
1036        assert_eq!(decoded.entries[0].entry.indent_prefix, "\t");
1037        assert_eq!(
1038            decoded.entries[0].text,
1039            "print 'clicked'\nprint 'done <>&='"
1040        );
1041        assert_eq!(
1042            decoded.entries[1].entry.pointer,
1043            "/children/1/transform/script"
1044        );
1045        assert_eq!(decoded.entries[1].entry.sidecar, "view.json.2.py");
1046        assert_eq!(decoded.entries[1].entry.indent_prefix, "\t\t");
1047
1048        // A member without scripts decodes to None.
1049        assert!(decode_member(br#"{"title":"T"}"#, "project.json").is_none());
1050        // A member that does not scan decodes to None (rides verbatim).
1051        assert!(decode_member(b"<<<not json>>>", "broken.json").is_none());
1052        // A script-python member (plain text, not JSON) is None.
1053        assert!(decode_member(b"print('plain')\n", "ignition/x/scratch").is_none());
1054    }
1055
1056    /// THE file-level sacred invariant, member edition: unedited
1057    /// sidecars re-encode BYTE-IDENTICAL; editing one sidecar changes
1058    /// only that span; a missing sidecar keeps the current value.
1059    #[test]
1060    fn encode_member_round_trips_bytes_and_splices_edits() {
1061        let decoded =
1062            decode_member(VIEW_JSON.as_bytes(), "c/views/Dashboard/view.json").expect("decodes");
1063        let entries: Vec<ManifestEntry> = decoded.entries.iter().map(|e| e.entry.clone()).collect();
1064        let texts: HashMap<String, String> = decoded
1065            .entries
1066            .iter()
1067            .map(|e| (e.entry.sidecar.clone(), e.text.clone()))
1068            .collect();
1069
1070        // Unedited: byte-identical.
1071        let out = encode_member(VIEW_JSON.as_bytes(), &entries, &texts).expect("encodes");
1072        assert_eq!(out, VIEW_JSON.as_bytes(), "unedited round-trip is exact");
1073
1074        // Edit sidecar 1 only: the re-encoded member differs, parses,
1075        // and carries the new script text at the SAME pointer.
1076        let mut edited = texts.clone();
1077        edited.insert(
1078            "view.json.1.py".to_string(),
1079            "print 'edited'\nprint 'twice'".to_string(),
1080        );
1081        let out = encode_member(VIEW_JSON.as_bytes(), &entries, &edited).expect("encodes");
1082        let parsed: serde_json::Value = serde_json::from_slice(&out).expect("still JSON");
1083        assert_eq!(
1084            parsed["children"][0]["eventScripts"]["actionPerformed"]["config"]["script"],
1085            "\tprint 'edited'\n\tprint 'twice'",
1086            "the edited text re-dents under the recorded prefix"
1087        );
1088        assert_eq!(
1089            parsed["children"][1]["transform"]["script"],
1090            "\t\tfor i in range(3):\n\t\t\tprint i\n\t\tprint 'end'",
1091            "the unedited sibling rides byte-equal content"
1092        );
1093
1094        // Missing sidecar: the value is preserved (never dropped).
1095        let mut missing = texts.clone();
1096        missing.remove("view.json.2.py");
1097        let out = encode_member(VIEW_JSON.as_bytes(), &entries, &missing).expect("encodes");
1098        assert_eq!(
1099            out,
1100            VIEW_JSON.as_bytes(),
1101            "a missing sidecar keeps the value"
1102        );
1103    }
1104
1105    /// A member whose manifest pointer no longer resolves (the user
1106    /// deleted the value) keeps its current bytes; a member that no
1107    /// longer parses refuses usage-class.
1108    #[test]
1109    fn encode_member_handles_unresolvable_pointers_and_broken_json() {
1110        let entries = vec![ManifestEntry {
1111            pointer: "/gone/script".to_string(),
1112            sidecar: "view.json.1.py".to_string(),
1113            indent_prefix: String::new(),
1114        }];
1115        let mut texts = HashMap::new();
1116        texts.insert("view.json.1.py".to_string(), "x".to_string());
1117        let out = encode_member(VIEW_JSON.as_bytes(), &entries, &texts).expect("encodes");
1118        assert_eq!(
1119            out,
1120            VIEW_JSON.as_bytes(),
1121            "an unresolvable pointer is a no-op"
1122        );
1123
1124        let err = encode_member(b"<<<broken>>>", &entries, &texts).expect_err("must refuse");
1125        assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
1126        assert_eq!(err.exit_code(), 2);
1127    }
1128
1129    // ---- Tree wrappers ---------------------------------------------------
1130
1131    /// Build an in-test export zip (the resources.rs fixture style).
1132    fn fixture_zip(members: &[(&str, &[u8])]) -> Vec<u8> {
1133        let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
1134        let options = zip::write::SimpleFileOptions::default()
1135            .compression_method(zip::CompressionMethod::Deflated);
1136        for (name, bytes) in members {
1137            writer.start_file(*name, options).expect("member starts");
1138            writer.write_all(bytes).expect("member writes");
1139        }
1140        writer.finish().expect("zip finalizes").into_inner()
1141    }
1142
1143    /// The full tree round-trip: decode → encode with no edits →
1144    /// every file member byte-identical (the contract's core, here
1145    /// at unit weight; the dedicated contract file carries the full
1146    /// fixture matrix).
1147    #[test]
1148    fn decode_encode_tree_round_trips_unedited_members() {
1149        let zip = fixture_zip(&[
1150            ("project.json", br#"{"title":"T"}"#.as_slice()),
1151            (
1152                "c/resources/views/Dashboard/view.json",
1153                VIEW_JSON.as_bytes(),
1154            ),
1155            (
1156                "c/resources/views/Dashboard/resource.json",
1157                br#"{"scope":"G","version":1,"files":["view.json"]}"#.as_slice(),
1158            ),
1159            ("ignition/resources/scratch", b"print('plain')".as_slice()),
1160        ]);
1161        let dir = tempfile::tempdir().expect("tempdir");
1162        let scripts = decode_export_tree(&zip, dir.path()).expect("decodes");
1163        assert_eq!(scripts, 2);
1164        assert!(dir.path().join(MANIFEST_NAME).is_file());
1165        assert!(
1166            dir.path()
1167                .join("c/resources/views/Dashboard/view.json.1.py")
1168                .is_file()
1169        );
1170
1171        let re_zipped = encode_export_tree(dir.path()).expect("re-encodes");
1172        let mut original = open_archive(&zip).expect("reopen");
1173        let mut re = open_archive(&re_zipped).expect("open re-zip");
1174        assert_eq!(re.len(), original.len(), "same member count");
1175        for index in 0..original.len() {
1176            let name = original.by_index(index).expect("orig").name().to_string();
1177            let mut orig_bytes = Vec::new();
1178            original
1179                .by_index(index)
1180                .expect("orig")
1181                .read_to_end(&mut orig_bytes)
1182                .expect("read");
1183            let mut re_file = re.by_name(&name).expect("member present");
1184            let mut re_bytes = Vec::new();
1185            re_file.read_to_end(&mut re_bytes).expect("read");
1186            assert_eq!(re_bytes, orig_bytes, "{name} byte-identical unedited");
1187        }
1188        // The manifest never rides the re-zip.
1189        assert!(re.by_name(MANIFEST_NAME).is_err());
1190        assert_eq!(count_file_members(&re_zipped).expect("counts"), 4);
1191    }
1192
1193    /// A tree edit flows through: editing one sidecar changes only
1194    /// that member; the others stay byte-identical.
1195    #[test]
1196    fn tree_edit_splices_only_the_edited_member() {
1197        let zip = fixture_zip(&[
1198            ("project.json", br#"{"title":"T"}"#.as_slice()),
1199            (
1200                "c/resources/views/Dashboard/view.json",
1201                VIEW_JSON.as_bytes(),
1202            ),
1203            ("ignition/resources/scratch", b"print('plain')".as_slice()),
1204        ]);
1205        let dir = tempfile::tempdir().expect("tempdir");
1206        decode_export_tree(&zip, dir.path()).expect("decodes");
1207        let sidecar = dir
1208            .path()
1209            .join("c/resources/views/Dashboard/view.json.1.py");
1210        std::fs::write(&sidecar, "print 'edited alone'").expect("edit sidecar");
1211        let re_zipped = encode_export_tree(dir.path()).expect("re-encodes");
1212
1213        let mut re = open_archive(&re_zipped).expect("open");
1214        let mut view = Vec::new();
1215        re.by_name("c/resources/views/Dashboard/view.json")
1216            .expect("view")
1217            .read_to_end(&mut view)
1218            .expect("read");
1219        let parsed: serde_json::Value = serde_json::from_slice(&view).expect("json");
1220        assert_eq!(
1221            parsed["children"][0]["eventScripts"]["actionPerformed"]["config"]["script"],
1222            "\tprint 'edited alone'"
1223        );
1224        let mut scratch = Vec::new();
1225        re.by_name("ignition/resources/scratch")
1226            .expect("scratch")
1227            .read_to_end(&mut scratch)
1228            .expect("read");
1229        assert_eq!(scratch, b"print('plain')");
1230    }
1231
1232    /// encode_export_tree refuses a non-decoded directory
1233    /// usage-class; decode refuses an export already carrying a
1234    /// manifest member.
1235    #[test]
1236    fn tree_wrapper_error_shapes() {
1237        let plain = tempfile::tempdir().expect("tempdir");
1238        let err = encode_export_tree(plain.path()).expect_err("no manifest refuses");
1239        assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
1240        assert_eq!(err.exit_code(), 2);
1241        assert!(err.to_string().contains("not a decoded export directory"));
1242
1243        let zip = fixture_zip(&[(MANIFEST_NAME, b"{}".as_slice())]);
1244        let dir = tempfile::tempdir().expect("tempdir");
1245        let err = decode_export_tree(&zip, dir.path()).expect_err("shadow refuses");
1246        assert!(matches!(err, CoreError::Internal(_)), "{err}");
1247    }
1248}