1use 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
62pub 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
79pub const MANIFEST_NAME: &str = "scripts-manifest.json";
82
83pub fn flint_encode(s: &str) -> String {
90 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
107fn 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
122fn 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
135pub 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 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 out.push('\\');
169 i += 1;
170 } else if let Some(decoded) = escape_char(next) {
171 out.push(decoded);
172 i += 2;
173 } else {
174 out.push('\\');
176 i += 1;
177 }
178 }
179 out
180}
181
182pub 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 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; } else {
226 break;
227 }
228 }
229 rest.to_string()
230 })
231 .collect();
232 (stripped.join("\n"), "\t".repeat(min_tabs))
233}
234
235pub 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
253fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271struct Span {
272 start: usize,
273 end: usize,
274}
275
276#[derive(Debug, Clone)]
280struct Found {
281 pointer: String,
282 span: Span,
283}
284
285fn escape_pointer_token(token: &str) -> String {
287 token.replace('~', "~0").replace('/', "~1")
288}
289
290fn 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 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
329struct 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 fn string(&mut self) -> Result<Span, CoreError> {
357 self.pos += 1; 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 Some(b'\\') => self.pos += 2,
370 Some(_) => self.pos += 1,
371 }
372 }
373 }
374
375 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 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 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; 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; 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
495fn 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
518pub struct ManifestEntry {
519 pub pointer: String,
522 pub sidecar: String,
525 pub indent_prefix: String,
527}
528
529#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
532pub struct Manifest {
533 pub version: u32,
535 pub members: BTreeMap<String, Vec<ManifestEntry>>,
537}
538
539#[derive(Debug, Clone, PartialEq)]
542pub struct DecodedMember {
543 pub member_path: String,
545 pub entries: Vec<DecodedEntry>,
547}
548
549#[derive(Debug, Clone, PartialEq)]
551pub struct DecodedEntry {
552 pub entry: ManifestEntry,
554 pub text: String,
556}
557
558pub 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; };
572 if !looks_like_script(raw_inner) {
573 continue; }
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
594pub 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 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; };
623 let Some(f) = found.iter().find(|f| f.pointer == entry.pointer) else {
624 continue; };
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
646pub fn tree_relative_string(path: &Path) -> String {
656 path.iter()
657 .map(|component| component.to_string_lossy())
658 .collect::<Vec<_>>()
659 .join("/")
660}
661
662pub 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
678fn 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
685fn rewrite_options() -> zip::write::SimpleFileOptions {
688 zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated)
689}
690
691pub 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 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
781pub 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 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 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; }
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; }
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
857fn 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 #[test]
890 fn encode_decode_round_trip_is_sacred() {
891 let corpus = [
892 "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 "\\\\t is not a tab",
907 "\ttab is a tab",
908 "if x < 3 && y > 2:\n\tprint 'it\\'s <>&='\n\treturn {value}",
910 "print(\"quoted \\\"inside\\\"\")",
911 "\tfor i in range(10):\n\t\tif i & 1 == 0:\n\t\t\tprint i, '<', '=', '>'",
913 "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 assert_eq!(
923 flint_encode(&flint_decode(&encoded)),
924 encoded,
925 "encode(decode(x)) == x for {encoded:?}"
926 );
927 }
928 }
929
930 #[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 assert_eq!(flint_decode(r"\\\t"), "\\\t");
938 }
939
940 #[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 assert_eq!(flint_decode(r"\u0041"), r"\u0041");
951 assert_eq!(flint_decode(r"\u00zz"), r"\u00zz");
952 assert_eq!(flint_decode(r"\u00"), r"\u00");
954 assert_eq!(flint_decode(r"\/"), r"\/");
956 }
957
958 #[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 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 assert_eq!(dedent(""), (String::new(), String::new()));
977
978 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 #[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 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 assert!(decode_member(br#"{"title":"T"}"#, "project.json").is_none());
1064 assert!(decode_member(b"<<<not json>>>", "broken.json").is_none());
1066 assert!(decode_member(b"print('plain')\n", "ignition/x/scratch").is_none());
1068 }
1069
1070 #[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 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 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 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 #[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 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 #[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 assert!(re.by_name(MANIFEST_NAME).is_err());
1204 assert_eq!(count_file_members(&re_zipped).expect("counts"), 4);
1205 }
1206
1207 #[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 #[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}