⎇ 7fb2ad3b1ebba21ac0dd997f8e282f67c847888b
crates/modules/nakui/core/src/executor.rs
Historia
Quién tocó qué
crudo
fix(nakui-core): schema_bundle_hash usa bytes reales del schema, no del bundle
1use serde_json::{Value, json};
2use sha2::{Digest, Sha256};
3use std::collections::{BTreeMap, HashMap, HashSet};
4use std::path::{Path, PathBuf};
5use thiserror::Error;
6use uuid::Uuid;
7
8use crate::delta::{FieldOp, simulate_on};
9use crate::graph::{GraphError, ManifestGraph};
10use crate::nickel_validator::{self, NickelError};
11use crate::manifest::{ConserveRule, Manifest, ManifestError, MorphismSpec, ValidationError};
12use crate::rhai_executor::{RhaiError, RhaiExecutor};
13use crate::store::{Store, StoreError};
14
15#[derive(Debug, Error)]
16pub enum ExecError {
17 #[error("morphism `{0}` not in manifest")]
18 UnknownMorphism(String),
19 #[error("missing input role `{role}` for morphism `{morphism}`")]
20 MissingInput { morphism: String, role: String },
21 #[error("duplicate input id {id} bound to roles `{role_a}` and `{role_b}`")]
22 DuplicateInputId {
23 id: Uuid,
24 role_a: String,
25 role_b: String,
26 },
27 #[error("entity `{0}` id `{1}` not found in store")]
28 EntityMissing(String, Uuid),
29 #[error(
30 "capability violation: morphism `{morphism}` produced op on `{token}` not in writes={declared:?}"
31 )]
32 CapabilityViolation {
33 morphism: String,
34 token: String,
35 declared: Vec<String>,
36 },
37 #[error(
38 "conservation violated: Σ Δ {entity}.{field} where {group_by} = {group:?} = {total} (expected 0)"
39 )]
40 ConservationViolation {
41 entity: String,
42 field: String,
43 group_by: String,
44 group: String,
45 total: i128,
46 },
47 #[error("conservation rule {entity}.{field}: {message}")]
48 ConservationMalformed {
49 entity: String,
50 field: String,
51 message: String,
52 },
53 #[error("schema pre-check failed on `{role}` ({entity}): {source}")]
54 SchemaPre {
55 role: String,
56 entity: String,
57 #[source]
58 source: NickelError,
59 },
60 #[error("schema post-check failed on `{role}` ({entity}): {source}")]
61 SchemaPost {
62 role: String,
63 entity: String,
64 #[source]
65 source: NickelError,
66 },
67 #[error("schema post-check failed on created {entity} {id}: {source}")]
68 SchemaPostCreate {
69 entity: String,
70 id: Uuid,
71 #[source]
72 source: NickelError,
73 },
74 #[error("rhai: {0}")]
75 Rhai(#[from] RhaiError),
76 #[error("store: {0}")]
77 Store(#[from] StoreError),
78 #[error("manifest: {0}")]
79 Manifest(#[from] ManifestError),
80 #[error("manifest validation: {0}")]
81 ManifestValidation(#[from] ValidationError),
82 #[error("manifest graph: {0}")]
83 Graph(#[from] GraphError),
84 #[error("io: {0}")]
85 Io(#[from] std::io::Error),
86}
87
88pub struct Executor {
89 pub manifest: Manifest,
90 pub graph: ManifestGraph,
91 pub module_dir: PathBuf,
92 pub schema_path: PathBuf,
93 pub rhai: RhaiExecutor,
94 /// `true` when `schema_path` is a tempfile bundle created by
95 /// `load_module`; Drop removes it. `false` for inline-built executors
96 /// that point at a real schema file owned by the caller (tests).
97 pub owned_bundle: bool,
98 /// Per-morphism `schema_hash`: SHA-256 of (Nickel bundle + manifest
99 /// spec + rhai script bytes), computed once at load. The hash es
100 /// el determinism contract para evolución de schemas —
101 /// `verify_log` lo usa para rechazar logs cuyos entries se
102 /// produjeron bajo reglas distintas.
103 pub schema_hashes: HashMap<String, [u8; 32]>,
104 /// Module-wide bundle hash: SHA-256 de los bytes del bundle Nickel.
105 /// Stamped onto every `LogEntry::Seed` via `seed_and_log` so
106 /// `verify_log` can flag seeds whose entity schemas have evolved
107 /// since they were logged. Coarser than `schema_hashes` (any
108 /// schema.k edit moves it, even one that doesn't affect the seeded
109 /// entity) but cheap and conservative — false positives over false
110 /// negatives, like the morphism hash.
111 pub schema_bundle_hash: [u8; 32],
112}
113
114impl Drop for Executor {
115 fn drop(&mut self) {
116 if self.owned_bundle {
117 let _ = std::fs::remove_file(&self.schema_path);
118 }
119 }
120}
121
122/// One row of the bound-inputs map. Holds both `role` and `entity` so the
123/// capability check can verify a Set's `path.entity` matches the role's
124/// declared entity (catches uuid-collision and lazy scripts).
125#[derive(Debug, Clone)]
126struct InputBinding {
127 role: String,
128 entity: String,
129}
130
131impl Executor {
132 pub fn load_module(module_dir: impl Into<PathBuf>) -> Result<Self, ExecError> {
133 let module_dir = module_dir.into();
134 let manifest = Manifest::load(&module_dir.join("nsmc.json"))?;
135 manifest.validate(&module_dir)?;
136 let graph = ManifestGraph::build(&manifest)?;
137 let schema_path = build_schema_bundle(&module_dir, &manifest.effective_schemas())?;
138
139 // Hash insumos = bytes reales de cada schema file, NO el bundle
140 // (que sólo contiene `import "/abs/path"` y no cambia cuando el
141 // archivo apuntado se mueve). Sin esto, el bundle hash quedaba
142 // pegado y la versión del seed nunca detectaba ediciones de
143 // schema. Ver `verify_log_rejects_seed_after_schema_changes`.
144 let schema_bundle_bytes = read_schema_files_concat(&module_dir, &manifest.effective_schemas())?;
145 let schema_bundle_hash = compute_schema_bundle_hash(&schema_bundle_bytes);
146 let mut schema_hashes = HashMap::with_capacity(manifest.morphisms.len());
147 for spec in &manifest.morphisms {
148 let script_path = module_dir.join(&spec.script);
149 let hash = compute_morphism_schema_hash(&schema_bundle_bytes, spec, &script_path)?;
150 schema_hashes.insert(spec.name.clone(), hash);
151 }
152
153 Ok(Self {
154 manifest,
155 graph,
156 module_dir,
157 schema_path,
158 rhai: RhaiExecutor::new_sandboxed(),
159 owned_bundle: true,
160 schema_hashes,
161 schema_bundle_hash,
162 })
163 }
164
165 /// Hash for the named morphism in the currently loaded module. `None`
166 /// if no such morphism is declared. Used by `verify_log` to enforce
167 /// the schema-version contract.
168 pub fn schema_hash(&self, morphism: &str) -> Option<[u8; 32]> {
169 self.schema_hashes.get(morphism).copied()
170 }
171
172 /// Single 32-byte hash representing the entire module's schema:
173 /// every morphism's hash, in canonical name order, framed and
174 /// chained. Snapshots pin this so a snapshot taken under bundle A
175 /// can be detected when later loaded against bundle B.
176 pub fn module_schema_hash(&self) -> [u8; 32] {
177 let mut entries: Vec<(&String, &[u8; 32])> = self.schema_hashes.iter().collect();
178 entries.sort_by_key(|(name, _)| name.as_str().to_owned());
179 let mut hasher = Sha256::new();
180 hasher.update(b"nakui-module-v1\0");
181 for (name, hash) in entries {
182 hasher.update((name.len() as u64).to_le_bytes());
183 hasher.update(name.as_bytes());
184 hasher.update(hash);
185 }
186 hasher.finalize().into()
187 }
188
189 /// Compute the ops for a morphism without mutating the store.
190 ///
191 /// Pipeline:
192 /// 1. Resolve manifest spec; bind caller's role->id to spec inputs.
193 /// 2. Reject duplicate ids across roles.
194 /// 3. Load every input entity; KCL pre-check each.
195 /// 4. Run the Rhai script with `{ states, ids, params }`.
196 /// 5. Capability check: every Set targets a tracked id whose entity
197 /// matches the role's declared entity, and produces a `<role>.<field>`
198 /// token in `writes`; Create/Delete produce `<entity>` tokens.
199 /// 6. Delta-level invariants (conservation rules).
200 /// 7. Per-input KCL post-check (skipped for inputs that the ops Delete).
201 /// 8. KCL-validate every Created record against its entity schema.
202 /// 9. Pre-apply check: store.apply_dry_run guarantees apply will land.
203 ///
204 /// On `Ok`, the returned ops are *contractually applicable* — caller can
205 /// log first and then apply, knowing apply will succeed barring transient
206 /// backend faults.
207 pub fn compute<S: Store>(
208 &self,
209 store: &S,
210 morphism_name: &str,
211 inputs: &[(&str, Uuid)],
212 params: Value,
213 ) -> Result<Vec<FieldOp>, ExecError> {
214 let spec: &MorphismSpec = self
215 .manifest
216 .morphism(morphism_name)
217 .ok_or_else(|| ExecError::UnknownMorphism(morphism_name.to_string()))?;
218
219 // 1. Bind inputs.
220 let inputs_map: BTreeMap<String, Uuid> = inputs
221 .iter()
222 .map(|(role, id)| (role.to_string(), *id))
223 .collect();
224 for spec_in in &spec.inputs {
225 if !inputs_map.contains_key(&spec_in.role) {
226 return Err(ExecError::MissingInput {
227 morphism: morphism_name.to_string(),
228 role: spec_in.role.clone(),
229 });
230 }
231 }
232
233 // 2. Build id -> binding (role + entity), rejecting duplicates.
234 let mut id_to_input: HashMap<Uuid, InputBinding> = HashMap::new();
235 for spec_in in &spec.inputs {
236 let id = inputs_map[&spec_in.role];
237 if let Some(other) = id_to_input.get(&id) {
238 return Err(ExecError::DuplicateInputId {
239 id,
240 role_a: other.role.clone(),
241 role_b: spec_in.role.clone(),
242 });
243 }
244 id_to_input.insert(
245 id,
246 InputBinding {
247 role: spec_in.role.clone(),
248 entity: spec_in.entity.clone(),
249 },
250 );
251 }
252
253 // 3. Load + pre-check every input.
254 let mut loaded: BTreeMap<String, Value> = BTreeMap::new();
255 let mut id_strings: BTreeMap<String, String> = BTreeMap::new();
256 for spec_in in &spec.inputs {
257 let id = inputs_map[&spec_in.role];
258 let state = store
259 .load(&spec_in.entity, id)
260 .ok_or_else(|| ExecError::EntityMissing(spec_in.entity.clone(), id))?;
261 self.validate_entity(&spec_in.entity, &state)
262 .map_err(|e| ExecError::SchemaPre {
263 role: spec_in.role.clone(),
264 entity: spec_in.entity.clone(),
265 source: e,
266 })?;
267 loaded.insert(spec_in.role.clone(), state);
268 id_strings.insert(spec_in.role.clone(), id.to_string());
269 }
270
271 // 4. Rhai.
272 let script_path = self.module_dir.join(&spec.script);
273 let input = json!({
274 "states": loaded,
275 "ids": id_strings,
276 "params": params,
277 });
278 let ops = self.rhai.run(&script_path, input)?;
279
280 // 5. Capability check.
281 let declared: HashSet<&str> = spec.writes.iter().map(String::as_str).collect();
282 for op in &ops {
283 let token = match op {
284 // Set y Clear comparten el mismo token shape: ambos
285 // mutan un field específico de un record tracked.
286 FieldOp::Set { path, .. } | FieldOp::Clear { path } => {
287 match id_to_input.get(&path.id) {
288 Some(binding) if binding.entity == path.entity => {
289 format!("{}.{}", binding.role, path.field)
290 }
291 Some(_) => {
292 return Err(ExecError::CapabilityViolation {
293 morphism: morphism_name.to_string(),
294 token: format!(
295 "<entity-mismatch>.{}.{}",
296 path.entity, path.field
297 ),
298 declared: spec.writes.clone(),
299 });
300 }
301 None => {
302 return Err(ExecError::CapabilityViolation {
303 morphism: morphism_name.to_string(),
304 token: format!(
305 "<untracked id>.{}.{}",
306 path.entity, path.field
307 ),
308 declared: spec.writes.clone(),
309 });
310 }
311 }
312 }
313 FieldOp::Create { entity, .. } => entity.clone(),
314 FieldOp::Delete { entity, .. } => entity.clone(),
315 };
316 if !declared.contains(token.as_str()) {
317 return Err(ExecError::CapabilityViolation {
318 morphism: morphism_name.to_string(),
319 token,
320 declared: spec.writes.clone(),
321 });
322 }
323 }
324
325 // 6. Conservation invariants.
326 for rule in &spec.invariants.conserve {
327 check_conservation(rule, &loaded, &id_to_input, &ops)?;
328 }
329
330 // 7. Per-input KCL post-check; skip Deleted inputs.
331 for spec_in in &spec.inputs {
332 let id = inputs_map[&spec_in.role];
333 if let Some(new_state) =
334 simulate_on(&loaded[&spec_in.role], &spec_in.entity, id, &ops)
335 {
336 self.validate_entity(&spec_in.entity, &new_state)
337 .map_err(|e| ExecError::SchemaPost {
338 role: spec_in.role.clone(),
339 entity: spec_in.entity.clone(),
340 source: e,
341 })?;
342 }
343 }
344
345 // 8. Validate every Created record against its entity schema.
346 for op in &ops {
347 if let FieldOp::Create { entity, id, data } = op {
348 self.validate_entity(entity, data)
349 .map_err(|e| ExecError::SchemaPostCreate {
350 entity: entity.clone(),
351 id: *id,
352 source: e,
353 })?;
354 }
355 }
356
357 // 9. Pre-apply check: structural compatibility with current store state.
358 store.apply_dry_run(&ops)?;
359
360 Ok(ops)
361 }
362
363 /// compute + apply, for callers that don't need event logging.
364 pub fn run<S: Store>(
365 &self,
366 store: &mut S,
367 morphism_name: &str,
368 inputs: &[(&str, Uuid)],
369 params: Value,
370 ) -> Result<Vec<FieldOp>, ExecError> {
371 let ops = self.compute(store, morphism_name, inputs, params)?;
372 store.apply(&ops)?;
373 Ok(ops)
374 }
375
376 fn validate_entity(&self, entity: &str, state: &Value) -> Result<(), NickelError> {
377 nickel_validator::vet(&self.schema_path, state, entity)
378 }
379}
380
381/// Module-wide hash of the Nickel bundle bytes. Stamped on
382/// `LogEntry::Seed` entries (which don't run through any morphism, so
383/// `compute_morphism_schema_hash` doesn't apply). Bumped by any byte
384/// change in any schema file the manifest exposes — coarser than a
385/// per-entity hash would be, but doesn't require Nickel parsing.
386fn compute_schema_bundle_hash(schema_bundle_bytes: &[u8]) -> [u8; 32] {
387 let mut hasher = Sha256::new();
388 hasher.update(b"nakui-bundle-v1\0");
389 hasher.update((schema_bundle_bytes.len() as u64).to_le_bytes());
390 hasher.update(schema_bundle_bytes);
391 hasher.finalize().into()
392}
393
394/// Per-morphism schema hash. SHA-256 with length-prefixed framing over
395/// three inputs that together determine the morphism's deterministic
396/// behaviour: the KCL schema bundle (entity shapes + invariants), the
397/// manifest spec (writes, conserve, depends_on, etc.), and a
398/// **normalized** form of the Rhai script — comments stripped and
399/// whitespace runs collapsed, with string literals preserved exactly.
400///
401/// The normalization makes the hash invariant to cosmetic edits (a
402/// developer adding a `// TODO` doesn't invalidate the log) without
403/// missing real behavioural changes. The framing tag is bumped to
404/// `nakui-schema-v2` so logs hashed under v1 (raw bytes) cleanly fail
405/// SchemaMismatch on upgrade rather than silently divergence.
406fn compute_morphism_schema_hash(
407 schema_bundle_bytes: &[u8],
408 spec: &MorphismSpec,
409 script_path: &Path,
410) -> std::io::Result<[u8; 32]> {
411 let script_bytes = std::fs::read(script_path)?;
412 let script_source = std::str::from_utf8(&script_bytes).map_err(|e| {
413 std::io::Error::new(
414 std::io::ErrorKind::InvalidData,
415 format!("script {} is not valid UTF-8: {}", script_path.display(), e),
416 )
417 })?;
418 let normalized_script = normalize_rhai_source(script_source);
419 let spec_json = serde_json::to_vec(spec).expect("MorphismSpec serializes");
420
421 let mut hasher = Sha256::new();
422 hasher.update(b"nakui-schema-v2\0");
423 hasher.update(b"schema:");
424 hasher.update((schema_bundle_bytes.len() as u64).to_le_bytes());
425 hasher.update(schema_bundle_bytes);
426 hasher.update(b"spec:");
427 hasher.update((spec_json.len() as u64).to_le_bytes());
428 hasher.update(&spec_json);
429 hasher.update(b"script:");
430 hasher.update((normalized_script.len() as u64).to_le_bytes());
431 hasher.update(normalized_script.as_bytes());
432 Ok(hasher.finalize().into())
433}
434
435/// Strip line/block comments and collapse whitespace runs in a Rhai
436/// source string. Preserves string literals exactly. Used to make the
437/// schema hash invariant to cosmetic edits.
438///
439/// Limitations:
440/// - Doesn't handle backtick template literals (Rhai 1.x interp
441/// strings). If the modules ever start using them, the normalizer
442/// must be extended; until then it's not a concern for the
443/// production scripts in `modules/`.
444/// - Doesn't handle nested block comments — Rhai itself doesn't
445/// either.
446pub fn normalize_rhai_source(src: &str) -> String {
447 let mut out = String::with_capacity(src.len());
448 let mut chars = src.chars().peekable();
449 let mut prev_was_space = true; // strip leading whitespace
450
451 while let Some(c) = chars.next() {
452 // Line comment: //...\n
453 if c == '/' && chars.peek() == Some(&'/') {
454 chars.next();
455 while let Some(&n) = chars.peek() {
456 if n == '\n' {
457 break;
458 }
459 chars.next();
460 }
461 continue;
462 }
463 // Block comment: /* ... */
464 if c == '/' && chars.peek() == Some(&'*') {
465 chars.next();
466 let mut prev = '\0';
467 while let Some(n) = chars.next() {
468 if prev == '*' && n == '/' {
469 break;
470 }
471 prev = n;
472 }
473 continue;
474 }
475 // String literal: copy verbatim including escape sequences.
476 if c == '"' {
477 out.push('"');
478 while let Some(n) = chars.next() {
479 if n == '\\' {
480 out.push('\\');
481 if let Some(esc) = chars.next() {
482 out.push(esc);
483 }
484 } else if n == '"' {
485 out.push('"');
486 break;
487 } else {
488 out.push(n);
489 }
490 }
491 prev_was_space = false;
492 continue;
493 }
494 // Whitespace run → single space (or nothing if at edge).
495 if c.is_whitespace() {
496 if !prev_was_space {
497 out.push(' ');
498 prev_was_space = true;
499 }
500 continue;
501 }
502 // Regular character.
503 out.push(c);
504 prev_was_space = false;
505 }
506
507 if out.ends_with(' ') {
508 out.pop();
509 }
510 out
511}
512
513/// Construye un bundle Nickel: en lugar de concatenar contenidos
514/// (cada `.ncl` es una expresión record completa, no juntable como
515/// texto plano), emite un archivo que mergea via `&` los imports.
516///
517/// El operador `&` de Nickel mergea records: si las keys son
518/// distintas (que es lo esperado entre schemas de módulos distintos)
519/// el resultado tiene la unión. Si hay colisión, Nickel rebota con
520/// un error claro al evaluar — ya cubierto por `manifest::validate`
521/// que chequea duplicados antes de llegar acá.
522///
523/// Verifica que cada path exista para fallar early con I/O error.
524/// El path en el `import "..."` queda absoluto (resuelto desde
525/// `module_dir`) para que el evaluator lo encuentre desde el
526/// tempdir.
527/// Concatena los bytes de cada schema file declarado, en orden y con
528/// framing `\0NCL:<name>\0`, para alimentar el bundle hash con el
529/// contenido real de los schemas. El bundle compilado por
530/// `build_schema_bundle` sólo contiene imports de paths absolutos —
531/// inestables entre runs y, peor, invariantes a ediciones del archivo
532/// apuntado. Esta función entrega bytes que sí mueven el hash cuando
533/// cambia el schema en disco.
534fn read_schema_files_concat(
535 module_dir: &std::path::Path,
536 schemas: &[String],
537) -> std::io::Result<Vec<u8>> {
538 let mut out = Vec::new();
539 for s in schemas {
540 out.extend_from_slice(b"\0NCL:");
541 out.extend_from_slice(s.as_bytes());
542 out.push(0);
543 let bytes = std::fs::read(module_dir.join(s))?;
544 out.extend_from_slice(&bytes);
545 }
546 Ok(out)
547}
548
549fn build_schema_bundle(
550 module_dir: &std::path::Path,
551 schemas: &[String],
552) -> std::io::Result<PathBuf> {
553 let mut imports: Vec<String> = Vec::with_capacity(schemas.len());
554 for s in schemas {
555 let p = module_dir.join(s);
556 // Verificar existencia + canonicalize para path absoluto
557 // estable (evita que cwd movimiento rompa el bundle).
558 let abs = std::fs::canonicalize(&p)?;
559 let abs_str = abs.display().to_string();
560 let escaped = abs_str.replace('\\', "\\\\").replace('"', "\\\"");
561 imports.push(format!("(import \"{escaped}\")"));
562 }
563 let combined = if imports.is_empty() {
564 // Bundle vacío = record vacío. Cualquier validación contra
565 // un entity rebota con "field not found" — comportamiento
566 // razonable para un módulo sin schemas declarados.
567 "{}".to_string()
568 } else {
569 imports.join(" & ")
570 };
571 let bundle = std::env::temp_dir().join(format!("nakui_schema_{}.ncl", Uuid::new_v4()));
572 std::fs::write(&bundle, combined)?;
573 Ok(bundle)
574}
575
576fn check_conservation(
577 rule: &ConserveRule,
578 loaded: &BTreeMap<String, Value>,
579 id_to_input: &HashMap<Uuid, InputBinding>,
580 ops: &[FieldOp],
581) -> Result<(), ExecError> {
582 let mut delta_by_group: HashMap<String, i128> = HashMap::new();
583
584 for op in ops {
585 if let FieldOp::Set { path, value } = op {
586 if path.entity != rule.entity || path.field != rule.field {
587 continue;
588 }
589 let binding = id_to_input
590 .get(&path.id)
591 .filter(|b| b.entity == path.entity)
592 .ok_or_else(|| ExecError::ConservationMalformed {
593 entity: rule.entity.clone(),
594 field: rule.field.clone(),
595 message: format!(
596 "Set on id {} with entity {} cannot be reconciled to a tracked input",
597 path.id, path.entity
598 ),
599 })?;
600 let old_state = &loaded[&binding.role];
601 let old_val =
602 old_state
603 .get(&rule.field)
604 .and_then(Value::as_i64)
605 .ok_or_else(|| ExecError::ConservationMalformed {
606 entity: rule.entity.clone(),
607 field: rule.field.clone(),
608 message: format!("old value at role `{}` is not i64", binding.role),
609 })?;
610 let new_val =
611 value
612 .as_i64()
613 .ok_or_else(|| ExecError::ConservationMalformed {
614 entity: rule.entity.clone(),
615 field: rule.field.clone(),
616 message: format!("Set value at role `{}` is not i64", binding.role),
617 })?;
618 let group_key = match &rule.group_by {
619 Some(g) => old_state
620 .get(g)
621 .and_then(Value::as_str)
622 .unwrap_or("")
623 .to_string(),
624 None => String::new(),
625 };
626 *delta_by_group.entry(group_key).or_insert(0) +=
627 (new_val as i128) - (old_val as i128);
628 }
629 }
630
631 for (group, total) in &delta_by_group {
632 if *total != 0 {
633 return Err(ExecError::ConservationViolation {
634 entity: rule.entity.clone(),
635 field: rule.field.clone(),
636 group_by: rule.group_by.clone().unwrap_or_else(|| "(global)".into()),
637 group: group.clone(),
638 total: *total,
639 });
640 }
641 }
642 Ok(())
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648
649 #[test]
650 fn normalize_strips_line_and_block_comments() {
651 let src = r#"
652// header comment
653let x = 1; // trailing
654/* block
655 spans lines */
656let y = 2;
657"#;
658 let normalized = normalize_rhai_source(src);
659 assert_eq!(normalized, "let x = 1; let y = 2;");
660 }
661
662 #[test]
663 fn normalize_collapses_whitespace_runs() {
664 let src = "let a =\t\t1;\n\n\n\nlet b = 2;";
665 let normalized = normalize_rhai_source(src);
666 assert_eq!(normalized, "let a = 1; let b = 2;");
667 }
668
669 #[test]
670 fn normalize_preserves_strings_verbatim_including_double_spaces() {
671 // The double space, the // inside, and the escape are preserved
672 // exactly because they're inside a string literal — semantic
673 // content, not cosmetic.
674 let src = r#"let s = "hello // not a comment \"world\"";"#;
675 let normalized = normalize_rhai_source(src);
676 assert_eq!(normalized, r#"let s = "hello // not a comment \"world\"";"#);
677 }
678
679 #[test]
680 fn normalize_is_idempotent() {
681 let src = "// a\nlet x = 1;\n";
682 let n1 = normalize_rhai_source(src);
683 let n2 = normalize_rhai_source(&n1);
684 assert_eq!(n1, n2);
685 }
686
687 #[test]
688 fn normalize_distinguishes_real_changes() {
689 // Adding a new statement is a non-cosmetic change — the
690 // normalized output must reflect it.
691 let a = "let x = 1;";
692 let b = "let x = 1; let y = 2;";
693 assert_ne!(normalize_rhai_source(a), normalize_rhai_source(b));
694
695 // Same for changing a literal value.
696 let c = "let x = 1;";
697 let d = "let x = 2;";
698 assert_ne!(normalize_rhai_source(c), normalize_rhai_source(d));
699 }
700
701 #[test]
702 fn normalize_handles_comment_at_end_without_newline() {
703 let src = "let x = 1; // no trailing newline";
704 let normalized = normalize_rhai_source(src);
705 assert_eq!(normalized, "let x = 1;");
706 }
707
708 #[test]
709 fn normalize_handles_unterminated_block_comment() {
710 // Defensive: if someone writes `/* ...` and forgets to close,
711 // we don't infinite-loop or panic. The trailing content is
712 // discarded, which is fine — Rhai won't parse this either.
713 let src = "let x = 1; /* never ends";
714 let normalized = normalize_rhai_source(src);
715 assert_eq!(normalized, "let x = 1;");
716 }
717}