Skip to main content

synx_core/
engine.rs

1//! SYNX Engine — resolves active markers (:random, :calc, :env, :alias, :secret, etc.)
2//! in a parsed SYNX value tree. Only runs in !active mode.
3
4use std::collections::HashMap;
5use std::sync::{Mutex, OnceLock};
6use std::time::{Duration, Instant};
7use crate::calc::safe_calc;
8use crate::parser;
9use crate::rng;
10use crate::value::*;
11
12static SPAM_BUCKETS: OnceLock<Mutex<HashMap<String, Vec<Instant>>>> = OnceLock::new();
13
14/// Maximum expression length accepted by :calc (prevents ReDoS/stack abuse).
15const MAX_CALC_EXPR_LEN: usize = 4096;
16/// Maximum resolved expression length produced by :calc substitutions.
17/// Prevents pathological inputs from growing the expression until OOM.
18const MAX_CALC_RESOLVED_LEN: usize = 64 * 1024;
19/// Maximum file size for :include / :watch reads (10 MB).
20const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
21/// Default maximum include depth.
22const DEFAULT_MAX_INCLUDE_DEPTH: usize = 16;
23/// Maximum object nesting depth for active-mode resolution (prevents stack overflow).
24const MAX_RESOLVE_DEPTH: usize = 512;
25
26/// Upper bound for single `String` scratch buffers built from hostile `:template` / replace paths.
27const MAX_ENGINE_SCRATCH_STRING: usize = 4 * 1024 * 1024;
28
29/// Validate that `full` path stays within the `base` directory (jail).
30/// Returns `Ok(canonical)` or an `Err` describing the violation.
31fn jail_path(base: &str, file_path: &str) -> Result<std::path::PathBuf, String> {
32    // Block absolute paths in the value itself
33    let fp = std::path::Path::new(file_path);
34    if fp.is_absolute() {
35        return Err(format!("SECURITY: absolute paths are not allowed: '{}'", file_path));
36    }
37    let base_canonical = match std::fs::canonicalize(base) {
38        Ok(p) => p,
39        Err(_) => std::path::PathBuf::from(base),
40    };
41    let full = base_canonical.join(file_path);
42    let full_canonical = match std::fs::canonicalize(&full) {
43        Ok(p) => p,
44        Err(_) => {
45            // File may not exist yet — at least verify no ".." escapes
46            let normalized = full.to_string_lossy();
47            if normalized.contains("..") {
48                return Err(format!("SECURITY: path traversal detected: '{}'", file_path));
49            }
50            return Ok(full);
51        }
52    };
53    if !full_canonical.starts_with(&base_canonical) {
54        return Err(format!("SECURITY: path escapes base directory: '{}'", file_path));
55    }
56    Ok(full_canonical)
57}
58
59/// Check file size before reading.
60fn check_file_size(path: &std::path::Path) -> Result<(), String> {
61    match std::fs::metadata(path) {
62        Ok(meta) if meta.len() > MAX_FILE_SIZE => {
63            Err(format!("SECURITY: file too large ({} bytes, max {})", meta.len(), MAX_FILE_SIZE))
64        }
65        _ => Ok(()),
66    }
67}
68
69/// Resolve all active-mode markers in a ParseResult.
70/// Returns the resolved root Value.
71pub fn resolve(result: &mut ParseResult, options: &Options) {
72    if result.mode != Mode::Active {
73        return;
74    }
75    let metadata = std::mem::take(&mut result.metadata);
76    let includes_directives = std::mem::take(&mut result.includes);
77    let use_directives = std::mem::take(&mut result.uses);
78
79    // ── Load !use packages (before includes, so packages are available) ──
80    #[cfg(feature = "wasm")]
81    let mut wasm_runtime = crate::wasm::WasmMarkerRuntime::new();
82    let packages_map = load_packages(
83        &use_directives,
84        options,
85        #[cfg(feature = "wasm")]
86        &mut wasm_runtime,
87    );
88
89    // If wasm feature is enabled and markers were loaded, create options with runtime
90    #[cfg(feature = "wasm")]
91    let wasm_options;
92    #[cfg(feature = "wasm")]
93    let options = if !wasm_runtime.marker_names().is_empty() {
94        wasm_options = Options {
95            wasm_runtime: Some(std::sync::Arc::new(wasm_runtime)),
96            ..options.clone()
97        };
98        &wasm_options
99    } else {
100        options
101    };
102
103    // ── Load !include files ──
104    let includes_map = load_includes(&includes_directives, options);
105
106    // ── Merge packages into root before resolution ──
107    if let Value::Object(ref mut root_map) = result.root {
108        for (alias, pkg_value) in &packages_map {
109            root_map.entry(alias.clone()).or_insert_with(|| pkg_value.clone());
110        }
111    }
112
113    // ── :inherit pre-pass ──
114    apply_inheritance(&mut result.root, &metadata);
115    // Remove private blocks (keys starting with _)
116    if let Value::Object(ref mut root_map) = result.root {
117        root_map.retain(|k, _| !k.starts_with('_'));
118    }
119
120    // ── Build type registry ──
121    let type_registry = build_type_registry(&metadata);
122    // ── Build constraint registry ──
123    let constraint_registry = build_constraint_registry(&metadata);
124
125    // SAFETY: `root_ptr` is a raw pointer to `result.root` used exclusively
126    // for *immutable* read access inside marker handlers (:calc, :alias,
127    // :map, :watch) that need to look up other keys in the root
128    // while also holding a mutable reference to a child object.
129    // The invariants that keep this sound:
130    //   1. We never write through `root_ptr` — only reads via `&*root_ptr`.
131    //   2. Mutable writes go through `map` (the current object), which is
132    //      always a distinct subtree from what we read via `root_ptr`.
133    //   3. The pointer is valid for the entire duration of `resolve_value`.
134    let root_ptr = &mut result.root as *mut Value;
135    resolve_value(&mut result.root, root_ptr, options, &metadata, "", &includes_map, 0);
136
137    // ── Validate field constraints (global, by field name) ──
138    validate_field_constraints(&mut result.root, &constraint_registry);
139    
140    // ── Validate field types ──
141    validate_field_types(&mut result.root, &type_registry, "");
142    
143    result.metadata = metadata;
144    result.includes = includes_directives;
145}
146
147fn resolve_value(
148    value: &mut Value,
149    root_ptr: *mut Value,
150    options: &Options,
151    metadata: &HashMap<String, MetaMap>,
152    path: &str,
153    includes: &HashMap<String, Value>,
154    depth: usize,
155) {
156    // Guard: prevent stack overflow from deeply nested objects
157    if depth >= MAX_RESOLVE_DEPTH {
158        // Safety: recursion only descends into Object variants (see lines below),
159        // so value is always an Object here. Non-Object values are safe to skip.
160        if let Value::Object(ref mut map) = value {
161            for val in map.values_mut() {
162                *val = Value::String(
163                    "NESTING_ERR: maximum object nesting depth exceeded".to_string()
164                );
165            }
166        }
167        return;
168    }
169
170    let meta_map = metadata.get(path).cloned();
171
172    if let Value::Object(ref mut map) = value {
173        let keys: Vec<String> = map.keys().cloned().collect();
174
175        // First pass: recurse into nested objects/arrays
176        for key in &keys {
177            let child_path = if path.is_empty() {
178                key.clone()
179            } else {
180                format!("{}.{}", path, key)
181            };
182
183            if let Some(child) = map.get_mut(key) {
184                match child {
185                    Value::Object(_) => {
186                        resolve_value(child, root_ptr, options, metadata, &child_path, includes, depth + 1);
187                    }
188                    Value::Array(arr) => {
189                        for item in arr.iter_mut() {
190                            if let Value::Object(_) = item {
191                                resolve_value(item, root_ptr, options, metadata, &child_path, includes, depth + 1);
192                            }
193                        }
194                    }
195                    _ => {}
196                }
197            }
198        }
199
200        // Second pass: apply markers
201        if let Some(ref mm) = meta_map {
202            for key in &keys {
203                let meta = match mm.get(key) {
204                    Some(m) => m.clone(),
205                    None => continue,
206                };
207
208                apply_markers(map, key, &meta, root_ptr, options, path, metadata, includes);
209            }
210        }
211
212        // Third pass: auto-{} interpolation on all string values
213        let keys2: Vec<String> = map.keys().cloned().collect();
214        for key in &keys2 {
215            if let Some(Value::String(s)) = map.get(key) {
216                if s.contains('{') {
217                    let root_ref = unsafe { &*root_ptr };
218                    let result = resolve_interpolation(s, root_ref, map, includes);
219                    if result != *s {
220                        map.insert(key.to_string(), Value::String(result));
221                    }
222                }
223            }
224        }
225    }
226}
227
228fn apply_markers(
229    map: &mut HashMap<String, Value>,
230    key: &str,
231    meta: &Meta,
232    root_ptr: *mut Value,
233    options: &Options,
234    path: &str,
235    metadata: &HashMap<String, MetaMap>,
236    _includes: &HashMap<String, Value>,
237) {
238    let markers = &meta.markers;
239
240    // ── :spam ──
241    // Syntax: key:spam:MAX_CALLS:WINDOW_SEC target
242    // WINDOW_SEC defaults to 1 when omitted.
243    // If target is a key path, resolves its value after passing the limit check.
244    if markers.contains(&"spam".to_string()) {
245        let spam_idx = markers.iter().position(|m| m == "spam").unwrap();
246        let max_calls = markers
247            .get(spam_idx + 1)
248            .and_then(|s| s.parse::<usize>().ok())
249            .unwrap_or(0);
250        let window_sec = markers
251            .get(spam_idx + 2)
252            .and_then(|s| s.parse::<u64>().ok())
253            .unwrap_or(1);
254
255        if max_calls == 0 {
256            map.insert(
257                key.to_string(),
258                Value::String("SPAM_ERR: invalid limit, use :spam:MAX[:WINDOW_SEC]".to_string()),
259            );
260            return;
261        }
262
263        let target = map
264            .get(key)
265            .map(value_to_string)
266            .unwrap_or_else(|| key.to_string());
267        let bucket_key = format!("{}::{}", key, target);
268
269        if !allow_spam_access(&bucket_key, max_calls, window_sec) {
270            map.insert(
271                key.to_string(),
272                Value::String(format!(
273                    "SPAM_ERR: '{}' exceeded {} calls per {}s",
274                    target, max_calls, window_sec
275                )),
276            );
277            return;
278        }
279
280        if let Some(resolved) = map
281            .get(key)
282            .and_then(|v| {
283                let t = value_to_string(v);
284                let root_ref = unsafe { &*root_ptr };
285                deep_get(root_ref, &t).or_else(|| map.get(t.as_str()).cloned())
286            })
287        {
288            map.insert(key.to_string(), resolved);
289        }
290    }
291
292    // ── :include / :import ──
293    if markers.contains(&"include".to_string()) || markers.contains(&"import".to_string()) {
294        if let Some(Value::String(file_path)) = map.get(key) {
295            let max_depth = options.max_include_depth.unwrap_or(DEFAULT_MAX_INCLUDE_DEPTH);
296            if options._include_depth >= max_depth {
297                map.insert(
298                    key.to_string(),
299                    Value::String(format!("INCLUDE_ERR: max include depth ({}) exceeded", max_depth)),
300                );
301                return;
302            }
303            let base = options
304                .base_path
305                .as_deref()
306                .unwrap_or(".");
307            let full = match jail_path(base, file_path) {
308                Ok(p) => p,
309                Err(e) => {
310                    map.insert(key.to_string(), Value::String(format!("INCLUDE_ERR: {}", e)));
311                    return;
312                }
313            };
314            if let Err(e) = check_file_size(&full) {
315                map.insert(key.to_string(), Value::String(format!("INCLUDE_ERR: {}", e)));
316                return;
317            }
318            match std::fs::read_to_string(&full) {
319                Ok(text) => {
320                    let mut included = parser::parse(&text);
321                    if included.mode == Mode::Active {
322                        let mut child_opts = options.clone();
323                        child_opts._include_depth += 1;
324                        if let Some(parent) = full.parent() {
325                            child_opts.base_path = Some(parent.to_string_lossy().into_owned());
326                        }
327                        resolve(&mut included, &child_opts);
328                    }
329                    map.insert(key.to_string(), included.root);
330                }
331                Err(e) => {
332                    map.insert(
333                        key.to_string(),
334                        Value::String(format!("INCLUDE_ERR: {}", e)),
335                    );
336                }
337            }
338        }
339        return;
340    }
341
342    // ── :env ──
343    if markers.contains(&"env".to_string()) {
344        if let Some(Value::String(var_name)) = map.get(key) {
345            let env_val = if let Some(ref env_map) = options.env {
346                env_map.get(var_name.as_str()).cloned()
347            } else {
348                std::env::var(var_name).ok()
349            };
350
351            let force_string = meta.type_hint.as_deref() == Some("string");
352            let default_idx = markers.iter().position(|m| m == "default");
353            if let Some(val) = env_val.filter(|v| !v.is_empty()) {
354                let resolved = if force_string {
355                    Value::String(val)
356                } else {
357                    cast_primitive(&val)
358                };
359                map.insert(key.to_string(), resolved);
360            } else if let Some(di) = default_idx {
361                if markers.len() > di + 1 {
362                    // Join all parts after 'default' back with ':'
363                    // to preserve IPs (0.0.0.0) and compound values
364                    let fallback = markers[di + 1..].join(":");
365                    let resolved = if force_string {
366                        Value::String(fallback)
367                    } else {
368                        cast_primitive(&fallback)
369                    };
370                    map.insert(key.to_string(), resolved);
371                } else {
372                    map.insert(key.to_string(), Value::Null);
373                }
374            } else {
375                map.insert(key.to_string(), Value::Null);
376            }
377        }
378    }
379
380    // ── :random ──
381    if markers.contains(&"random".to_string()) {
382        if let Some(Value::Array(arr)) = map.get(key) {
383            if arr.is_empty() {
384                map.insert(key.to_string(), Value::Null);
385                return;
386            }
387            let picked = if !meta.args.is_empty() {
388                let weights: Vec<f64> = meta.args.iter().filter_map(|s| s.parse().ok()).collect();
389                weighted_random(arr, &weights)
390            } else {
391                arr[rng::random_usize(arr.len())].clone()
392            };
393            map.insert(key.to_string(), picked);
394        }
395    }
396
397    // ── :ref ──
398    // Like :alias but feeds the resolved value into subsequent markers.
399    // Supports :ref:calc shorthand: key:ref:calc:*2 base_rate → resolves base_rate, then applies "VALUE * 2".
400    if markers.contains(&"ref".to_string()) {
401        if let Some(Value::String(target)) = map.get(key) {
402            let root_ref = unsafe { &*root_ptr };
403            let resolved = deep_get(root_ref, target)
404                .or_else(|| map.get(target.as_str()).cloned())
405                .unwrap_or(Value::Null);
406
407            // If :calc follows with a shorthand expression
408            if markers.contains(&"calc".to_string()) {
409                if let Some(n) = value_as_number(&resolved) {
410                    let calc_idx = markers.iter().position(|m| m == "calc").unwrap();
411                    if let Some(calc_expr) = markers.get(calc_idx + 1) {
412                        let first = calc_expr.chars().next().unwrap_or(' ');
413                        if "+-*/%".contains(first) {
414                            let expr = format!("{} {}", format_number(n), calc_expr);
415                            match safe_calc(&expr) {
416                                Ok(result) => {
417                                    let v = if result.fract() == 0.0 && result.abs() < i64::MAX as f64 {
418                                        Value::Int(result as i64)
419                                    } else {
420                                        Value::Float(result)
421                                    };
422                                    map.insert(key.to_string(), v);
423                                }
424                                Err(e) => {
425                                    map.insert(key.to_string(), Value::String(format!("CALC_ERR: {}", e)));
426                                }
427                            }
428                        } else {
429                            map.insert(key.to_string(), resolved);
430                        }
431                    } else {
432                        map.insert(key.to_string(), resolved);
433                    }
434                } else {
435                    map.insert(key.to_string(), resolved);
436                }
437            } else {
438                map.insert(key.to_string(), resolved);
439            }
440        }
441    }
442
443    // ── :i18n ──
444    // Selects a localized value from a nested object based on options.lang.
445    // Supports pluralization: key:i18n:COUNT_FIELD
446    //   When count field is specified, the language entry must contain plural forms:
447    //   title:i18n:item_count
448    //     en
449    //       one {count} item
450    //       other {count} items
451    //     ru
452    //       one {count} предмет
453    //       few {count} предмета
454    //       many {count} предметов
455    //       other {count} предметов
456    if markers.contains(&"i18n".to_string()) {
457        if let Some(Value::Object(translations)) = map.get(key) {
458            let lang = options.lang.as_deref().unwrap_or("en");
459            let val = translations.get(lang)
460                .or_else(|| translations.get("en"))
461                .or_else(|| translations.values().next())
462                .cloned()
463                .unwrap_or(Value::Null);
464
465            // Check for pluralization: i18n:count_field
466            let i18n_idx = markers.iter().position(|m| m == "i18n").unwrap();
467            let count_field = markers.get(i18n_idx + 1).cloned();
468
469            if let (Some(ref cf), Value::Object(ref plural_forms)) = (&count_field, &val) {
470                // Look up count value from current map or root
471                let count_val = map.get(cf)
472                    .and_then(value_as_number)
473                    .or_else(|| {
474                        let root_ref = unsafe { &*root_ptr };
475                        deep_get(root_ref, cf).and_then(|v| value_as_number(&v))
476                    })
477                    .unwrap_or(0.0) as i64;
478
479                let category = plural_category(lang, count_val);
480                let chosen = plural_forms.get(category)
481                    .or_else(|| plural_forms.get("other"))
482                    .or_else(|| plural_forms.values().next())
483                    .cloned()
484                    .unwrap_or(Value::Null);
485
486                // Substitute {count} in the result string
487                if let Value::String(ref s) = chosen {
488                    let replaced = s.replace("{count}", &count_val.to_string());
489                    map.insert(key.to_string(), Value::String(replaced));
490                } else {
491                    map.insert(key.to_string(), chosen);
492                }
493            } else {
494                map.insert(key.to_string(), val);
495            }
496        }
497    }
498
499    // ── :calc ──
500    if markers.contains(&"calc".to_string()) {
501        if let Some(Value::String(expr)) = map.get(key) {
502            if expr.len() > MAX_CALC_EXPR_LEN {
503                map.insert(
504                    key.to_string(),
505                    Value::String(format!("CALC_ERR: expression too long ({} chars, max {})", expr.len(), MAX_CALC_EXPR_LEN)),
506                );
507                return;
508            }
509            let mut resolved = expr.clone();
510
511            // Substitute variables from root (flat keys)
512            let root_ref = unsafe { &*root_ptr };
513            if let Value::Object(ref root_map) = root_ref {
514                for (rk, rv) in root_map {
515                    if let Some(n) = value_as_number(rv) {
516                        resolved = replace_word(&resolved, rk, &format_number(n));
517                        if resolved.len() > MAX_CALC_RESOLVED_LEN {
518                            map.insert(
519                                key.to_string(),
520                                Value::String(format!(
521                                    "CALC_ERR: resolved expression too long (max {} bytes)",
522                                    MAX_CALC_RESOLVED_LEN
523                                )),
524                            );
525                            return;
526                        }
527                    }
528                }
529            }
530
531            // Substitute from current object (flat keys)
532            for (rk, rv) in map.iter() {
533                if rk != key {
534                    if let Some(n) = value_as_number(rv) {
535                        resolved = replace_word(&resolved, rk, &format_number(n));
536                        if resolved.len() > MAX_CALC_RESOLVED_LEN {
537                            map.insert(
538                                key.to_string(),
539                                Value::String(format!(
540                                    "CALC_ERR: resolved expression too long (max {} bytes)",
541                                    MAX_CALC_RESOLVED_LEN
542                                )),
543                            );
544                            return;
545                        }
546                    }
547                }
548            }
549
550            // Substitute dot-path references (e.g., base.hp, server.port)
551            let root_ref2 = unsafe { &*root_ptr };
552            let mut dot_resolved = String::new();
553            let bytes = resolved.as_bytes();
554            let len = bytes.len();
555            let mut i = 0;
556            while i < len {
557                if is_word_char(bytes[i]) {
558                    let start = i;
559                    let mut has_dot = false;
560                    while i < len && (is_word_char(bytes[i]) || bytes[i] == b'.') {
561                        if bytes[i] == b'.' { has_dot = true; }
562                        i += 1;
563                    }
564                    let token = &resolved[start..i];
565                    if has_dot && token.contains('.') {
566                        if let Some(val) = deep_get(root_ref2, token) {
567                            if let Some(n) = value_as_number(&val) {
568                                dot_resolved.push_str(&format_number(n));
569                                if dot_resolved.len() > MAX_CALC_RESOLVED_LEN {
570                                    map.insert(
571                                        key.to_string(),
572                                        Value::String(format!(
573                                            "CALC_ERR: resolved expression too long (max {} bytes)",
574                                            MAX_CALC_RESOLVED_LEN
575                                        )),
576                                    );
577                                    return;
578                                }
579                                continue;
580                            }
581                        }
582                    }
583                    dot_resolved.push_str(token);
584                    if dot_resolved.len() > MAX_CALC_RESOLVED_LEN {
585                        map.insert(
586                            key.to_string(),
587                            Value::String(format!(
588                                "CALC_ERR: resolved expression too long (max {} bytes)",
589                                MAX_CALC_RESOLVED_LEN
590                            )),
591                        );
592                        return;
593                    }
594                } else {
595                    dot_resolved.push(bytes[i] as char);
596                    i += 1;
597                    if dot_resolved.len() > MAX_CALC_RESOLVED_LEN {
598                        map.insert(
599                            key.to_string(),
600                            Value::String(format!(
601                                "CALC_ERR: resolved expression too long (max {} bytes)",
602                                MAX_CALC_RESOLVED_LEN
603                            )),
604                        );
605                        return;
606                    }
607                }
608            }
609            resolved = dot_resolved;
610
611            match safe_calc(&resolved) {
612                Ok(result) => {
613                    let v = if result.fract() == 0.0 && result.abs() < i64::MAX as f64 {
614                        Value::Int(result as i64)
615                    } else {
616                        Value::Float(result)
617                    };
618                    map.insert(key.to_string(), v);
619                }
620                Err(e) => {
621                    map.insert(
622                        key.to_string(),
623                        Value::String(format!("CALC_ERR: {}", e)),
624                    );
625                }
626            }
627        }
628    }
629
630    // ── :alias ──
631    if markers.contains(&"alias".to_string()) {
632        if let Some(Value::String(target)) = map.get(key) {
633            let target = target.clone();
634            // Build the full dot-path of the current key
635            let current_path = if path.is_empty() {
636                key.to_string()
637            } else {
638                format!("{}.{}", path, key)
639            };
640            // Detect direct self-reference: key:alias key
641            if target == key || target == current_path {
642                map.insert(
643                    key.to_string(),
644                    Value::String(format!("ALIAS_ERR: self-referential alias: {} → {}", current_path, target)),
645                );
646            } else {
647                // Detect one-hop cycle: a → b → a
648                // Only flag as cycle if the target key ALSO has an :alias marker.
649                // Without this check, plain string values that happen to match the current
650                // key name would produce false-positive ALIAS_ERR results.
651                let root_ref = unsafe { &*root_ptr };
652                let target_val = deep_get(root_ref, &target);
653                // Determine the metadata path of the target key
654                let (target_parent, target_key_name) = if let Some(dot) = target.rfind('.') {
655                    (target[..dot].to_string(), target[dot + 1..].to_string())
656                } else {
657                    (String::new(), target.clone())
658                };
659                let target_has_alias = metadata
660                    .get(&target_parent)
661                    .and_then(|mm| mm.get(&target_key_name))
662                    .map(|m| m.markers.contains(&"alias".to_string()))
663                    .unwrap_or(false);
664                let is_cycle = target_has_alias && match &target_val {
665                    Some(Value::String(s)) => s == key || s == &current_path,
666                    _ => false,
667                };
668                if is_cycle {
669                    map.insert(
670                        key.to_string(),
671                        Value::String(format!("ALIAS_ERR: circular alias detected: {} → {}", current_path, target)),
672                    );
673                } else {
674                    let val = target_val.unwrap_or(Value::Null);
675                    map.insert(key.to_string(), val);
676                }
677            }
678        }
679    }
680
681    // ── :secret ──
682    if markers.contains(&"secret".to_string()) {
683        if let Some(val) = map.get(key) {
684            let s = value_to_string(val);
685            map.insert(key.to_string(), Value::Secret(s));
686        }
687    }
688
689    // ── :unique ──
690    if markers.contains(&"unique".to_string()) {
691        if let Some(Value::Array(arr)) = map.get(key) {
692            let mut seen = Vec::new();
693            let mut unique = Vec::new();
694            for item in arr {
695                let s = value_to_string(item);
696                if !seen.contains(&s) {
697                    seen.push(s);
698                    unique.push(item.clone());
699                }
700            }
701            map.insert(key.to_string(), Value::Array(unique));
702        }
703    }
704
705    // ── :geo ──
706    if markers.contains(&"geo".to_string()) {
707        if let Some(Value::Array(arr)) = map.get(key) {
708            let region = options.region.as_deref().unwrap_or("US");
709            let prefix = format!("{} ", region);
710            let found = arr.iter().find(|item| {
711                if let Value::String(s) = item {
712                    s.starts_with(&prefix)
713                } else {
714                    false
715                }
716            });
717
718            let result = if let Some(Value::String(s)) = found {
719                Value::String(s[prefix.len()..].trim().to_string())
720            } else if let Some(first) = arr.first() {
721                if let Value::String(s) = first {
722                    if let Some(space) = s.find(' ') {
723                        Value::String(s[space + 1..].trim().to_string())
724                    } else {
725                        first.clone()
726                    }
727                } else {
728                    first.clone()
729                }
730            } else {
731                Value::Null
732            };
733            map.insert(key.to_string(), result);
734        }
735    }
736
737    // ── :template (legacy — handled by auto-{} in resolve_value) ──
738
739    // ── :split ──
740    if markers.contains(&"split".to_string()) {
741        if let Some(Value::String(s)) = map.get(key) {
742            let split_idx = markers.iter().position(|m| m == "split").unwrap();
743            let sep = if split_idx + 1 < markers.len() {
744                delimiter_from_keyword(&markers[split_idx + 1])
745            } else {
746                ",".to_string()
747            };
748            let items: Vec<Value> = s
749                .split(&sep)
750                .map(|p| p.trim())
751                .filter(|p| !p.is_empty())
752                .map(|p| cast_primitive(p))
753                .collect();
754            map.insert(key.to_string(), Value::Array(items));
755        }
756    }
757
758    // ── :join ──
759    if markers.contains(&"join".to_string()) {
760        if let Some(Value::Array(arr)) = map.get(key) {
761            let join_idx = markers.iter().position(|m| m == "join").unwrap();
762            let sep = if join_idx + 1 < markers.len() {
763                delimiter_from_keyword(&markers[join_idx + 1])
764            } else {
765                ",".to_string()
766            };
767            let joined: String = arr
768                .iter()
769                .map(|v| value_to_string(v))
770                .collect::<Vec<_>>()
771                .join(&sep);
772            map.insert(key.to_string(), Value::String(joined));
773        }
774    }
775
776    // ── :default (standalone, without :env) ──
777    if markers.contains(&"default".to_string()) && !markers.contains(&"env".to_string()) {
778        let is_empty = match map.get(key) {
779            Some(Value::Null) | None => true,
780            Some(Value::String(s)) if s.is_empty() => true,
781            _ => false,
782        };
783        if is_empty {
784            let di = markers.iter().position(|m| m == "default").unwrap();
785            if markers.len() > di + 1 {
786                let fallback = markers[di + 1..].join(":");
787                let resolved = if meta.type_hint.as_deref() == Some("string") {
788                    Value::String(fallback)
789                } else {
790                    cast_primitive(&fallback)
791                };
792                map.insert(key.to_string(), resolved);
793            }
794        }
795    }
796
797    // ── :clamp ──
798    // Syntax: key:clamp:MIN:MAX value
799    // Clamps a numeric value to [MIN, MAX].
800    if markers.contains(&"clamp".to_string()) {
801        let clamp_idx = markers.iter().position(|m| m == "clamp").unwrap();
802        let min_s = markers.get(clamp_idx + 1).cloned().unwrap_or_default();
803        let max_s = markers.get(clamp_idx + 2).cloned().unwrap_or_default();
804        if let (Ok(lo), Ok(hi)) = (min_s.parse::<f64>(), max_s.parse::<f64>()) {
805            if lo > hi {
806                map.insert(key.to_string(), Value::String(
807                    format!("CONSTRAINT_ERR: clamp min ({}) > max ({})", lo, hi),
808                ));
809            } else if let Some(n) = map.get(key).and_then(value_as_number) {
810                let clamped = n.clamp(lo, hi);
811                let v = if clamped.fract() == 0.0 && clamped.abs() < i64::MAX as f64 {
812                    Value::Int(clamped as i64)
813                } else {
814                    Value::Float(clamped)
815                };
816                map.insert(key.to_string(), v);
817            }
818        }
819    }
820
821    // ── :round ──
822    // Syntax: key:round:N value  (N = decimal places, default 0)
823    // Works standalone or after :calc: key:calc:round:2 expr
824    if markers.contains(&"round".to_string()) {
825        let round_idx = markers.iter().position(|m| m == "round").unwrap();
826        let decimals: u32 = markers.get(round_idx + 1)
827            .and_then(|s| s.parse().ok())
828            .unwrap_or(0);
829        if let Some(n) = map.get(key).and_then(value_as_number) {
830            let factor = 10f64.powi(decimals as i32);
831            let rounded = (n * factor).round() / factor;
832            let v = if decimals == 0 {
833                Value::Int(rounded as i64)
834            } else {
835                Value::Float(rounded)
836            };
837            map.insert(key.to_string(), v);
838        }
839    }
840
841    // ── :map ──
842    // Syntax: key:map:source_key\n  - lookup_val result
843    // Looks up `source_key` in root, finds matching "lookup_val result" entry in the array.
844    if markers.contains(&"map".to_string()) {
845        if let Some(Value::Array(arr)) = map.get(key) {
846            let map_idx = markers.iter().position(|m| m == "map").unwrap();
847            let source_key = markers.get(map_idx + 1).cloned().unwrap_or_default();
848            let lookup_val = if !source_key.is_empty() {
849                let root_ref = unsafe { &*root_ptr };
850                deep_get(root_ref, &source_key)
851                    .or_else(|| map.get(&source_key).cloned())
852                    .map(|v| value_to_string(&v))
853                    .unwrap_or_default()
854            } else {
855                // Use the current string value as lookup key
856                match map.get(key) {
857                    Some(Value::String(s)) => s.clone(),
858                    _ => String::new(),
859                }
860            };
861
862            // Find matching entry: "lookup_val result_text"
863            let arr_clone = arr.clone();
864            let result = arr_clone.iter().find_map(|item| {
865                if let Value::String(s) = item {
866                    if let Some(space) = s.find(' ') {
867                        if s[..space].trim() == lookup_val {
868                            return Some(cast_primitive(s[space + 1..].trim()));
869                        }
870                    }
871                }
872                None
873            });
874            map.insert(key.to_string(), result.unwrap_or(Value::Null));
875        }
876    }
877
878    // ── :format ──
879    // Syntax: key:format:PATTERN value  (printf-style: %.2f, %d, %05d, %e)
880    // Converts numeric or string value to a formatted string.
881    if markers.contains(&"format".to_string()) {
882        let fmt_idx = markers.iter().position(|m| m == "format").unwrap();
883        let pattern = markers.get(fmt_idx + 1).cloned().unwrap_or_else(|| "%s".to_string());
884        if let Some(current) = map.get(key) {
885            let formatted = apply_format_pattern(&pattern, current);
886            map.insert(key.to_string(), Value::String(formatted));
887        }
888    }
889
890    // ── :fallback ──
891    // Syntax: key:fallback:DEFAULT_PATH value
892    // If the value (treated as a file path) doesn't exist on disk, use the fallback.
893    // Falls back to default if value is also null/empty.
894    if markers.contains(&"fallback".to_string()) {
895        let fb_idx = markers.iter().position(|m| m == "fallback").unwrap();
896        let default_val = markers.get(fb_idx + 1).cloned().unwrap_or_default();
897        let use_fallback = match map.get(key) {
898            None | Some(Value::Null) => true,
899            Some(Value::String(s)) if s.is_empty() => true,
900            Some(Value::String(s)) => {
901                let base = options.base_path.as_deref().unwrap_or(".");
902                match jail_path(base, s) {
903                    Ok(safe) => !safe.exists(),
904                    Err(_) => true, // path escapes jail → treat as missing → use fallback
905                }
906            }
907            _ => false,
908        };
909        if use_fallback && !default_val.is_empty() {
910            map.insert(key.to_string(), Value::String(default_val));
911        }
912    }
913
914    // ── :once ──
915    // Syntax: key:once  or  key:once:uuid  or  key:once:random  or  key:once:timestamp
916    // Generates a value once and persists it in a .synx.lock sidecar file.
917    if markers.contains(&"once".to_string()) {
918        let once_idx = markers.iter().position(|m| m == "once").unwrap();
919        let gen_type = markers.get(once_idx + 1).map(|s| s.as_str()).unwrap_or("uuid");
920        let lock_path = options.base_path.as_deref()
921            .map(|b| std::path::Path::new(b).join(".synx.lock"))
922            .unwrap_or_else(|| std::path::Path::new(".synx.lock").to_path_buf());
923
924        // Try to read existing value from lock file
925        let existing = read_lock_value(&lock_path, key);
926        if let Some(locked) = existing {
927            map.insert(key.to_string(), Value::String(locked));
928        } else {
929            let generated = match gen_type {
930                "uuid" => rng::generate_uuid(),
931                "timestamp" => std::time::SystemTime::now()
932                    .duration_since(std::time::UNIX_EPOCH)
933                    .unwrap_or_default()
934                    .as_secs()
935                    .to_string(),
936                "random" => rng::random_usize(u32::MAX as usize).to_string(),
937                _ => rng::generate_uuid(),
938            };
939            write_lock_value(&lock_path, key, &generated);
940            map.insert(key.to_string(), Value::String(generated));
941        }
942    }
943
944    // ── :version ──
945    // Syntax: key:version:OP:REQUIRED value
946    // Compares the value (current version) against REQUIRED using OP (>=, <=, >, <, ==, !=).
947    // Returns a bool.
948    if markers.contains(&"version".to_string()) {
949        if let Some(Value::String(current_ver)) = map.get(key) {
950            let ver_idx = markers.iter().position(|m| m == "version").unwrap();
951            let op = markers.get(ver_idx + 1).map(|s| s.as_str()).unwrap_or(">=");
952            let required = markers.get(ver_idx + 2).cloned().unwrap_or_default();
953            let result = compare_versions(current_ver, op, &required);
954            map.insert(key.to_string(), Value::Bool(result));
955        }
956    }
957
958    // ── :watch ──
959    // Syntax: key:watch:KEY_PATH ./file.json  (or ./file.synx)
960    // Reads the referenced file at parse time. Optionally extracts a key path (JSON/SYNX).
961    if markers.contains(&"watch".to_string()) {
962        if let Some(Value::String(file_path)) = map.get(key) {
963            let max_depth = options.max_include_depth.unwrap_or(DEFAULT_MAX_INCLUDE_DEPTH);
964            if options._include_depth >= max_depth {
965                map.insert(
966                    key.to_string(),
967                    Value::String(format!("WATCH_ERR: max include depth ({}) exceeded", max_depth)),
968                );
969                return;
970            }
971            let base = options.base_path.as_deref().unwrap_or(".");
972            let full = match jail_path(base, file_path) {
973                Ok(p) => p,
974                Err(e) => {
975                    map.insert(key.to_string(), Value::String(format!("WATCH_ERR: {}", e)));
976                    return;
977                }
978            };
979            if let Err(e) = check_file_size(&full) {
980                map.insert(key.to_string(), Value::String(format!("WATCH_ERR: {}", e)));
981                return;
982            }
983            let watch_idx = markers.iter().position(|m| m == "watch").unwrap();
984            let key_path = markers.get(watch_idx + 1).cloned();
985
986            match std::fs::read_to_string(&full) {
987                Ok(content) => {
988                    let value = if let Some(ref kp) = key_path {
989                        extract_from_file_content(&content, kp, full.extension().and_then(|e| e.to_str()).unwrap_or("")).unwrap_or(Value::Null)
990                    } else {
991                        Value::String(content.trim().to_string())
992                    };
993                    map.insert(key.to_string(), value);
994                }
995                Err(e) => {
996                    map.insert(key.to_string(), Value::String(format!("WATCH_ERR: {}", e)));
997                }
998            }
999        }
1000    }
1001
1002    // ── :prompt ──
1003    // Syntax: key:prompt:LABEL subtree
1004    // Converts the resolved subtree (object) into a SYNX-formatted string
1005    // wrapped in a labeled code fence, ready for LLM prompt embedding.
1006    if markers.contains(&"prompt".to_string()) {
1007        let prompt_idx = markers.iter().position(|m| m == "prompt").unwrap();
1008        let label = markers.get(prompt_idx + 1).cloned().unwrap_or_else(|| key.to_string());
1009        if let Some(val) = map.get(key) {
1010            let synx_text = stringify_value(val, 0);
1011            let block = format!("{} (SYNX):\n```synx\n{}```", label, synx_text);
1012            map.insert(key.to_string(), Value::String(block));
1013        }
1014    }
1015
1016    // ── :vision ──
1017    // Metadata-only marker. Recognized by the engine (no error), value passes through.
1018    // Applications detect this marker via metadata to dispatch image generation.
1019
1020    // ── :audio ──
1021    // Metadata-only marker. Recognized by the engine (no error), value passes through.
1022    // Applications detect this marker via metadata to dispatch audio generation.
1023
1024    // ── WASM custom markers ──
1025    // If a marker is not built-in and a WASM runtime is loaded, dispatch to it.
1026    #[cfg(feature = "wasm")]
1027    if let Some(ref wasm_rt) = options.wasm_runtime {
1028        for marker in markers {
1029            if crate::wasm::BUILTIN_MARKERS.contains(&marker.as_str()) {
1030                continue;
1031            }
1032            if wasm_rt.has_marker(marker) {
1033                // Collect args: all marker parts after this marker name
1034                let marker_idx = markers.iter().position(|m| m == marker).unwrap();
1035                let args: Vec<String> = markers[marker_idx + 1..].to_vec();
1036                let current_value = map.get(key).cloned().unwrap_or(Value::Null);
1037                match wasm_rt.apply_marker(marker, &current_value, &args) {
1038                    Ok(result) => {
1039                        map.insert(key.to_string(), result);
1040                    }
1041                    Err(e) => {
1042                        map.insert(key.to_string(), Value::String(format!("WASM_ERR: {}", e)));
1043                    }
1044                }
1045                break; // Only apply one WASM marker per key
1046            }
1047        }
1048    }
1049
1050    // ── Constraint validation (always last, after all markers resolved) ──
1051    if let Some(ref c) = meta.constraints {
1052        validate_constraints(map, key, c);
1053    }
1054}
1055
1056// ─── Constraint enforcement ───────────────────────────────
1057
1058fn validate_constraints(map: &mut HashMap<String, Value>, key: &str, c: &Constraints) {
1059    let val = match map.get(key) {
1060        Some(v) => v.clone(),
1061        None => {
1062            if c.required {
1063                map.insert(key.to_string(), Value::String(
1064                    format!("CONSTRAINT_ERR: '{}' is required", key),
1065                ));
1066            }
1067            return;
1068        }
1069    };
1070
1071    // required
1072    if c.required {
1073        let empty = matches!(val, Value::Null)
1074            || matches!(&val, Value::String(s) if s.is_empty());
1075        if empty {
1076            map.insert(key.to_string(), Value::String(
1077                format!("CONSTRAINT_ERR: '{}' is required", key),
1078            ));
1079            return;
1080        }
1081    }
1082
1083    // type check
1084    if let Some(ref type_name) = c.type_name {
1085        let ok = match type_name.as_str() {
1086            "int"    => matches!(val, Value::Int(_)),
1087            "float"  => matches!(val, Value::Float(_) | Value::Int(_)),
1088            "bool"   => matches!(val, Value::Bool(_)),
1089            "string" => matches!(val, Value::String(_)),
1090            _        => true,
1091        };
1092        if !ok {
1093            map.insert(key.to_string(), Value::String(
1094                format!("CONSTRAINT_ERR: '{}' expected type '{}'", key, type_name),
1095            ));
1096            return;
1097        }
1098    }
1099
1100    // enum check
1101    if let Some(ref enum_vals) = c.enum_values {
1102        let val_str = match &val {
1103            Value::String(s) => s.clone(),
1104            Value::Int(n)    => n.to_string(),
1105            Value::Float(f)  => f.to_string(),
1106            Value::Bool(b)   => b.to_string(),
1107            _                => String::new(),
1108        };
1109        if !enum_vals.contains(&val_str) {
1110            map.insert(key.to_string(), Value::String(
1111                format!("CONSTRAINT_ERR: '{}' must be one of [{}]", key, enum_vals.join("|")),
1112            ));
1113            return;
1114        }
1115    }
1116
1117    // min / max  (numbers: value range; strings: length range)
1118    let num = match &val {
1119        Value::Int(n)    => Some(*n as f64),
1120        Value::Float(f)  => Some(*f),
1121        Value::String(s) if c.min.is_some() || c.max.is_some() => Some(s.len() as f64),
1122        _                => None,
1123    };
1124    if let Some(n) = num {
1125        if let Some(min) = c.min {
1126            if n < min {
1127                map.insert(key.to_string(), Value::String(
1128                    format!("CONSTRAINT_ERR: '{}' value {} is below min {}", key, n, min),
1129                ));
1130                return;
1131            }
1132        }
1133        if let Some(max) = c.max {
1134            if n > max {
1135                map.insert(key.to_string(), Value::String(
1136                    format!("CONSTRAINT_ERR: '{}' value {} exceeds max {}", key, n, max),
1137                ));
1138                return;
1139            }
1140        }
1141    }
1142    // Note: `pattern` validation is intentionally skipped in the Rust engine
1143    // (adding a regex crate would bloat the dependency tree).  Pattern
1144    // validation is fully implemented in the JS engine.
1145}
1146
1147// ─── New-marker helpers ───────────────────────────────────
1148
1149/// Apply a printf-style format pattern to a value.
1150fn apply_format_pattern(pattern: &str, value: &Value) -> String {
1151    match value {
1152        Value::Int(n) => {
1153            if pattern.contains('d') || pattern.contains('i') {
1154                format_int_pattern(pattern, *n)
1155            } else if pattern.contains('f') || pattern.contains('e') {
1156                format_float_pattern(pattern, *n as f64)
1157            } else {
1158                n.to_string()
1159            }
1160        }
1161        Value::Float(f) => {
1162            if pattern.contains('f') || pattern.contains('e') {
1163                format_float_pattern(pattern, *f)
1164            } else {
1165                format_number(*f)
1166            }
1167        }
1168        Value::String(s) => s.clone(),
1169        other => value_to_string(other),
1170    }
1171}
1172
1173fn format_int_pattern(pattern: &str, n: i64) -> String {
1174    // Guardrail: user-controlled width can be enormous (esp. under fuzzing).
1175    // Large widths can cause pathological allocations or panics in formatting internals.
1176    const MAX_FMT_WIDTH: usize = 4096;
1177    if let Some(s) = pattern.strip_prefix('%') {
1178        if let Some(inner) = s.strip_suffix('d').or_else(|| s.strip_suffix('i')) {
1179            if let Some(w) = inner.strip_prefix('0') {
1180                if let Ok(width) = w.parse::<usize>() {
1181                    let width = width.min(MAX_FMT_WIDTH);
1182                    return format!("{:0>width$}", n, width = width);
1183                }
1184            }
1185            if let Ok(width) = inner.parse::<usize>() {
1186                let width = width.min(MAX_FMT_WIDTH);
1187                return format!("{:>width$}", n, width = width);
1188            }
1189        }
1190    }
1191    n.to_string()
1192}
1193
1194fn format_float_pattern(pattern: &str, f: f64) -> String {
1195    // Same rationale as MAX_FMT_WIDTH: avoid pathological precision values.
1196    const MAX_FMT_PREC: usize = 1024;
1197    if let Some(s) = pattern.strip_prefix('%') {
1198        if let Some(inner) = s.strip_suffix('f').or_else(|| s.strip_suffix('e')) {
1199            if let Some(prec_s) = inner.strip_prefix('.') {
1200                if let Ok(prec) = prec_s.parse::<usize>() {
1201                    let prec = prec.min(MAX_FMT_PREC);
1202                    return format!("{:.prec$}", f, prec = prec);
1203                }
1204            }
1205        }
1206    }
1207    f.to_string()
1208}
1209
1210/// Read a persisted value from the .synx.lock file.
1211fn read_lock_value(lock_path: &std::path::Path, key: &str) -> Option<String> {
1212    let content = std::fs::read_to_string(lock_path).ok()?;
1213    for line in content.lines() {
1214        if let Some(rest) = line.strip_prefix(key) {
1215            if rest.starts_with(' ') {
1216                return Some(rest.trim_start().to_string());
1217            }
1218        }
1219    }
1220    None
1221}
1222
1223/// Write/update a key value pair in the .synx.lock file.
1224fn write_lock_value(lock_path: &std::path::Path, key: &str, value: &str) {
1225    let mut lines: Vec<String> = std::fs::read_to_string(lock_path)
1226        .unwrap_or_default()
1227        .lines()
1228        .map(|l| l.to_string())
1229        .collect();
1230
1231    let new_line = format!("{} {}", key, value);
1232    let mut found = false;
1233    for line in lines.iter_mut() {
1234        if line.starts_with(key) && line[key.len()..].starts_with(' ') {
1235            *line = new_line.clone();
1236            found = true;
1237            break;
1238        }
1239    }
1240    if !found {
1241        lines.push(new_line);
1242    }
1243    let _ = std::fs::write(lock_path, lines.join("\n") + "\n");
1244}
1245
1246/// Compare two version strings using a comparison operator.
1247fn compare_versions(current: &str, op: &str, required: &str) -> bool {
1248    let parse_ver = |s: &str| -> Vec<u64> {
1249        s.split('.').filter_map(|p| p.parse().ok()).collect()
1250    };
1251    let cv = parse_ver(current);
1252    let rv = parse_ver(required);
1253    let len = cv.len().max(rv.len());
1254    let mut ord = std::cmp::Ordering::Equal;
1255    for i in 0..len {
1256        let a = cv.get(i).copied().unwrap_or(0);
1257        let b = rv.get(i).copied().unwrap_or(0);
1258        if a != b {
1259            ord = a.cmp(&b);
1260            break;
1261        }
1262    }
1263    match op {
1264        ">=" => ord != std::cmp::Ordering::Less,
1265        "<=" => ord != std::cmp::Ordering::Greater,
1266        ">"  => ord == std::cmp::Ordering::Greater,
1267        "<"  => ord == std::cmp::Ordering::Less,
1268        "==" | "=" => ord == std::cmp::Ordering::Equal,
1269        "!=" => ord != std::cmp::Ordering::Equal,
1270        _ => false,
1271    }
1272}
1273
1274fn allow_spam_access(bucket_key: &str, max_calls: usize, window_sec: u64) -> bool {
1275    let now = Instant::now();
1276    let window = Duration::from_secs(window_sec.max(1));
1277
1278    let buckets = SPAM_BUCKETS.get_or_init(|| Mutex::new(HashMap::new()));
1279    let mut guard = match buckets.lock() {
1280        Ok(g) => g,
1281        Err(poisoned) => poisoned.into_inner(),
1282    };
1283
1284    let calls = guard.entry(bucket_key.to_string()).or_default();
1285    calls.retain(|ts| now.duration_since(*ts) <= window);
1286
1287    if calls.len() >= max_calls {
1288        return false;
1289    }
1290
1291    calls.push(now);
1292    true
1293}
1294
1295#[cfg(test)]
1296fn clear_spam_buckets() {
1297    let buckets = SPAM_BUCKETS.get_or_init(|| Mutex::new(HashMap::new()));
1298    if let Ok(mut guard) = buckets.lock() {
1299        guard.clear();
1300    }
1301}
1302
1303/// Extract a value from file content by key path (JSON dot-path or SYNX key).
1304fn extract_from_file_content(content: &str, key_path: &str, ext: &str) -> Option<Value> {
1305    if ext == "json" {
1306        let search = format!("\"{}\"", key_path);
1307        if let Some(pos) = content.find(&search) {
1308            let after = content[pos + search.len()..].trim_start();
1309            if let Some(rest) = after.strip_prefix(':') {
1310                let val_s = rest.trim_start()
1311                    .trim_end_matches(',')
1312                    .trim_end_matches('}')
1313                    .trim()
1314                    .trim_matches('"');
1315                return Some(cast_primitive(val_s));
1316            }
1317        }
1318        None
1319    } else {
1320        for line in content.lines() {
1321            let trimmed = line.trim_start();
1322            if trimmed.starts_with(key_path) {
1323                let rest = &trimmed[key_path.len()..];
1324                if rest.starts_with(' ') {
1325                    return Some(cast_primitive(rest.trim_start()));
1326                }
1327            }
1328        }
1329        None
1330    }
1331}
1332
1333// ─── Helpers ─────────────────────────────────────────────
1334
1335/// Serialize a Value to SYNX format string (for :prompt marker).
1336fn stringify_value(value: &Value, indent: usize) -> String {
1337    let spaces = " ".repeat(indent);
1338    match value {
1339        Value::Object(map) => {
1340            let mut out = String::new();
1341            let mut keys: Vec<&str> = map.keys().map(|k| k.as_str()).collect();
1342            keys.sort_unstable();
1343            for key in keys {
1344                let val = &map[key];
1345                match val {
1346                    Value::Object(_) => {
1347                        out.push_str(&format!("{}{}\n", spaces, key));
1348                        out.push_str(&stringify_value(val, indent + 2));
1349                    }
1350                    Value::Array(arr) => {
1351                        out.push_str(&format!("{}{}\n", spaces, key));
1352                        for item in arr {
1353                            out.push_str(&format!("{}  - {}\n", spaces, value_to_string(item)));
1354                        }
1355                    }
1356                    _ => {
1357                        out.push_str(&format!("{}{} {}\n", spaces, key, value_to_string(val)));
1358                    }
1359                }
1360            }
1361            out
1362        }
1363        _ => format!("{}{}\n", spaces, value_to_string(value)),
1364    }
1365}
1366
1367pub(crate) fn cast_primitive(val: &str) -> Value {
1368    // Quoted strings preserve literal value
1369    if val.len() >= 2 {
1370        let bytes = val.as_bytes();
1371        if (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
1372            || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
1373        {
1374            return Value::String(val[1..val.len() - 1].to_string());
1375        }
1376    }
1377    match val {
1378        "true" => Value::Bool(true),
1379        "false" => Value::Bool(false),
1380        "null" => Value::Null,
1381        _ => {
1382            if let Ok(i) = val.parse::<i64>() {
1383                Value::Int(i)
1384            } else if let Ok(f) = val.parse::<f64>() {
1385                Value::Float(f)
1386            } else {
1387                Value::String(val.to_string())
1388            }
1389        }
1390    }
1391}
1392
1393fn delimiter_from_keyword(keyword: &str) -> String {
1394    match keyword {
1395        "space" => " ".to_string(),
1396        "pipe" => "|".to_string(),
1397        "dash" => "-".to_string(),
1398        "dot" => ".".to_string(),
1399        "semi" => ";".to_string(),
1400        "tab" => "\t".to_string(),
1401        "slash" => "/".to_string(),
1402        other => other.to_string(),
1403    }
1404}
1405
1406fn value_as_number(v: &Value) -> Option<f64> {
1407    match v {
1408        Value::Int(n) => Some(*n as f64),
1409        Value::Float(f) => Some(*f),
1410        _ => None,
1411    }
1412}
1413
1414fn value_to_string(v: &Value) -> String {
1415    match v {
1416        Value::String(s) => s.clone(),
1417        Value::Int(n) => n.to_string(),
1418        Value::Float(f) => format_number(*f as f64),
1419        Value::Bool(b) => b.to_string(),
1420        Value::Null => "null".to_string(),
1421        Value::Secret(s) => s.clone(),
1422        Value::Array(_) | Value::Object(_) => String::new(),
1423    }
1424}
1425
1426fn format_number(n: f64) -> String {
1427    if n.fract() == 0.0 && n.abs() < i64::MAX as f64 {
1428        (n as i64).to_string()
1429    } else {
1430        n.to_string()
1431    }
1432}
1433
1434/// Replace whole-word occurrences of `word` with `replacement`.
1435fn replace_word(haystack: &str, word: &str, replacement: &str) -> String {
1436    let word_bytes = word.as_bytes();
1437    let word_len = word_bytes.len();
1438    let hay_bytes = haystack.as_bytes();
1439    let hay_len = hay_bytes.len();
1440
1441    if word_len > hay_len {
1442        return haystack.to_string();
1443    }
1444
1445    let mut result = String::with_capacity(hay_len.min(MAX_ENGINE_SCRATCH_STRING));
1446    let mut i = 0;
1447
1448    while i <= hay_len - word_len {
1449        if result.len() >= MAX_ENGINE_SCRATCH_STRING {
1450            break;
1451        }
1452        if &hay_bytes[i..i + word_len] == word_bytes {
1453            let before_ok = i == 0 || !is_word_char(hay_bytes[i - 1]);
1454            let after_ok = i + word_len >= hay_len || !is_word_char(hay_bytes[i + word_len]);
1455            if before_ok && after_ok {
1456                let room = MAX_ENGINE_SCRATCH_STRING.saturating_sub(result.len());
1457                if room > 0 {
1458                    let take = replacement.len().min(room);
1459                    let end = replacement.floor_char_boundary(take);
1460                    result.push_str(&replacement[..end]);
1461                }
1462                i += word_len;
1463                continue;
1464            }
1465        }
1466        if result.len() < MAX_ENGINE_SCRATCH_STRING {
1467            result.push(hay_bytes[i] as char);
1468        }
1469        i += 1;
1470    }
1471    while i < hay_len && result.len() < MAX_ENGINE_SCRATCH_STRING {
1472        result.push(hay_bytes[i] as char);
1473        i += 1;
1474    }
1475    result
1476}
1477
1478fn is_word_char(b: u8) -> bool {
1479    b.is_ascii_alphanumeric() || b == b'_'
1480}
1481
1482fn weighted_random(items: &[Value], weights: &[f64]) -> Value {
1483    let mut w: Vec<f64> = weights.to_vec();
1484    if w.len() < items.len() {
1485        let assigned: f64 = w.iter().sum();
1486        // If the explicit weights already exceed 100, give unassigned items
1487        // the same average weight as the assigned ones so they remain visible.
1488        // If there is room left under 100, distribute the remainder equally.
1489        let per_item = if assigned < 100.0 {
1490            (100.0 - assigned) / (items.len() - w.len()) as f64
1491        } else {
1492            assigned / w.len() as f64
1493        };
1494        while w.len() < items.len() {
1495            w.push(per_item);
1496        }
1497    }
1498    let total: f64 = w.iter().sum();
1499    if total <= 0.0 {
1500        return items[rng::random_usize(items.len())].clone();
1501    }
1502
1503    let rand_val = rng::random_f64_01();
1504    let mut cumulative = 0.0;
1505    for (i, item) in items.iter().enumerate() {
1506        cumulative += w[i] / total;
1507        if rand_val <= cumulative {
1508            return item.clone();
1509        }
1510    }
1511    items.last().cloned().unwrap_or(Value::Null)
1512}
1513
1514// ─── Inheritance pre-pass ─────────────────────────────────
1515
1516fn apply_inheritance(root: &mut Value, metadata: &HashMap<String, MetaMap>) {
1517    let root_meta = match metadata.get("") {
1518        Some(m) => m.clone(),
1519        None => return,
1520    };
1521
1522    let root_map = match root.as_object_mut() {
1523        Some(m) => m as *mut HashMap<String, Value>,
1524        None => return,
1525    };
1526
1527    // Collect inherit targets: child_key → [parent1, parent2, ...]
1528    let mut inherits: Vec<(String, Vec<String>)> = Vec::new();
1529    for (key, meta) in &root_meta {
1530        if meta.markers.contains(&"inherit".to_string()) {
1531            let idx = meta.markers.iter().position(|m| m == "inherit").unwrap();
1532            // All markers after "inherit" are parent names (multi-parent support)
1533            let parents: Vec<String> = meta.markers[idx + 1..].to_vec();
1534            if !parents.is_empty() {
1535                inherits.push((key.clone(), parents));
1536            }
1537        }
1538    }
1539
1540    let map = unsafe { &mut *root_map };
1541    for (child_key, parents) in &inherits {
1542        // Merge parents left-to-right: first parent is base, each subsequent overrides
1543        let mut merged: HashMap<String, Value> = HashMap::new();
1544        for parent_name in parents {
1545            if let Some(Value::Object(p)) = map.get(parent_name) {
1546                for (k, v) in p {
1547                    merged.insert(k.clone(), v.clone());
1548                }
1549            }
1550        }
1551        // Child fields override all parents
1552        if let Some(Value::Object(c)) = map.get(child_key) {
1553            for (k, v) in c {
1554                merged.insert(k.clone(), v.clone());
1555            }
1556        }
1557        map.insert(child_key.clone(), Value::Object(merged));
1558    }
1559}
1560
1561fn deep_get(root: &Value, path: &str) -> Option<Value> {
1562    // Try direct key
1563    if let Value::Object(map) = root {
1564        if let Some(val) = map.get(path) {
1565            return Some(val.clone());
1566        }
1567    }
1568    // Dot-path traversal
1569    let parts: Vec<&str> = path.split('.').collect();
1570    let mut current = root;
1571    for part in parts {
1572        match current {
1573            Value::Object(map) => match map.get(part) {
1574                Some(v) => current = v,
1575                None => return None,
1576            },
1577            _ => return None,
1578        }
1579    }
1580    Some(current.clone())
1581}
1582
1583/// Resolve {placeholder} references in a template string.
1584/// Supports: {key}, {key.nested}, {key:alias}, {key:include}
1585fn resolve_interpolation(
1586    tpl: &str,
1587    root: &Value,
1588    local_map: &HashMap<String, Value>,
1589    includes: &HashMap<String, Value>,
1590) -> String {
1591    let bytes = tpl.as_bytes();
1592    let len = bytes.len();
1593    let mut result = String::with_capacity(len.min(MAX_ENGINE_SCRATCH_STRING));
1594    let mut i = 0;
1595
1596    while i < len {
1597        if result.len() >= MAX_ENGINE_SCRATCH_STRING {
1598            break;
1599        }
1600        if bytes[i] == b'{' {
1601            if let Some(close) = tpl[i + 1..].find('}') {
1602                let inner = &tpl[i + 1..i + 1 + close];
1603                // Check for scope separator ':'
1604                if let Some(colon) = inner.find(':') {
1605                    let ref_name = &inner[..colon];
1606                    let scope = &inner[colon + 1..];
1607                    // Valid ref name?
1608                    if ref_name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') {
1609                        let resolved = if scope == "include" {
1610                            // {key:include} — first/only include
1611                            if includes.len() == 1 {
1612                                let first = includes.values().next().unwrap();
1613                                deep_get(first, ref_name)
1614                            } else {
1615                                None
1616                            }
1617                        } else {
1618                            // {key:alias} — look up by alias
1619                            includes.get(scope).and_then(|inc| deep_get(inc, ref_name))
1620                        };
1621                        if let Some(val) = resolved {
1622                            let s = value_to_string(&val);
1623                            let room = MAX_ENGINE_SCRATCH_STRING.saturating_sub(result.len());
1624                            if room > 0 {
1625                                let take = s.len().min(room);
1626                                let end = s.floor_char_boundary(take);
1627                                result.push_str(&s[..end]);
1628                            }
1629                        } else {
1630                            result.push('{');
1631                            let rem = MAX_ENGINE_SCRATCH_STRING.saturating_sub(result.len() + 1);
1632                            if rem > 0 {
1633                                let end = inner.floor_char_boundary(inner.len().min(rem));
1634                                result.push_str(&inner[..end]);
1635                            }
1636                            if result.len() < MAX_ENGINE_SCRATCH_STRING {
1637                                result.push('}');
1638                            }
1639                        }
1640                        i += 2 + close;
1641                        continue;
1642                    }
1643                } else {
1644                    // {key} — local
1645                    let ref_name = inner;
1646                    if ref_name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') {
1647                        let resolved = deep_get(root, ref_name).or_else(|| {
1648                            local_map.get(ref_name).cloned()
1649                        });
1650                        if let Some(val) = resolved {
1651                            let s = value_to_string(&val);
1652                            let room = MAX_ENGINE_SCRATCH_STRING.saturating_sub(result.len());
1653                            if room > 0 {
1654                                let take = s.len().min(room);
1655                                let end = s.floor_char_boundary(take);
1656                                result.push_str(&s[..end]);
1657                            }
1658                        } else {
1659                            result.push('{');
1660                            let rem = MAX_ENGINE_SCRATCH_STRING.saturating_sub(result.len() + 1);
1661                            if rem > 0 {
1662                                let end = ref_name.floor_char_boundary(ref_name.len().min(rem));
1663                                result.push_str(&ref_name[..end]);
1664                            }
1665                            if result.len() < MAX_ENGINE_SCRATCH_STRING {
1666                                result.push('}');
1667                            }
1668                        }
1669                        i += 2 + close;
1670                        continue;
1671                    }
1672                }
1673            }
1674        }
1675        if result.len() < MAX_ENGINE_SCRATCH_STRING {
1676            result.push(bytes[i] as char);
1677        }
1678        i += 1;
1679    }
1680    result
1681}
1682
1683/// Load !include files into a map<alias, Value>.
1684fn load_includes(
1685    directives: &[IncludeDirective],
1686    options: &Options,
1687) -> HashMap<String, Value> {
1688    let mut map = HashMap::new();
1689    let base = options.base_path.as_deref().unwrap_or(".");
1690    let max_depth = options.max_include_depth.unwrap_or(DEFAULT_MAX_INCLUDE_DEPTH);
1691    if options._include_depth >= max_depth {
1692        return map;
1693    }
1694    for inc in directives {
1695        let full = match jail_path(base, &inc.path) {
1696            Ok(p) => p,
1697            Err(_) => continue,
1698        };
1699        if check_file_size(&full).is_err() {
1700            continue;
1701        }
1702        if let Ok(text) = std::fs::read_to_string(&full) {
1703            let mut included = parser::parse(&text);
1704            if included.mode == Mode::Active {
1705                let mut child_opts = options.clone();
1706                child_opts._include_depth += 1;
1707                if let Some(parent) = full.parent() {
1708                    child_opts.base_path = Some(parent.to_string_lossy().into_owned());
1709                }
1710                resolve(&mut included, &child_opts);
1711            }
1712            map.insert(inc.alias.clone(), included.root);
1713        }
1714    }
1715    map
1716}
1717
1718/// Load !use packages into a map<alias, Value>.
1719///
1720/// Looks for `<packages_path>/@scope/name/src/main.synx` on disk.
1721/// Falls back to `./synx_packages/` when `options.packages_path` is unset.
1722fn load_packages(
1723    directives: &[UseDirective],
1724    options: &Options,
1725    #[cfg(feature = "wasm")] wasm_runtime: &mut crate::wasm::WasmMarkerRuntime,
1726) -> HashMap<String, Value> {
1727    let mut map = HashMap::new();
1728    let pkg_base = options
1729        .packages_path
1730        .as_deref()
1731        .unwrap_or("./synx_packages");
1732    let base = options.base_path.as_deref().unwrap_or(".");
1733    let pkg_root = std::path::Path::new(base).join(pkg_base);
1734
1735    for ud in directives {
1736        // @scope/name  → package dir is <pkg_root>/@scope/name
1737        let pkg_dir = pkg_root.join(&ud.package);
1738
1739        // Check if this is a WASM marker package (type markers in manifest)
1740        #[cfg(feature = "wasm")]
1741        {
1742            if is_marker_package(&pkg_dir) {
1743                // Load the .wasm file from the package
1744                let wasm_entry = read_manifest_wasm(&pkg_dir)
1745                    .unwrap_or_else(|| pkg_dir.join("src").join("main.wasm"));
1746                let caps = read_manifest_capabilities(&pkg_dir);
1747                if wasm_entry.is_file() {
1748                    if let Ok(wasm_bytes) = std::fs::read(&wasm_entry) {
1749                        match wasm_runtime.load_module(&wasm_bytes, caps) {
1750                            Ok(_markers) => {}
1751                            Err(e) => {
1752                                map.insert(
1753                                    ud.alias.clone(),
1754                                    Value::String(format!("WASM_ERR: {}", e)),
1755                                );
1756                            }
1757                        }
1758                    }
1759                }
1760                continue;
1761            }
1762        }
1763
1764        // Read entry point from manifest (synx-pkg.synx), fall back to src/main.synx
1765        let entry = read_manifest_main(&pkg_dir)
1766            .unwrap_or_else(|| pkg_dir.join("src").join("main.synx"));
1767
1768        if !entry.is_file() {
1769            continue;
1770        }
1771        if check_file_size(&entry).is_err() {
1772            continue;
1773        }
1774        if let Ok(text) = std::fs::read_to_string(&entry) {
1775            let mut parsed = parser::parse(&text);
1776            if parsed.mode == Mode::Active {
1777                let mut child_opts = options.clone();
1778                child_opts._include_depth += 1;
1779                child_opts.base_path = Some(
1780                    entry.parent().unwrap_or(&pkg_dir).to_string_lossy().into_owned()
1781                );
1782                resolve(&mut parsed, &child_opts);
1783            }
1784            map.insert(ud.alias.clone(), parsed.root);
1785        }
1786    }
1787    map
1788}
1789
1790/// Read synx-pkg.synx manifest and extract the `main` entry point path.
1791/// Returns the absolute path to the entry file, or None if manifest is missing/invalid.
1792fn read_manifest_main(pkg_dir: &std::path::Path) -> Option<std::path::PathBuf> {
1793    let manifest = pkg_dir.join("synx-pkg.synx");
1794    let text = std::fs::read_to_string(&manifest).ok()?;
1795    for line in text.lines() {
1796        let trimmed = line.trim();
1797        if let Some(rest) = trimmed.strip_prefix("main ") {
1798            let entry_path = rest.trim();
1799            if !entry_path.is_empty() {
1800                return Some(pkg_dir.join(entry_path));
1801            }
1802        }
1803        // Legacy field name support
1804        if let Some(rest) = trimmed.strip_prefix("entry ") {
1805            let entry_path = rest.trim();
1806            if !entry_path.is_empty() {
1807                return Some(pkg_dir.join(entry_path));
1808            }
1809        }
1810    }
1811    None
1812}
1813
1814/// Check if a package is a WASM marker package.
1815/// Matches `type markers` in manifest, or `main` pointing to a `.wasm` file.
1816#[cfg(feature = "wasm")]
1817fn is_marker_package(pkg_dir: &std::path::Path) -> bool {
1818    let manifest = pkg_dir.join("synx-pkg.synx");
1819    if let Ok(text) = std::fs::read_to_string(&manifest) {
1820        for line in text.lines() {
1821            let trimmed = line.trim();
1822            if trimmed == "type markers" {
1823                return true;
1824            }
1825            if let Some(rest) = trimmed.strip_prefix("main ") {
1826                if rest.trim().ends_with(".wasm") {
1827                    return true;
1828                }
1829            }
1830        }
1831    }
1832    false
1833}
1834
1835/// Read the WASM entry point from a marker package manifest.
1836#[cfg(feature = "wasm")]
1837fn read_manifest_wasm(pkg_dir: &std::path::Path) -> Option<std::path::PathBuf> {
1838    let manifest = pkg_dir.join("synx-pkg.synx");
1839    let text = std::fs::read_to_string(&manifest).ok()?;
1840    for line in text.lines() {
1841        let trimmed = line.trim();
1842        if let Some(rest) = trimmed.strip_prefix("wasm ") {
1843            let wasm_path = rest.trim();
1844            if !wasm_path.is_empty() {
1845                return Some(pkg_dir.join(wasm_path));
1846            }
1847        }
1848        // Also check `main` if it points to a .wasm file
1849        if let Some(rest) = trimmed.strip_prefix("main ") {
1850            let path = rest.trim();
1851            if path.ends_with(".wasm") {
1852                return Some(pkg_dir.join(path));
1853            }
1854        }
1855    }
1856    None
1857}
1858
1859/// Read capability permissions from a marker package manifest.
1860#[cfg(feature = "wasm")]
1861fn read_manifest_capabilities(pkg_dir: &std::path::Path) -> crate::wasm::WasmCapabilities {
1862    let manifest = pkg_dir.join("synx-pkg.synx");
1863    let text = match std::fs::read_to_string(&manifest) {
1864        Ok(t) => t,
1865        Err(_) => return crate::wasm::WasmCapabilities::default(),
1866    };
1867    for line in text.lines() {
1868        let trimmed = line.trim();
1869        if let Some(rest) = trimmed.strip_prefix("permissions ") {
1870            return crate::wasm::WasmCapabilities::from_manifest_line(rest);
1871        }
1872    }
1873    crate::wasm::WasmCapabilities::default()
1874}
1875
1876// ─── Type validation ──────────────────────────────────────
1877
1878/// Build a global type registry from all metadata.
1879/// Maps field name → expected type (e.g., "hp" → "int").
1880fn build_type_registry(metadata: &HashMap<String, MetaMap>) -> HashMap<String, String> {
1881    let mut registry: HashMap<String, String> = HashMap::new();
1882
1883    for meta_map in metadata.values() {
1884        for (key, meta) in meta_map {
1885            if let Some(ref type_hint) = meta.type_hint {
1886                // If type already registered, check for conflict
1887                if let Some(existing) = registry.get(key) {
1888                    if existing != type_hint {
1889                        // Type conflict: same field defined with different types
1890                        // For now, first definition wins; could also log error
1891                    }
1892                } else {
1893                    registry.insert(key.clone(), type_hint.clone());
1894                }
1895            }
1896        }
1897    }
1898
1899    registry
1900}
1901
1902/// Build a global constraint registry from all metadata.
1903/// Maps field name → merged constraints from [] declarations.
1904fn build_constraint_registry(metadata: &HashMap<String, MetaMap>) -> HashMap<String, Constraints> {
1905    let mut registry: HashMap<String, Constraints> = HashMap::new();
1906
1907    for meta_map in metadata.values() {
1908        for (key, meta) in meta_map {
1909            if let Some(ref constraints) = meta.constraints {
1910                registry
1911                    .entry(key.clone())
1912                    .and_modify(|existing| merge_constraints(existing, constraints))
1913                    .or_insert_with(|| constraints.clone());
1914            }
1915        }
1916    }
1917
1918    registry
1919}
1920
1921/// Merge constraints when the same field is declared multiple times.
1922/// Strategy is intentionally strict to keep schemas consistent across templates.
1923fn merge_constraints(base: &mut Constraints, incoming: &Constraints) {
1924    if incoming.required {
1925        base.required = true;
1926    }
1927    if incoming.readonly {
1928        base.readonly = true;
1929    }
1930
1931    // Stricter numeric bounds win.
1932    base.min = match (base.min, incoming.min) {
1933        (Some(a), Some(b)) => Some(a.max(b)),
1934        (None, Some(b)) => Some(b),
1935        (a, None) => a,
1936    };
1937    base.max = match (base.max, incoming.max) {
1938        (Some(a), Some(b)) => Some(a.min(b)),
1939        (None, Some(b)) => Some(b),
1940        (a, None) => a,
1941    };
1942
1943    // Keep first non-empty type/pattern/enum declaration.
1944    if base.type_name.is_none() {
1945        base.type_name = incoming.type_name.clone();
1946    }
1947    if base.pattern.is_none() {
1948        base.pattern = incoming.pattern.clone();
1949    }
1950    if base.enum_values.is_none() {
1951        base.enum_values = incoming.enum_values.clone();
1952    }
1953}
1954
1955/// Validate [] constraints recursively for all object fields that have
1956/// a registered constraint rule.
1957fn validate_field_constraints(value: &mut Value, registry: &HashMap<String, Constraints>) {
1958    if let Value::Object(ref mut map) = value {
1959        let keys: Vec<String> = map.keys().cloned().collect();
1960        for key in &keys {
1961            if let Some(constraints) = registry.get(key) {
1962                validate_constraints(map, key, constraints);
1963            }
1964
1965            if let Some(child) = map.get_mut(key) {
1966                match child {
1967                    Value::Object(_) => validate_field_constraints(child, registry),
1968                    Value::Array(arr) => {
1969                        for item in arr.iter_mut() {
1970                            if let Value::Object(_) = item {
1971                                validate_field_constraints(item, registry);
1972                            }
1973                        }
1974                    }
1975                    _ => {}
1976                }
1977            }
1978        }
1979    }
1980}
1981
1982/// Validate that all values in the tree match their registered types.
1983fn validate_field_types(value: &mut Value, registry: &HashMap<String, String>, path: &str) {
1984    match value {
1985        Value::Object(ref mut map) => {
1986            let keys: Vec<String> = map.keys().cloned().collect();
1987            for key in &keys {
1988                if let Some(expected_type) = registry.get(key) {
1989                    if let Some(val) = map.get(key) {
1990                        if !value_matches_type(val, expected_type) {
1991                            // Type mismatch: replace with error string
1992                            let current_type = value_type_name(val);
1993                            map.insert(key.clone(), Value::String(
1994                                format!("TYPE_ERR: '{}' expected {} but got {}", key, expected_type, current_type)
1995                            ));
1996                        }
1997                    }
1998                }
1999                
2000                // Recurse into nested objects and arrays
2001                if let Some(child) = map.get_mut(key) {
2002                    match child {
2003                        Value::Object(_) => {
2004                            let child_path = if path.is_empty() {
2005                                key.clone()
2006                            } else {
2007                                format!("{}.{}", path, key)
2008                            };
2009                            validate_field_types(child, registry, &child_path);
2010                        }
2011                        Value::Array(ref mut arr) => {
2012                            for item in arr.iter_mut() {
2013                                if let Value::Object(_) = item {
2014                                    validate_field_types(item, registry, path);
2015                                }
2016                            }
2017                        }
2018                        _ => {}
2019                    }
2020                }
2021            }
2022        }
2023        _ => {}
2024    }
2025}
2026
2027/// Check if a value matches an expected type.
2028fn value_matches_type(value: &Value, expected_type: &str) -> bool {
2029    match expected_type {
2030        "int" => matches!(value, Value::Int(_)),
2031        "float" => matches!(value, Value::Float(_) | Value::Int(_)),
2032        "bool" => matches!(value, Value::Bool(_)),
2033        "string" => matches!(value, Value::String(_) | Value::Secret(_)),
2034        "array" => matches!(value, Value::Array(_)),
2035        "object" => matches!(value, Value::Object(_)),
2036        _ => true, // Unknown types are accepted
2037    }
2038}
2039
2040/// Get the human-readable name of a value's type.
2041fn value_type_name(value: &Value) -> String {
2042    match value {
2043        Value::Int(_) => "int".to_string(),
2044        Value::Float(_) => "float".to_string(),
2045        Value::Bool(_) => "bool".to_string(),
2046        Value::String(_) => "string".to_string(),
2047        Value::Secret(_) => "secret".to_string(),
2048        Value::Array(_) => "array".to_string(),
2049        Value::Object(_) => "object".to_string(),
2050        Value::Null => "null".to_string(),
2051    }
2052}
2053
2054// ─── CLDR plural rules ───────────────────────────────────
2055
2056/// Return the CLDR plural category for a given language and integer count.
2057/// Categories: "zero", "one", "two", "few", "many", "other".
2058fn plural_category(lang: &str, n: i64) -> &'static str {
2059    let abs_n = n.unsigned_abs();
2060    let n10 = abs_n % 10;
2061    let n100 = abs_n % 100;
2062
2063    match lang {
2064        // East Slavic: Russian, Ukrainian, Belarusian
2065        "ru" | "uk" | "be" => {
2066            if n10 == 1 && n100 != 11 {
2067                "one"
2068            } else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) {
2069                "few"
2070            } else {
2071                "many"
2072            }
2073        }
2074        // West/South Slavic: Polish
2075        "pl" => {
2076            if n10 == 1 && n100 != 11 {
2077                "one"
2078            } else if (2..=4).contains(&n10) && !(12..=14).contains(&n100) {
2079                "few"
2080            } else {
2081                "many"
2082            }
2083        }
2084        // Czech, Slovak
2085        "cs" | "sk" => {
2086            if abs_n == 1 { "one" }
2087            else if (2..=4).contains(&abs_n) { "few" }
2088            else { "other" }
2089        }
2090        // Arabic
2091        "ar" => {
2092            if abs_n == 0 { "zero" }
2093            else if abs_n == 1 { "one" }
2094            else if abs_n == 2 { "two" }
2095            else if (3..=10).contains(&n100) { "few" }
2096            else if (11..=99).contains(&n100) { "many" }
2097            else { "other" }
2098        }
2099        // French, Portuguese (Brazilian) — 0 and 1 are "one"
2100        "fr" | "pt" => {
2101            if abs_n <= 1 { "one" } else { "other" }
2102        }
2103        // Japanese, Chinese, Korean, Vietnamese, Thai — no plural forms
2104        "ja" | "zh" | "ko" | "vi" | "th" => "other",
2105        // English, German, Spanish, Italian, Dutch, Swedish, Norwegian, Danish, etc.
2106        _ => {
2107            if abs_n == 1 { "one" } else { "other" }
2108        }
2109    }
2110}
2111
2112#[cfg(test)]
2113mod tests {
2114    use crate::{parse, Options, Value};
2115    use super::{resolve, read_manifest_main};
2116
2117    #[test]
2118    fn test_ref_simple() {
2119        let mut r = parse("!active\nbase_rate 50\nquick_rate:ref base_rate");
2120        resolve(&mut r, &Options::default());
2121        let map = r.root.as_object().unwrap();
2122        assert_eq!(map["quick_rate"], Value::Int(50));
2123    }
2124
2125    #[test]
2126    fn test_ref_calc_shorthand() {
2127        let mut r = parse("!active\nbase_rate 50\ndouble_rate:ref:calc:*2 base_rate");
2128        resolve(&mut r, &Options::default());
2129        let map = r.root.as_object().unwrap();
2130        assert_eq!(map["double_rate"], Value::Int(100));
2131    }
2132
2133    #[test]
2134    fn test_inherit() {
2135        let mut r = parse("!active\n_base\n  weight 10\n  stackable true\nsteel:inherit:_base\n  weight 25\n  material metal");
2136        resolve(&mut r, &Options::default());
2137        let map = r.root.as_object().unwrap();
2138        assert!(!map.contains_key("_base"));
2139        let steel = map["steel"].as_object().unwrap();
2140        assert_eq!(steel["weight"], Value::Int(25));
2141        assert_eq!(steel["stackable"], Value::Bool(true));
2142        assert_eq!(steel["material"], Value::String("metal".into()));
2143    }
2144
2145    #[test]
2146    fn test_i18n_select_lang() {
2147        let mut r = parse("!active\ntitle:i18n\n  en Hello\n  ru Привет\n  de Hallo");
2148        let opts = Options { lang: Some("ru".into()), ..Default::default() };
2149        resolve(&mut r, &opts);
2150        let map = r.root.as_object().unwrap();
2151        assert_eq!(map["title"], Value::String("Привет".into()));
2152    }
2153
2154    #[test]
2155    fn test_i18n_fallback_en() {
2156        let mut r = parse("!active\ntitle:i18n\n  en Hello\n  ru Привет");
2157        let opts = Options { lang: Some("fr".into()), ..Default::default() };
2158        resolve(&mut r, &opts);
2159        let map = r.root.as_object().unwrap();
2160        assert_eq!(map["title"], Value::String("Hello".into()));
2161    }
2162
2163    #[test]
2164    fn test_auto_interpolation_simple() {
2165        let mut r = parse("!active\nname Wario\ngreeting Hello, {name}!");
2166        resolve(&mut r, &Options::default());
2167        let map = r.root.as_object().unwrap();
2168        assert_eq!(map["greeting"], Value::String("Hello, Wario!".into()));
2169    }
2170
2171    #[test]
2172    fn test_auto_interpolation_nested() {
2173        let mut r = parse("!active\nserver\n  host localhost\n  port 8080\nurl http://{server.host}:{server.port}/api");
2174        resolve(&mut r, &Options::default());
2175        let map = r.root.as_object().unwrap();
2176        assert_eq!(map["url"], Value::String("http://localhost:8080/api".into()));
2177    }
2178
2179    #[test]
2180    fn test_template_legacy_still_works() {
2181        let mut r = parse("!active\nname Wario\ngreeting:template Hello, {name}!");
2182        resolve(&mut r, &Options::default());
2183        let map = r.root.as_object().unwrap();
2184        assert_eq!(map["greeting"], Value::String("Hello, Wario!".into()));
2185    }
2186
2187    #[test]
2188    fn test_type_validation() {
2189        // Test that type validation works: hp(int) defined in _base_unit,
2190        // then used in other places with correct type
2191        let mut r = parse(
2192            "!active\n\
2193            _base_unit\n  \
2194              hp(int) 100\n  \
2195              speed(float) 1.5\n\
2196            infantry:inherit:_base_unit\n  \
2197              name Infantry\n  \
2198              hp 80"
2199        );
2200        resolve(&mut r, &Options::default());
2201        let map = r.root.as_object().unwrap();
2202        
2203        // _base_unit should be removed (private)
2204        assert!(!map.contains_key("_base_unit"));
2205        
2206        // infantry should exist with correct types
2207        let infantry = map["infantry"].as_object().unwrap();
2208        assert_eq!(infantry["hp"], Value::Int(80));  // Correct: int
2209        assert_eq!(infantry["speed"], Value::Float(1.5));  // Correct: float
2210    }
2211
2212    #[test]
2213    fn test_type_validation_error() {
2214        // Test that type mismatch is detected and replaced with error
2215        let mut r = parse(
2216            "!active\n\
2217            _base_unit\n  \
2218              hp(int) 100\n\
2219            infantry:inherit:_base_unit\n  \
2220              hp hello"  // Type mismatch: string instead of int
2221        );
2222        resolve(&mut r, &Options::default());
2223        let map = r.root.as_object().unwrap();
2224        
2225        let infantry = map["infantry"].as_object().unwrap();
2226        // Should be replaced with error message
2227        if let Value::String(s) = &infantry["hp"] {
2228            assert!(s.contains("TYPE_ERR"));
2229        } else {
2230            panic!("Expected error string for type mismatch");
2231        }
2232    }
2233
2234    #[test]
2235    fn test_constraint_validation_inherited_range() {
2236        let mut r = parse(
2237            "!active\n\
2238            _base_unit\n  \
2239              hp[min:1, max:50000] 1000\n\
2240            infantry:inherit:_base_unit\n  \
2241              hp 60000"
2242        );
2243        resolve(&mut r, &Options::default());
2244        let map = r.root.as_object().unwrap();
2245        let infantry = map["infantry"].as_object().unwrap();
2246
2247        if let Value::String(s) = &infantry["hp"] {
2248            assert!(s.contains("CONSTRAINT_ERR"));
2249            assert!(s.contains("exceeds max"));
2250        } else {
2251            panic!("Expected constraint error string");
2252        }
2253    }
2254
2255    #[test]
2256    fn test_constraint_validation_required() {
2257        let mut r = parse(
2258            "!active\n\
2259            _base_unit\n  \
2260                            description[type:string, required] hello\n\
2261            scout:inherit:_base_unit\n  \
2262                            description null"
2263        );
2264        resolve(&mut r, &Options::default());
2265        let map = r.root.as_object().unwrap();
2266        let scout = map["scout"].as_object().unwrap();
2267
2268        if let Value::String(s) = &scout["description"] {
2269            assert!(s.contains("CONSTRAINT_ERR"));
2270            assert!(s.contains("required"));
2271        } else {
2272            panic!("Expected required-constraint error string");
2273        }
2274    }
2275
2276    #[test]
2277    fn test_multi_parent_inherit() {
2278        let mut r = parse(
2279            "!active\n\
2280            _movable\n  \
2281              speed 10\n  \
2282              can_move true\n\
2283            _damageable\n  \
2284              hp 100\n  \
2285              armor 5\n\
2286            tank:inherit:_movable:_damageable\n  \
2287              name Tank\n  \
2288              armor 20"
2289        );
2290        resolve(&mut r, &Options::default());
2291        let map = r.root.as_object().unwrap();
2292
2293        assert!(!map.contains_key("_movable"));
2294        assert!(!map.contains_key("_damageable"));
2295
2296        let tank = map["tank"].as_object().unwrap();
2297        assert_eq!(tank["speed"], Value::Int(10));        // from _movable
2298        assert_eq!(tank["can_move"], Value::Bool(true));   // from _movable
2299        assert_eq!(tank["hp"], Value::Int(100));           // from _damageable
2300        assert_eq!(tank["armor"], Value::Int(20));         // child overrides _damageable's 5
2301        assert_eq!(tank["name"], Value::String("Tank".into()));
2302    }
2303
2304    #[test]
2305    fn test_calc_dot_path() {
2306        let mut r = parse(
2307            "!active\n\
2308            stats\n  \
2309              base_hp 100\n  \
2310              multiplier 3\n\
2311            total_hp:calc stats.base_hp * stats.multiplier"
2312        );
2313        resolve(&mut r, &Options::default());
2314        let map = r.root.as_object().unwrap();
2315        assert_eq!(map["total_hp"], Value::Int(300));
2316    }
2317
2318    #[test]
2319    fn test_i18n_plural_en() {
2320        let mut r = parse(
2321            "!active\n\
2322            count 5\n\
2323            items:i18n:count\n  \
2324              en\n    \
2325                one item\n    \
2326                other items"
2327        );
2328        let opts = Options { lang: Some("en".into()), ..Default::default() };
2329        resolve(&mut r, &opts);
2330        let map = r.root.as_object().unwrap();
2331        assert_eq!(map["items"], Value::String("items".into()));
2332    }
2333
2334    #[test]
2335    fn test_i18n_plural_en_one() {
2336        let mut r = parse(
2337            "!active\n\
2338            count 1\n\
2339            items:i18n:count\n  \
2340              en\n    \
2341                one {count} item\n    \
2342                other {count} items"
2343        );
2344        let opts = Options { lang: Some("en".into()), ..Default::default() };
2345        resolve(&mut r, &opts);
2346        let map = r.root.as_object().unwrap();
2347        assert_eq!(map["items"], Value::String("1 item".into()));
2348    }
2349
2350    #[test]
2351    fn test_i18n_plural_ru() {
2352        let mut r = parse(
2353            "!active\n\
2354            count 3\n\
2355            items:i18n:count\n  \
2356              ru\n    \
2357                one предмет\n    \
2358                few предмета\n    \
2359                many предметов\n    \
2360                other предметов"
2361        );
2362        let opts = Options { lang: Some("ru".into()), ..Default::default() };
2363        resolve(&mut r, &opts);
2364        let map = r.root.as_object().unwrap();
2365        assert_eq!(map["items"], Value::String("предмета".into()));
2366    }
2367
2368    #[test]
2369    fn test_quoted_null_preserved() {
2370        let r = parse("status \"null\"\nenabled \"true\"\ncount \"42\"");
2371        let map = r.root.as_object().unwrap();
2372        assert_eq!(map["status"], Value::String("null".into()));
2373        assert_eq!(map["enabled"], Value::String("true".into()));
2374        assert_eq!(map["count"], Value::String("42".into()));
2375    }
2376
2377    #[test]
2378    fn test_unquoted_null_is_null() {
2379        let r = parse("status null\nenabled true\ncount 42");
2380        let map = r.root.as_object().unwrap();
2381        assert_eq!(map["status"], Value::Null);
2382        assert_eq!(map["enabled"], Value::Bool(true));
2383        assert_eq!(map["count"], Value::Int(42));
2384    }
2385
2386    #[test]
2387    fn test_spam_rate_limit_exceeded() {
2388        super::clear_spam_buckets();
2389
2390        let mut r1 = parse("!active\nsecret_token abc\naccess:spam:1:5 secret_token");
2391        resolve(&mut r1, &Options::default());
2392        let map1 = r1.root.as_object().unwrap();
2393        assert_eq!(map1["access"], Value::String("abc".into()));
2394
2395        let mut r2 = parse("!active\nsecret_token abc\naccess:spam:1:5 secret_token");
2396        resolve(&mut r2, &Options::default());
2397        let map2 = r2.root.as_object().unwrap();
2398        match &map2["access"] {
2399            Value::String(s) => assert!(s.starts_with("SPAM_ERR:")),
2400            _ => panic!("Expected SPAM_ERR string"),
2401        }
2402    }
2403
2404    #[test]
2405    fn test_spam_default_window_sec_is_one() {
2406        super::clear_spam_buckets();
2407
2408        let mut r = parse("!active\na 1\nx:spam:2 a");
2409        resolve(&mut r, &Options::default());
2410        let map = r.root.as_object().unwrap();
2411        assert_eq!(map["x"], Value::Int(1));
2412    }
2413
2414    #[test]
2415    fn test_deep_nesting_does_not_overflow() {
2416        // Deep indentation chain: parser caps nesting (see `MAX_PARSE_NESTING_DEPTH` in parser);
2417        // this test only checks resolve + navigation do not panic and yield a bounded tree.
2418        let mut synx = String::from("!active\n");
2419        let mut indent = String::new();
2420        for i in 0..200 {
2421            synx.push_str(&format!("{}level_{}\n", indent, i));
2422            indent.push_str("  ");
2423        }
2424        synx.push_str(&format!("{}value deep\n", indent));
2425
2426        let mut result = parse(&synx);
2427        resolve(&mut result, &Default::default());
2428        assert!(matches!(result.root, Value::Object(_)));
2429
2430        let mut cur = &result.root;
2431        let mut depth = 0usize;
2432        loop {
2433            let Value::Object(map) = cur else { break };
2434            let key = format!("level_{}", depth);
2435            match map.get(&key) {
2436                Some(next) => {
2437                    cur = next;
2438                    depth += 1;
2439                }
2440                None => break,
2441            }
2442        }
2443        assert!(
2444            depth >= 100,
2445            "expected at least 100 chained levels from parse, got {}",
2446            depth
2447        );
2448        assert!(
2449            depth <= 130,
2450            "parser nesting cap should keep chain shallow, got {}",
2451            depth
2452        );
2453    }
2454
2455    #[test]
2456    fn test_circular_alias_returns_error() {
2457        let mut r = parse("!active\na:alias b\nb:alias a");
2458        resolve(&mut r, &Default::default());
2459        let root = r.root.as_object().unwrap();
2460        let a_val = root.get("a").unwrap();
2461        let b_val = root.get("b").unwrap();
2462        assert!(
2463            matches!(a_val, Value::String(s) if s.starts_with("ALIAS_ERR:")),
2464            "expected ALIAS_ERR for 'a', got: {:?}", a_val
2465        );
2466        assert!(
2467            matches!(b_val, Value::String(s) if s.starts_with("ALIAS_ERR:")),
2468            "expected ALIAS_ERR for 'b', got: {:?}", b_val
2469        );
2470    }
2471
2472    #[test]
2473    fn test_self_alias_returns_error() {
2474        let mut r = parse("!active\na:alias a");
2475        resolve(&mut r, &Default::default());
2476        let root = r.root.as_object().unwrap();
2477        let a_val = root.get("a").unwrap();
2478        assert!(
2479            matches!(a_val, Value::String(s) if s.starts_with("ALIAS_ERR:")),
2480            "expected ALIAS_ERR for self-alias, got: {:?}", a_val
2481        );
2482    }
2483
2484    #[test]
2485    fn test_valid_alias_still_works() {
2486        let mut r = parse("!active\nbase 42\ncopy:alias base");
2487        resolve(&mut r, &Default::default());
2488        let root = r.root.as_object().unwrap();
2489        assert_eq!(root.get("copy"), Some(&Value::Int(42)));
2490    }
2491
2492    #[test]
2493    fn test_alias_to_string_valued_key_no_false_positive() {
2494        // 'a' holds a literal string "b". 'b' aliases 'a'.
2495        // b should resolve to "b" — NOT trigger ALIAS_ERR.
2496        let mut r = parse("!active\na b\nb:alias a");
2497        resolve(&mut r, &Default::default());
2498        let root = r.root.as_object().unwrap();
2499        assert_eq!(
2500            root.get("b"),
2501            Some(&Value::String("b".to_string())),
2502            "alias to a string-valued key should not produce ALIAS_ERR"
2503        );
2504    }
2505
2506    #[test]
2507    fn test_prompt_marker() {
2508        let mut r = parse("!active\nmemory:prompt:Core\n  identity ASAI\n  creator APERTURESyndicate");
2509        resolve(&mut r, &Options::default());
2510        let map = r.root.as_object().unwrap();
2511        if let Value::String(s) = &map["memory"] {
2512            assert!(s.starts_with("Core (SYNX):"));
2513            assert!(s.contains("```synx"));
2514            assert!(s.contains("identity ASAI"));
2515        } else {
2516            panic!("Expected :prompt to produce a string");
2517        }
2518    }
2519
2520    #[test]
2521    fn test_vision_marker_passthrough() {
2522        let mut r = parse("!active\nimage:vision Generate a sunset");
2523        resolve(&mut r, &Options::default());
2524        let map = r.root.as_object().unwrap();
2525        assert_eq!(map["image"], Value::String("Generate a sunset".into()));
2526    }
2527
2528    #[test]
2529    fn test_audio_marker_passthrough() {
2530        let mut r = parse("!active\nnarration:audio Read this summary aloud");
2531        resolve(&mut r, &Options::default());
2532        let map = r.root.as_object().unwrap();
2533        assert_eq!(map["narration"], Value::String("Read this summary aloud".into()));
2534    }
2535
2536    #[test]
2537    fn test_use_directive_loads_package() {
2538        // Set up a temp package for `!use`
2539        let tmp = std::env::temp_dir().join("synx-use-test-load");
2540        let _ = std::fs::remove_dir_all(&tmp);
2541        let pkg_dir = tmp.join("synx_packages/@test/config");
2542        std::fs::create_dir_all(pkg_dir.join("src")).unwrap();
2543        std::fs::write(pkg_dir.join("synx-pkg.synx"), "name @test/config\nversion 1.0.0\nmain src/main.synx\n").unwrap();
2544        std::fs::write(pkg_dir.join("src/main.synx"), "identity APERTURESyndicate\ndefault_port 8080\n").unwrap();
2545
2546        let mut r = parse("!active\n!use @test/config\napp MyApp");
2547        let opts = Options {
2548            base_path: Some(tmp.to_string_lossy().into_owned()),
2549            ..Default::default()
2550        };
2551        resolve(&mut r, &opts);
2552        let map = r.root.as_object().unwrap();
2553        assert!(map.contains_key("config"), "package not loaded");
2554        let pkg = map["config"].as_object().unwrap();
2555        assert_eq!(pkg["identity"], Value::String("APERTURESyndicate".into()));
2556        assert_eq!(pkg["default_port"], Value::Int(8080));
2557        assert_eq!(map["app"], Value::String("MyApp".into()));
2558        let _ = std::fs::remove_dir_all(&tmp);
2559    }
2560
2561    #[test]
2562    fn test_use_directive_with_alias() {
2563        let tmp = std::env::temp_dir().join("synx-use-test-alias");
2564        let _ = std::fs::remove_dir_all(&tmp);
2565        let pkg_dir = tmp.join("synx_packages/@test/config");
2566        std::fs::create_dir_all(pkg_dir.join("src")).unwrap();
2567        std::fs::write(pkg_dir.join("synx-pkg.synx"), "name @test/config\nversion 1.0.0\nmain src/main.synx\n").unwrap();
2568        std::fs::write(pkg_dir.join("src/main.synx"), "identity APERTURESyndicate\ndefault_port 8080\n").unwrap();
2569
2570        let mut r = parse("!active\n!use @test/config as defaults\napp MyApp");
2571        let opts = Options {
2572            base_path: Some(tmp.to_string_lossy().into_owned()),
2573            ..Default::default()
2574        };
2575        resolve(&mut r, &opts);
2576        let map = r.root.as_object().unwrap();
2577        assert!(map.contains_key("defaults"), "aliased package not loaded");
2578        assert!(!map.contains_key("config"), "should use alias, not auto name");
2579        let pkg = map["defaults"].as_object().unwrap();
2580        assert_eq!(pkg["identity"], Value::String("APERTURESyndicate".into()));
2581        let _ = std::fs::remove_dir_all(&tmp);
2582    }
2583
2584    #[test]
2585    fn test_use_directive_missing_package_ignored() {
2586        let mut r = parse("!active\n!use @nonexistent/pkg\napp MyApp");
2587        resolve(&mut r, &Options::default());
2588        let map = r.root.as_object().unwrap();
2589        // Missing package should be silently skipped
2590        assert!(!map.contains_key("pkg"));
2591        assert_eq!(map["app"], Value::String("MyApp".into()));
2592    }
2593
2594    #[test]
2595    fn test_use_reads_manifest_main_field() {
2596        let tmp = std::env::temp_dir().join("synx-use-test-main");
2597        let _ = std::fs::remove_dir_all(&tmp);
2598        let pkg_dir = tmp.join("synx_packages/@test/myapp");
2599        std::fs::create_dir_all(pkg_dir.join("src")).unwrap();
2600        std::fs::write(pkg_dir.join("synx-pkg.synx"), "name @test/myapp\nversion 1.0.0\nmain src/main.synx\n").unwrap();
2601        std::fs::write(pkg_dir.join("src/main.synx"), "app_name MyApp\nversion 2.0.0\n").unwrap();
2602
2603        let mut r = parse("!active\n!use @test/myapp as myapp\napp Test");
2604        let opts = Options {
2605            base_path: Some(tmp.to_string_lossy().into_owned()),
2606            ..Default::default()
2607        };
2608        resolve(&mut r, &opts);
2609        let map = r.root.as_object().unwrap();
2610        assert!(map.contains_key("myapp"), "package not loaded via manifest");
2611        let pkg = map["myapp"].as_object().unwrap();
2612        assert_eq!(pkg["app_name"], Value::String("MyApp".into()));
2613        let _ = std::fs::remove_dir_all(&tmp);
2614    }
2615
2616    #[test]
2617    fn test_read_manifest_main_function() {
2618        let tmp = std::env::temp_dir().join("synx-manifest-main-test");
2619        let _ = std::fs::remove_dir_all(&tmp);
2620        std::fs::create_dir_all(tmp.join("src")).unwrap();
2621        std::fs::write(tmp.join("synx-pkg.synx"), "name @test/pkg\nversion 1.0.0\nmain src/main.synx\n").unwrap();
2622        std::fs::write(tmp.join("src/main.synx"), "key value\n").unwrap();
2623
2624        let result = read_manifest_main(&tmp);
2625        assert!(result.is_some(), "should read main from synx-pkg.synx");
2626        let path = result.unwrap();
2627        assert!(path.ends_with("src/main.synx") || path.ends_with("src\\main.synx"));
2628        let _ = std::fs::remove_dir_all(&tmp);
2629    }
2630}