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/// Render a tree-relative path as a FORWARD-slash string — the
649/// canonical member/manifest-key form. Windows `PathBuf::to_string_lossy`
650/// emits `\`, which would poison the three cross-platform seams (zip
651/// member names, the codec manifest keys, the workspace manifest's
652/// `local_path` — all git-shared or gateway-facing). Component-wise
653/// joining preserves a literal `\` inside a POSIX filename, which a
654/// blanket replace would corrupt.
655pub fn tree_relative_string(path: &Path) -> String {
656    path.iter()
657        .map(|component| component.to_string_lossy())
658        .collect::<Vec<_>>()
659        .join("/")
660}
661
662/// Count a zip's FILE members (directory entries excluded) — the
663/// export-decode result's member count.
664pub fn count_file_members(zip_bytes: &[u8]) -> Result<usize, CoreError> {
665    let mut archive = open_archive(zip_bytes)?;
666    let mut count = 0usize;
667    for index in 0..archive.len() {
668        let file = archive
669            .by_index(index)
670            .map_err(|err| CoreError::Internal(format!("cannot walk export zip: {err}")))?;
671        if !file.is_dir() {
672            count += 1;
673        }
674    }
675    Ok(count)
676}
677
678/// Open an export zip for reading — the resources.rs classification
679/// (a non-zip export is a gateway-contract violation, exit 1).
680fn open_archive(zip_bytes: &[u8]) -> Result<zip::ZipArchive<std::io::Cursor<&[u8]>>, CoreError> {
681    zip::ZipArchive::new(std::io::Cursor::new(zip_bytes))
682        .map_err(|err| CoreError::Internal(format!("project export is not a readable zip: {err}")))
683}
684
685/// The deterministic options every re-encoded member rides (the
686/// resources.rs rewrite convention).
687fn rewrite_options() -> zip::write::SimpleFileOptions {
688    zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated)
689}
690
691/// Decode an export zip into a DIRECTORY: every member written at
692/// its path, `<member>.<n>.py` sidecars beside the `.json` members
693/// that carry embedded scripts, and [`MANIFEST_NAME`] at the tree
694/// root. Returns the sidecar count. The exported JSON stays
695/// MARKER-FREE (gateway-clean).
696pub fn decode_export_tree(zip_bytes: &[u8], out_dir: &Path) -> Result<usize, CoreError> {
697    let mut archive = open_archive(zip_bytes)?;
698    let names: BTreeSet<String> = archive.file_names().map(str::to_string).collect();
699    if names.contains(MANIFEST_NAME) {
700        return Err(CoreError::Internal(format!(
701            "the export already carries a {MANIFEST_NAME} member — refusing to \
702             shadow it with the decode manifest"
703        )));
704    }
705    std::fs::create_dir_all(out_dir).map_err(|err| {
706        CoreError::Internal(format!(
707            "cannot create decode directory {}: {err}",
708            out_dir.display()
709        ))
710    })?;
711    let mut manifest = Manifest {
712        version: 1,
713        members: BTreeMap::new(),
714    };
715    let mut scripts = 0usize;
716    for index in 0..archive.len() {
717        let mut file = archive
718            .by_index(index)
719            .map_err(|err| CoreError::Internal(format!("cannot walk export zip: {err}")))?;
720        let name = file.name().to_string();
721        if file.is_dir() {
722            std::fs::create_dir_all(out_dir.join(&name))
723                .map_err(|err| CoreError::Internal(format!("cannot create {}: {err}", name)))?;
724            continue;
725        }
726        let mut bytes = Vec::new();
727        file.read_to_end(&mut bytes).map_err(|err| {
728            CoreError::Internal(format!("cannot decompress zip member {name:?}: {err}"))
729        })?;
730        let dest = out_dir.join(&name);
731        if let Some(parent) = dest.parent() {
732            std::fs::create_dir_all(parent).map_err(|err| {
733                CoreError::Internal(format!("cannot create {}: {err}", parent.display()))
734            })?;
735        }
736        std::fs::write(&dest, &bytes).map_err(|err| {
737            CoreError::Internal(format!("cannot write {}: {err}", dest.display()))
738        })?;
739        // The decode pass: `.json` members only, sidecars as
740        // siblings (counter-named), entries recorded in the manifest.
741        if name.ends_with(".json")
742            && let Some(decoded) = decode_member(&bytes, &name)
743        {
744            for e in &decoded.entries {
745                let sidecar_member_path = match name.rsplit_once('/') {
746                    Some((parent, _)) => format!("{parent}/{}", e.entry.sidecar),
747                    None => e.entry.sidecar.clone(),
748                };
749                if names.contains(&sidecar_member_path) {
750                    return Err(CoreError::Internal(format!(
751                        "sidecar {sidecar_member_path:?} collides with a real export \
752                         member — refusing to shadow it"
753                    )));
754                }
755                let sidecar_path = dest.with_file_name(&e.entry.sidecar);
756                std::fs::write(&sidecar_path, &e.text).map_err(|err| {
757                    CoreError::Internal(format!("cannot write {}: {err}", sidecar_path.display()))
758                })?;
759                scripts += 1;
760            }
761            manifest.members.insert(
762                name.clone(),
763                decoded.entries.iter().map(|e| e.entry.clone()).collect(),
764            );
765        }
766    }
767    let manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|err| {
768        CoreError::Internal(format!("cannot serialize the decode manifest: {err}"))
769    })?;
770    let manifest_path = out_dir.join(MANIFEST_NAME);
771    std::fs::write(
772        &manifest_path,
773        format!("{}\n", String::from_utf8_lossy(&manifest_bytes)),
774    )
775    .map_err(|err| {
776        CoreError::Internal(format!("cannot write {}: {err}", manifest_path.display()))
777    })?;
778    Ok(scripts)
779}
780
781/// Re-zip a decoded export DIRECTORY back into importable zip bytes:
782/// the manifest is consumed + stripped, every sidecar referenced by
783/// it is stripped, members with manifest entries ride
784/// [`encode_member`] (span-level splice), everything else copies
785/// verbatim. Missing sidecars keep the member's current value (the
786/// decode rule). A directory without [`MANIFEST_NAME`] is not a
787/// decoded export tree (usage-class refusal).
788pub fn encode_export_tree(dir: &Path) -> Result<Vec<u8>, CoreError> {
789    let manifest_path = dir.join(MANIFEST_NAME);
790    let manifest: Manifest = std::fs::read(&manifest_path)
791        .map_err(|err| CoreError::InvalidInput {
792            reason: format!(
793                "{} is not a decoded export directory (cannot read {MANIFEST_NAME}: {err})",
794                dir.display()
795            ),
796        })
797        .and_then(|bytes| {
798            serde_json::from_slice(&bytes).map_err(|err| CoreError::InvalidInput {
799                reason: format!("{MANIFEST_NAME} is not valid JSON: {err}"),
800            })
801        })?;
802    // The sidecar set to strip: (member's parent dir, sidecar name).
803    let mut sidecar_set: BTreeSet<(String, String)> = BTreeSet::new();
804    for (member, entries) in &manifest.members {
805        let parent = member
806            .rsplit_once('/')
807            .map_or(String::new(), |(p, _)| p.to_string());
808        for entry in entries {
809            sidecar_set.insert((parent.clone(), entry.sidecar.clone()));
810        }
811    }
812    // Deterministic walk: entries sorted by name per directory.
813    let mut files: Vec<PathBuf> = Vec::new();
814    walk_files(dir, Path::new(""), &mut files)?;
815    let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
816    let options = rewrite_options();
817    for rel in files {
818        let rel_str = tree_relative_string(&rel);
819        if rel_str == MANIFEST_NAME {
820            continue; // consumed + stripped
821        }
822        let (parent, basename) = rel_str
823            .rsplit_once('/')
824            .map_or(("", rel_str.as_str()), |(p, b)| (p, b));
825        if sidecar_set.contains(&(parent.to_string(), basename.to_string())) {
826            continue; // stripped — their text rides via the splice
827        }
828        let bytes = std::fs::read(dir.join(&rel)).map_err(|err| CoreError::InvalidInput {
829            reason: format!("cannot read {}: {err}", rel.display()),
830        })?;
831        let content = if let Some(entries) = manifest.members.get(&rel_str) {
832            let mut texts: HashMap<String, String> = HashMap::new();
833            let member_abs = dir.join(&rel);
834            for entry in entries {
835                if let Ok(text) = std::fs::read_to_string(member_abs.with_file_name(&entry.sidecar))
836                {
837                    texts.insert(entry.sidecar.clone(), text);
838                }
839            }
840            encode_member(&bytes, entries, &texts)?
841        } else {
842            bytes
843        };
844        writer
845            .start_file(rel_str.clone(), options)
846            .map_err(|err| CoreError::Internal(format!("cannot re-zip {rel_str:?}: {err}")))?;
847        writer
848            .write_all(&content)
849            .map_err(|err| CoreError::Internal(format!("cannot re-zip {rel_str:?}: {err}")))?;
850    }
851    writer
852        .finish()
853        .map_err(|err| CoreError::Internal(format!("cannot finalize re-encoded zip: {err}")))
854        .map(|cursor| cursor.into_inner())
855}
856
857/// Recursive, name-sorted file listing under `dir`/`prefix`.
858fn walk_files(dir: &Path, prefix: &Path, out: &mut Vec<PathBuf>) -> Result<(), CoreError> {
859    let mut entries: Vec<_> = std::fs::read_dir(dir.join(prefix))
860        .map_err(|err| CoreError::InvalidInput {
861            reason: format!("cannot walk {}: {err}", dir.join(prefix).display()),
862        })?
863        .collect::<Result<Vec<_>, _>>()
864        .map_err(|err| CoreError::InvalidInput {
865            reason: format!("cannot walk {}: {err}", dir.join(prefix).display()),
866        })?;
867    entries.sort_by_key(|entry| entry.file_name());
868    for entry in entries {
869        let rel = prefix.join(entry.file_name());
870        if entry.path().is_dir() {
871            walk_files(dir, &rel, out)?;
872        } else {
873            out.push(rel);
874        }
875    }
876    Ok(())
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882
883    // ---- The sacred codec invariant (ported ignition-nvim vectors) ----
884
885    /// THE invariant corpus: every string Ignition's writer can
886    /// produce round-trips `flint_encode(flint_decode(x)) == x` —
887    /// all 12 escapes, the `\\t` ambiguity, quotes, HTML five,
888    /// unicode text, and real script shapes.
889    #[test]
890    fn encode_decode_round_trip_is_sacred() {
891        let corpus = [
892            // all twelve table entries
893            "back\\\\slash",
894            "quote \" inside",
895            "tab\there",
896            "backspace\u{8}",
897            "new\nline",
898            "carriage\rreturn",
899            "form\u{c}feed",
900            "less < than",
901            "greater > than",
902            "amp & ersand",
903            "equals = sign",
904            "apostrophe ' here",
905            // the ambiguity: literal backslash + t vs the tab escape
906            "\\\\t is not a tab",
907            "\ttab is a tab",
908            // mixed real-world script shapes
909            "if x < 3 && y > 2:\n\tprint 'it\\'s <>&='\n\treturn {value}",
910            "print(\"quoted \\\"inside\\\"\")",
911            // multi-line with all markers
912            "\tfor i in range(10):\n\t\tif i & 1 == 0:\n\t\t\tprint i, '<', '=', '>'",
913            // unicode rides verbatim (the table never escapes it)
914            "café ☕ naïve",
915            "",
916            "no escapes at all",
917        ];
918        for text in corpus {
919            let encoded = flint_encode(text);
920            assert_eq!(flint_decode(&encoded), text, "decode(encode({text:?}))");
921            // the sacred direction the plan pins:
922            assert_eq!(
923                flint_encode(&flint_decode(&encoded)),
924                encoded,
925                "encode(decode(x)) == x for {encoded:?}"
926            );
927        }
928    }
929
930    /// `\\t` (literal backslash + t) decodes differently from `\t`
931    /// (tab) — the multi-pass impossibility, single-pass proof.
932    #[test]
933    fn decode_distinguishes_escaped_backslash_t_from_tab() {
934        assert_eq!(flint_decode(r"\\t"), r"\t");
935        assert_eq!(flint_decode(r"\t"), "\t");
936        // backslash, backslash, tab-escape → backslash + tab.
937        assert_eq!(flint_decode(r"\\\t"), "\\\t");
938    }
939
940    /// Unknown `\uXXXX` escapes KEEP the backslash (and the escape
941    /// rides verbatim through re-encode-as-text); the HTML five map.
942    #[test]
943    fn decode_maps_the_html_five_and_keeps_unknown_unicode_escapes() {
944        assert_eq!(flint_decode(r"\u003c"), "<");
945        assert_eq!(flint_decode(r"\u003e"), ">");
946        assert_eq!(flint_decode(r"\u0026"), "&");
947        assert_eq!(flint_decode(r"\u003d"), "=");
948        assert_eq!(flint_decode(r"\u0027"), "'");
949        // Unknown — backslash kept, sequence verbatim:
950        assert_eq!(flint_decode(r"\u0041"), r"\u0041");
951        assert_eq!(flint_decode(r"\u00zz"), r"\u00zz");
952        // Truncated — backslash kept:
953        assert_eq!(flint_decode(r"\u00"), r"\u00");
954        // Unknown single escapes keep the backslash too:
955        assert_eq!(flint_decode(r"\/"), r"\/");
956    }
957
958    /// dedent/reindent: the common leading-TAB prefix strips and
959    /// restores (only non-empty lines reindent; no-indent text is a
960    /// no-op with an empty prefix).
961    #[test]
962    fn dedent_reindent_inverse_on_tab_indented_scripts() {
963        let script = "\t\tfor i in range(3):\n\t\t\tprint i\n\t\tprint 'end'";
964        let (dedented, prefix) = dedent(script);
965        assert_eq!(dedented, "for i in range(3):\n\tprint i\nprint 'end'");
966        assert_eq!(prefix, "\t\t");
967        assert_eq!(reindent(&dedented, &prefix), script);
968
969        // No common indent: unchanged, empty prefix.
970        let flat = "print('x')\nprint('y')";
971        let (same, empty) = dedent(flat);
972        assert_eq!((same.as_str(), empty.as_str()), (flat, ""));
973        assert_eq!(reindent(&same, &empty), flat);
974
975        // Empty-string edge.
976        assert_eq!(dedent(""), (String::new(), String::new()));
977
978        // Trailing newline: the empty last line stays empty.
979        let trailing = "\tdo()\n";
980        let (dedented, prefix) = dedent(trailing);
981        assert_eq!((dedented.as_str(), prefix.as_str()), ("do()\n", "\t"));
982        assert_eq!(reindent(&dedented, &prefix), trailing);
983    }
984
985    /// SCRIPT_KEYS is the ignition-nvim list, all ten, in order.
986    #[test]
987    fn script_keys_match_ignition_nvim() {
988        assert_eq!(
989            SCRIPT_KEYS,
990            [
991                "script",
992                "code",
993                "eventScript",
994                "transform",
995                "onActionPerformed",
996                "onChange",
997                "onStartup",
998                "onShutdown",
999                "expression",
1000            ]
1001        );
1002    }
1003
1004    // ---- decode_member / encode_member ---------------------------------
1005
1006    /// A live-shaped view member: two embedded scripts at different
1007    /// depths, an expression value under a SCRIPT_KEY that must PASS
1008    /// THROUGH, and plain text fields.
1009    const VIEW_JSON: &str = r#"{
1010  "scope": "G",
1011  "children": [
1012    {
1013      "type": "ia.display.label",
1014      "meta": {
1015        "name": "lbl"
1016      },
1017      "props": {
1018        "text": "plain <>&=' text"
1019      },
1020      "eventScripts": {
1021        "actionPerformed": {
1022          "config": {
1023            "script": "\tprint \u0027clicked\u0027\n\tprint \u0027done \u003c\u003e\u0026\u003d\u0027"
1024          }
1025        }
1026      }
1027    },
1028    {
1029      "type": "ia.chart",
1030      "transform": {
1031        "script": "\t\tfor i in range(3):\n\t\t\tprint i\n\t\tprint \u0027end\u0027"
1032      },
1033      "props": {
1034        "expression": "toStr({view.args.x} * 2)"
1035      }
1036    }
1037  ]
1038}"#;
1039
1040    #[test]
1041    fn decode_member_finds_nested_scripts_and_passes_expressions_through() {
1042        let decoded = decode_member(VIEW_JSON.as_bytes(), "c/views/Dashboard/view.json")
1043            .expect("two scripts decode");
1044        assert_eq!(decoded.entries.len(), 2, "the expression does not decode");
1045        assert_eq!(
1046            decoded.entries[0].entry.pointer,
1047            "/children/0/eventScripts/actionPerformed/config/script"
1048        );
1049        assert_eq!(decoded.entries[0].entry.sidecar, "view.json.1.py");
1050        assert_eq!(decoded.entries[0].entry.indent_prefix, "\t");
1051        assert_eq!(
1052            decoded.entries[0].text,
1053            "print 'clicked'\nprint 'done <>&='"
1054        );
1055        assert_eq!(
1056            decoded.entries[1].entry.pointer,
1057            "/children/1/transform/script"
1058        );
1059        assert_eq!(decoded.entries[1].entry.sidecar, "view.json.2.py");
1060        assert_eq!(decoded.entries[1].entry.indent_prefix, "\t\t");
1061
1062        // A member without scripts decodes to None.
1063        assert!(decode_member(br#"{"title":"T"}"#, "project.json").is_none());
1064        // A member that does not scan decodes to None (rides verbatim).
1065        assert!(decode_member(b"<<<not json>>>", "broken.json").is_none());
1066        // A script-python member (plain text, not JSON) is None.
1067        assert!(decode_member(b"print('plain')\n", "ignition/x/scratch").is_none());
1068    }
1069
1070    /// THE file-level sacred invariant, member edition: unedited
1071    /// sidecars re-encode BYTE-IDENTICAL; editing one sidecar changes
1072    /// only that span; a missing sidecar keeps the current value.
1073    #[test]
1074    fn encode_member_round_trips_bytes_and_splices_edits() {
1075        let decoded =
1076            decode_member(VIEW_JSON.as_bytes(), "c/views/Dashboard/view.json").expect("decodes");
1077        let entries: Vec<ManifestEntry> = decoded.entries.iter().map(|e| e.entry.clone()).collect();
1078        let texts: HashMap<String, String> = decoded
1079            .entries
1080            .iter()
1081            .map(|e| (e.entry.sidecar.clone(), e.text.clone()))
1082            .collect();
1083
1084        // Unedited: byte-identical.
1085        let out = encode_member(VIEW_JSON.as_bytes(), &entries, &texts).expect("encodes");
1086        assert_eq!(out, VIEW_JSON.as_bytes(), "unedited round-trip is exact");
1087
1088        // Edit sidecar 1 only: the re-encoded member differs, parses,
1089        // and carries the new script text at the SAME pointer.
1090        let mut edited = texts.clone();
1091        edited.insert(
1092            "view.json.1.py".to_string(),
1093            "print 'edited'\nprint 'twice'".to_string(),
1094        );
1095        let out = encode_member(VIEW_JSON.as_bytes(), &entries, &edited).expect("encodes");
1096        let parsed: serde_json::Value = serde_json::from_slice(&out).expect("still JSON");
1097        assert_eq!(
1098            parsed["children"][0]["eventScripts"]["actionPerformed"]["config"]["script"],
1099            "\tprint 'edited'\n\tprint 'twice'",
1100            "the edited text re-dents under the recorded prefix"
1101        );
1102        assert_eq!(
1103            parsed["children"][1]["transform"]["script"],
1104            "\t\tfor i in range(3):\n\t\t\tprint i\n\t\tprint 'end'",
1105            "the unedited sibling rides byte-equal content"
1106        );
1107
1108        // Missing sidecar: the value is preserved (never dropped).
1109        let mut missing = texts.clone();
1110        missing.remove("view.json.2.py");
1111        let out = encode_member(VIEW_JSON.as_bytes(), &entries, &missing).expect("encodes");
1112        assert_eq!(
1113            out,
1114            VIEW_JSON.as_bytes(),
1115            "a missing sidecar keeps the value"
1116        );
1117    }
1118
1119    /// A member whose manifest pointer no longer resolves (the user
1120    /// deleted the value) keeps its current bytes; a member that no
1121    /// longer parses refuses usage-class.
1122    #[test]
1123    fn encode_member_handles_unresolvable_pointers_and_broken_json() {
1124        let entries = vec![ManifestEntry {
1125            pointer: "/gone/script".to_string(),
1126            sidecar: "view.json.1.py".to_string(),
1127            indent_prefix: String::new(),
1128        }];
1129        let mut texts = HashMap::new();
1130        texts.insert("view.json.1.py".to_string(), "x".to_string());
1131        let out = encode_member(VIEW_JSON.as_bytes(), &entries, &texts).expect("encodes");
1132        assert_eq!(
1133            out,
1134            VIEW_JSON.as_bytes(),
1135            "an unresolvable pointer is a no-op"
1136        );
1137
1138        let err = encode_member(b"<<<broken>>>", &entries, &texts).expect_err("must refuse");
1139        assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
1140        assert_eq!(err.exit_code(), 2);
1141    }
1142
1143    // ---- Tree wrappers ---------------------------------------------------
1144
1145    /// Build an in-test export zip (the resources.rs fixture style).
1146    fn fixture_zip(members: &[(&str, &[u8])]) -> Vec<u8> {
1147        let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
1148        let options = zip::write::SimpleFileOptions::default()
1149            .compression_method(zip::CompressionMethod::Deflated);
1150        for (name, bytes) in members {
1151            writer.start_file(*name, options).expect("member starts");
1152            writer.write_all(bytes).expect("member writes");
1153        }
1154        writer.finish().expect("zip finalizes").into_inner()
1155    }
1156
1157    /// The full tree round-trip: decode → encode with no edits →
1158    /// every file member byte-identical (the contract's core, here
1159    /// at unit weight; the dedicated contract file carries the full
1160    /// fixture matrix).
1161    #[test]
1162    fn decode_encode_tree_round_trips_unedited_members() {
1163        let zip = fixture_zip(&[
1164            ("project.json", br#"{"title":"T"}"#.as_slice()),
1165            (
1166                "c/resources/views/Dashboard/view.json",
1167                VIEW_JSON.as_bytes(),
1168            ),
1169            (
1170                "c/resources/views/Dashboard/resource.json",
1171                br#"{"scope":"G","version":1,"files":["view.json"]}"#.as_slice(),
1172            ),
1173            ("ignition/resources/scratch", b"print('plain')".as_slice()),
1174        ]);
1175        let dir = tempfile::tempdir().expect("tempdir");
1176        let scripts = decode_export_tree(&zip, dir.path()).expect("decodes");
1177        assert_eq!(scripts, 2);
1178        assert!(dir.path().join(MANIFEST_NAME).is_file());
1179        assert!(
1180            dir.path()
1181                .join("c/resources/views/Dashboard/view.json.1.py")
1182                .is_file()
1183        );
1184
1185        let re_zipped = encode_export_tree(dir.path()).expect("re-encodes");
1186        let mut original = open_archive(&zip).expect("reopen");
1187        let mut re = open_archive(&re_zipped).expect("open re-zip");
1188        assert_eq!(re.len(), original.len(), "same member count");
1189        for index in 0..original.len() {
1190            let name = original.by_index(index).expect("orig").name().to_string();
1191            let mut orig_bytes = Vec::new();
1192            original
1193                .by_index(index)
1194                .expect("orig")
1195                .read_to_end(&mut orig_bytes)
1196                .expect("read");
1197            let mut re_file = re.by_name(&name).expect("member present");
1198            let mut re_bytes = Vec::new();
1199            re_file.read_to_end(&mut re_bytes).expect("read");
1200            assert_eq!(re_bytes, orig_bytes, "{name} byte-identical unedited");
1201        }
1202        // The manifest never rides the re-zip.
1203        assert!(re.by_name(MANIFEST_NAME).is_err());
1204        assert_eq!(count_file_members(&re_zipped).expect("counts"), 4);
1205    }
1206
1207    /// A tree edit flows through: editing one sidecar changes only
1208    /// that member; the others stay byte-identical.
1209    #[test]
1210    fn tree_edit_splices_only_the_edited_member() {
1211        let zip = fixture_zip(&[
1212            ("project.json", br#"{"title":"T"}"#.as_slice()),
1213            (
1214                "c/resources/views/Dashboard/view.json",
1215                VIEW_JSON.as_bytes(),
1216            ),
1217            ("ignition/resources/scratch", b"print('plain')".as_slice()),
1218        ]);
1219        let dir = tempfile::tempdir().expect("tempdir");
1220        decode_export_tree(&zip, dir.path()).expect("decodes");
1221        let sidecar = dir
1222            .path()
1223            .join("c/resources/views/Dashboard/view.json.1.py");
1224        std::fs::write(&sidecar, "print 'edited alone'").expect("edit sidecar");
1225        let re_zipped = encode_export_tree(dir.path()).expect("re-encodes");
1226
1227        let mut re = open_archive(&re_zipped).expect("open");
1228        let mut view = Vec::new();
1229        re.by_name("c/resources/views/Dashboard/view.json")
1230            .expect("view")
1231            .read_to_end(&mut view)
1232            .expect("read");
1233        let parsed: serde_json::Value = serde_json::from_slice(&view).expect("json");
1234        assert_eq!(
1235            parsed["children"][0]["eventScripts"]["actionPerformed"]["config"]["script"],
1236            "\tprint 'edited alone'"
1237        );
1238        let mut scratch = Vec::new();
1239        re.by_name("ignition/resources/scratch")
1240            .expect("scratch")
1241            .read_to_end(&mut scratch)
1242            .expect("read");
1243        assert_eq!(scratch, b"print('plain')");
1244    }
1245
1246    /// encode_export_tree refuses a non-decoded directory
1247    /// usage-class; decode refuses an export already carrying a
1248    /// manifest member.
1249    #[test]
1250    fn tree_wrapper_error_shapes() {
1251        let plain = tempfile::tempdir().expect("tempdir");
1252        let err = encode_export_tree(plain.path()).expect_err("no manifest refuses");
1253        assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
1254        assert_eq!(err.exit_code(), 2);
1255        assert!(err.to_string().contains("not a decoded export directory"));
1256
1257        let zip = fixture_zip(&[(MANIFEST_NAME, b"{}".as_slice())]);
1258        let dir = tempfile::tempdir().expect("tempdir");
1259        let err = decode_export_tree(&zip, dir.path()).expect_err("shadow refuses");
1260        assert!(matches!(err, CoreError::Internal(_)), "{err}");
1261    }
1262}