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 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
664fn 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
671fn rewrite_options() -> zip::write::SimpleFileOptions {
674 zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated)
675}
676
677pub 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 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
767pub 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 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 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; }
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; }
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
843fn 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 #[test]
876 fn encode_decode_round_trip_is_sacred() {
877 let corpus = [
878 "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 "\\\\t is not a tab",
893 "\ttab is a tab",
894 "if x < 3 && y > 2:\n\tprint 'it\\'s <>&='\n\treturn {value}",
896 "print(\"quoted \\\"inside\\\"\")",
897 "\tfor i in range(10):\n\t\tif i & 1 == 0:\n\t\t\tprint i, '<', '=', '>'",
899 "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 assert_eq!(
909 flint_encode(&flint_decode(&encoded)),
910 encoded,
911 "encode(decode(x)) == x for {encoded:?}"
912 );
913 }
914 }
915
916 #[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 assert_eq!(flint_decode(r"\\\t"), "\\\t");
924 }
925
926 #[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 assert_eq!(flint_decode(r"\u0041"), r"\u0041");
937 assert_eq!(flint_decode(r"\u00zz"), r"\u00zz");
938 assert_eq!(flint_decode(r"\u00"), r"\u00");
940 assert_eq!(flint_decode(r"\/"), r"\/");
942 }
943
944 #[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 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 assert_eq!(dedent(""), (String::new(), String::new()));
963
964 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 #[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 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 assert!(decode_member(br#"{"title":"T"}"#, "project.json").is_none());
1050 assert!(decode_member(b"<<<not json>>>", "broken.json").is_none());
1052 assert!(decode_member(b"print('plain')\n", "ignition/x/scratch").is_none());
1054 }
1055
1056 #[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 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 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 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 #[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 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 #[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 assert!(re.by_name(MANIFEST_NAME).is_err());
1190 assert_eq!(count_file_members(&re_zipped).expect("counts"), 4);
1191 }
1192
1193 #[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 #[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}