diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a185092..16c3fc0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,6 @@ - + # NTNT Language - GitHub Copilot Instructions @@ -29,6 +29,40 @@ ntnt test server.tnt --get /health --post /users --body 'name=Alice' --- +## Type Safety Modes (v0.4.0+) + +Two independent axes for type control: + +**Runtime (`NTNT_TYPE_MODE`):** Controls behavior on type mismatches at runtime. +- `strict` — crash on mismatch (use for auth/payment apps) +- `warn` — log `[WARN]` and continue **(default)** +- `forgiving` — silent degradation + +**Lint (`NTNT_LINT_MODE`):** Controls annotation requirements. +- `default` — only check annotated code **(default)** +- `warn` — also warn about missing annotations (`--warn-untyped`) +- `strict` — missing annotations are errors (`--strict`) + +```bash +# Recommended for production apps with auth: +NTNT_TYPE_MODE=strict NTNT_LINT_MODE=strict ntnt run server.tnt + +# Development: +NTNT_TYPE_MODE=warn ntnt run server.tnt +``` + +**Type syntax (v0.4.0+):** +- Optional shorthand: `fn find(id: Int) -> User?` (equivalent to `Optional`) +- Type aliases: `type UserId = Int`, `type Handler = (Request) -> Response` +- Array types: `fn sum(nums: [Int]) -> Int` +- Generics: `fn identity(x: T) -> T` — type checker infers concrete types from call args + +**Error messages** include file:line, source snippets, expected/got context, and fix hints. + +`NTNT_STRICT` is deprecated — use `NTNT_LINT_MODE=strict`. + +--- + ## Intent-Driven Development (IDD) IDD is **the core workflow** for NTNT development. You capture user requirements as executable specifications, then implement code that satisfies them. diff --git a/CLAUDE.md b/CLAUDE.md index 0ef2442..b60064d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ - + # NTNT Language - Claude Code Instructions @@ -49,6 +49,40 @@ ntnt test server.tnt --get /health --post /users --body 'name=Alice' --- +## Type Safety Modes (v0.4.0+) + +Two independent axes for type control: + +**Runtime (`NTNT_TYPE_MODE`):** Controls behavior on type mismatches at runtime. +- `strict` — crash on mismatch (use for auth/payment apps) +- `warn` — log `[WARN]` and continue **(default)** +- `forgiving` — silent degradation + +**Lint (`NTNT_LINT_MODE`):** Controls annotation requirements. +- `default` — only check annotated code **(default)** +- `warn` — also warn about missing annotations (`--warn-untyped`) +- `strict` — missing annotations are errors (`--strict`) + +```bash +# Recommended for production apps with auth: +NTNT_TYPE_MODE=strict NTNT_LINT_MODE=strict ntnt run server.tnt + +# Development: +NTNT_TYPE_MODE=warn ntnt run server.tnt +``` + +**Type syntax (v0.4.0+):** +- Optional shorthand: `fn find(id: Int) -> User?` (equivalent to `Optional`) +- Type aliases: `type UserId = Int`, `type Handler = (Request) -> Response` +- Array types: `fn sum(nums: [Int]) -> Int` +- Generics: `fn identity(x: T) -> T` — type checker infers concrete types from call args + +**Error messages** include file:line, source snippets, expected/got context, and fix hints. + +`NTNT_STRICT` is deprecated — use `NTNT_LINT_MODE=strict`. + +--- + ## Intent-Driven Development (IDD) IDD is **the core workflow** for NTNT development. You capture user requirements as executable specifications, then implement code that satisfies them. diff --git a/Cargo.lock b/Cargo.lock index 4269418..acfc1bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1607,7 +1607,7 @@ dependencies = [ [[package]] name = "ntnt" -version = "0.3.17" +version = "0.4.0" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 2830827..17ecbae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ntnt" -version = "0.3.17" +version = "0.4.0" edition = "2021" authors = ["NTNT Language Team"] description = "NTNT (Intent) - A programming language designed for AI-driven development" diff --git a/README.md b/README.md index 2fa7336..05c5742 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ NTNT explores a different approach. Requirements are executable specifications w | **Intent-Driven Development** | Write requirements in `.intent` files. Link code with `@implements`. Run `ntnt intent check` to verify. Full traceability from requirement to implementation. | | **Design by Contract** | `requires` and `ensures` built into function syntax. In HTTP routes, contract violations return 400/500 automatically. | | **Agent-Native Tooling** | `ntnt inspect` outputs JSON describing every function, route, and contract. `ntnt validate` returns machine-readable errors. | -| **Gradual Type System** | Optional type annotations with inference. Types catch structural errors at lint time; contracts catch semantic errors. Strict mode for full enforcement. | +| **Gradual Type System** | Optional type annotations with inference, generics (`fn identity(x: T) -> T`), type aliases (`type Handler = (Request) -> Response`), and `T?` shorthand. Two independent axes: **lint mode** (`--warn-untyped` / `--strict`) controls static analysis depth; **runtime mode** (`NTNT_TYPE_MODE=strict\|warn\|forgiving`) controls what happens on type mismatches. | | **Batteries Included** | HTTP servers, PostgreSQL, SQLite, JSON, CSV, file I/O, crypto, concurrency - all in the standard library. No package manager needed. | | **Hot Reload** | HTTP servers reload automatically when you save. Edit code, refresh browser, see changes. | @@ -160,7 +160,8 @@ fn withdraw(amount: Int) -> Int ```bash ntnt run # Run a .tnt file ntnt lint # Check for errors -ntnt lint --strict # Check with strict type warnings +ntnt lint --warn-untyped # Warn on missing type annotations +ntnt lint --strict # Require type annotations (errors) ntnt validate # Validate with JSON output ntnt test --get / # Quick HTTP endpoint testing ntnt intent check # Verify code matches intent diff --git a/docs/AI_AGENT_GUIDE.md b/docs/AI_AGENT_GUIDE.md index afcfeaf..10a7935 100644 --- a/docs/AI_AGENT_GUIDE.md +++ b/docs/AI_AGENT_GUIDE.md @@ -22,6 +22,40 @@ ntnt test server.tnt --get /health --post /users --body 'name=Alice' --- +## Type Safety Modes (v0.4.0+) + +Two independent axes for type control: + +**Runtime (`NTNT_TYPE_MODE`):** Controls behavior on type mismatches at runtime. +- `strict` — crash on mismatch (use for auth/payment apps) +- `warn` — log `[WARN]` and continue **(default)** +- `forgiving` — silent degradation + +**Lint (`NTNT_LINT_MODE`):** Controls annotation requirements. +- `default` — only check annotated code **(default)** +- `warn` — also warn about missing annotations (`--warn-untyped`) +- `strict` — missing annotations are errors (`--strict`) + +```bash +# Recommended for production apps with auth: +NTNT_TYPE_MODE=strict NTNT_LINT_MODE=strict ntnt run server.tnt + +# Development: +NTNT_TYPE_MODE=warn ntnt run server.tnt +``` + +**Type syntax (v0.4.0+):** +- Optional shorthand: `fn find(id: Int) -> User?` (equivalent to `Optional`) +- Type aliases: `type UserId = Int`, `type Handler = (Request) -> Response` +- Array types: `fn sum(nums: [Int]) -> Int` +- Generics: `fn identity(x: T) -> T` — type checker infers concrete types from call args + +**Error messages** include file:line, source snippets, expected/got context, and fix hints. + +`NTNT_STRICT` is deprecated — use `NTNT_LINT_MODE=strict`. + +--- + ## Intent-Driven Development (IDD) IDD is **the core workflow** for NTNT development. You capture user requirements as executable specifications, then implement code that satisfies them. diff --git a/docs/IAL_REFERENCE.md b/docs/IAL_REFERENCE.md index bc10adf..4ca37d8 100644 --- a/docs/IAL_REFERENCE.md +++ b/docs/IAL_REFERENCE.md @@ -2,7 +2,7 @@ > **Auto-generated from [ial.toml](ial.toml)** - Do not edit directly. > -> Last updated: v0.3.17 +> Last updated: v0.4.0 IAL is a term rewriting engine that translates natural language assertions into executable tests diff --git a/docs/RUNTIME_REFERENCE.md b/docs/RUNTIME_REFERENCE.md index ac535c2..c7b4516 100644 --- a/docs/RUNTIME_REFERENCE.md +++ b/docs/RUNTIME_REFERENCE.md @@ -2,7 +2,7 @@ > **Auto-generated from [runtime.toml](runtime.toml)** - Do not edit directly. > -> Last updated: v0.3.17 +> Last updated: v0.4.0 Runtime configuration, environment variables, and CLI commands for NTNT @@ -25,9 +25,11 @@ Environment variables that control NTNT runtime behavior |----------|--------|---------|-------------| | `NTNT_ALLOW_PRIVATE_IPS` | `true` | unset (disabled — private IPs blocked) | Allow `fetch()` to connect to private/internal IP ranges (10.x, 172.16-31.x, 192.168.x, 127.x). Required for Docker inter-container communication (e.g., calling a sidecar at 172.19.0.1). Disabled by default to prevent SSRF attacks. | | `NTNT_ENV` | `development`, `production`, `prod` | development (when unset) | Controls runtime mode. Production mode disables hot-reload for better performance. | +| `NTNT_LINT_MODE` | `default`, `warn`, `strict` | default | Controls lint strictness for type annotations. `default`: only check annotated code. `warn`: also warn about missing annotations (non-fatal). `strict`: missing annotations are errors. CLI flags (`--strict`, `--warn-untyped`) override this. | | `NTNT_MAX_RECURSION` | integer | 256 | Maximum recursion depth for function calls. Prevents stack overflow from runaway recursion. | -| `NTNT_STRICT` | `1`, `true` | unset (disabled) | Enable strict type checking. For `ntnt run`, blocks execution if type errors are found. For `ntnt lint`, warns about untyped function signatures. Also configurable via `ntnt lint --strict` or `ntnt.toml` config. | +| `NTNT_STRICT` | `1`, `true` | unset (disabled) | **Deprecated — use `NTNT_LINT_MODE=strict` instead.** Enable strict type checking. Still works but emits a deprecation warning. | | `NTNT_TIMEOUT` | integer (seconds) | 30 | Request timeout for HTTP server in seconds. | +| `NTNT_TYPE_MODE` | `strict`, `warn`, `forgiving` | warn | Controls runtime behavior for type mismatches. `strict`: type mismatches crash (fail-closed, recommended for auth/payments). `warn`: log `[WARN]` and continue (default). `forgiving`: silent degradation (pre-v0.4 behavior). See [Type Safety Modes](#type-safety-modes). | ### Examples @@ -38,15 +40,97 @@ NTNT_ALLOW_PRIVATE_IPS=true ntnt run server.tnt # Controls runtime mode NTNT_ENV=production ntnt run server.tnt +# Controls lint strictness for type annotations +NTNT_LINT_MODE=strict ntnt lint server.tnt + # Maximum recursion depth for function calls NTNT_MAX_RECURSION=512 ntnt run server.tnt -# Enable strict type checking +# **Deprecated — use `NTNT_LINT_MODE=strict` instead.** Enable strict type checking NTNT_STRICT=1 ntnt run server.tnt # Request timeout for HTTP server in seconds. NTNT_TIMEOUT=60 ntnt run server.tnt +# Controls runtime behavior for type mismatches +NTNT_TYPE_MODE=strict ntnt run server.tnt + +``` + +--- + +## Type Safety Modes + +ntnt provides two independent axes for type control, configured via environment variables. + +### Axis 1: Runtime Type Mode (`NTNT_TYPE_MODE`) + +Controls what happens when types mismatch at runtime (e.g., bad data from a database, wrong API response type). + +| Mode | Behavior | Use Case | +|------|----------|----------| +| `strict` | Type mismatches crash the request (fail-closed) | Apps with auth, payments, safety-critical logic | +| `warn` | Log `[WARN]` to stderr and continue **(default)** | Production apps with log monitoring | +| `forgiving` | Silent degradation, no warnings | Content sites where uptime > correctness | + +```bash +# Crash on type mismatches (safest) +NTNT_TYPE_MODE=strict ntnt run server.tnt + +# Log warnings and continue (default) +NTNT_TYPE_MODE=warn ntnt run server.tnt + +# Silent degradation (pre-v0.4 behavior) +NTNT_TYPE_MODE=forgiving ntnt run server.tnt +``` + +**Affected operations:** index (`[]`) type mismatch, `for..in` on non-collections, field access on wrong types, template expression errors. Warnings are deduplicated per evaluation context — the same mismatch won't spam 50 times in a loop. + +**Security note:** Apps with authentication, authorization, or financial logic should use `strict`. Forgiving mode is fail-open — a type mismatch on a permission check can silently bypass it. + +### Axis 2: Lint Mode (`NTNT_LINT_MODE`) + +Controls how the linter treats missing type annotations. + +| Mode | Behavior | CI Exit Code | +|------|----------|--------------| +| `default` | Only report errors in annotated code | 0 (if no type conflicts) | +| `warn` | Also warn about missing annotations | 0 (warnings are non-fatal) | +| `strict` | Missing annotations are errors | Non-zero on missing annotations | + +```bash +# Default: only check annotated code +ntnt lint app.tnt + +# Warn about missing annotations (non-fatal) +ntnt lint --warn-untyped app.tnt +NTNT_LINT_MODE=warn ntnt lint app.tnt + +# Require all annotations (CI-safe) +ntnt lint --strict app.tnt +NTNT_LINT_MODE=strict ntnt lint app.tnt +``` + +### Precedence + +``` +CLI flag > Environment variable > ntnt.toml > built-in default +``` + +### Docker Configuration + +```yaml +# SaaS app with auth + payments +environment: + - NTNT_TYPE_MODE=strict + - NTNT_LINT_MODE=strict + - NTNT_ENV=production + +# Content site / blog +environment: + - NTNT_TYPE_MODE=warn + - NTNT_LINT_MODE=default + - NTNT_ENV=production ``` --- @@ -198,7 +282,8 @@ Check source file(s) for syntax errors and common mistakes |--------|------|---------|-------------| | `--quiet`, `-q` | flag | - | Show only errors, not warnings or suggestions | | `--fix` | flag | - | Output auto-fix suggestions as JSON patch | -| `--strict` | flag | - | Warn about untyped function parameters and missing return types (also: NTNT_STRICT=1 or ntnt.toml) | +| `--warn-untyped` | flag | - | Enable strict typechecker warnings without failing the build: warns on missing type annotations and other strict-mode issues (e.g., Float→Int precision-loss, complex interpolation). Exit code remains 0. Also: `NTNT_LINT_MODE=warn`. | +| `--strict` | flag | - | Require type annotations on all functions — missing annotations are errors (non-zero exit). Also: `NTNT_LINT_MODE=strict`. Replaces deprecated `NTNT_STRICT`. | **Example:** ```bash diff --git a/docs/STDLIB_REFERENCE.md b/docs/STDLIB_REFERENCE.md index fc1aa1d..38e3a7f 100644 --- a/docs/STDLIB_REFERENCE.md +++ b/docs/STDLIB_REFERENCE.md @@ -2,7 +2,7 @@ > **Auto-generated from source code doc comments** - Do not edit directly. > -> Last updated: v0.3.17 +> Last updated: v0.4.0 ## Table of Contents diff --git a/docs/SYNTAX_REFERENCE.md b/docs/SYNTAX_REFERENCE.md index ba0a80a..42448ff 100644 --- a/docs/SYNTAX_REFERENCE.md +++ b/docs/SYNTAX_REFERENCE.md @@ -2,7 +2,7 @@ > **Auto-generated from [syntax.toml](syntax.toml)** - Do not edit directly. > -> Last updated: v0.3.17 +> Last updated: v0.4.0 ## Table of Contents @@ -279,6 +279,36 @@ Syntax: `let x: Type = value` Optional type annotations on variables +### OPTIONAL SHORTHAND + +Syntax: `T?` + +Shorthand for Optional in type annotations + +### TYPE ALIAS + +Syntax: `type Name = Type` + +Type alias declaration + +### FUNCTION TYPE + +Syntax: `(ParamTypes) -> ReturnType` + +Function type annotation for parameters and type aliases + +### ARRAY TYPE + +Syntax: `[ElementType]` + +Array type annotation + +### GENERICS + +Syntax: `fn name(param: T) -> T { }` + +Generic type parameters on functions + --- ## Imports diff --git a/docs/runtime.toml b/docs/runtime.toml index 6139414..4c3044f 100644 --- a/docs/runtime.toml +++ b/docs/runtime.toml @@ -41,13 +41,104 @@ description = "Maximum recursion depth for function calls. Prevents stack overfl example = "NTNT_MAX_RECURSION=512 ntnt run server.tnt" affects = ["runtime"] +[env_vars.NTNT_TYPE_MODE] +values = ["strict", "warn", "forgiving"] +default = "warn" +description = "Controls runtime behavior for type mismatches. `strict`: type mismatches crash (fail-closed, recommended for auth/payments). `warn`: log `[WARN]` and continue (default). `forgiving`: silent degradation (pre-v0.4 behavior). See [Type Safety Modes](#type-safety-modes)." +example = "NTNT_TYPE_MODE=strict ntnt run server.tnt" +affects = ["type-checking", "runtime"] + +[env_vars.NTNT_LINT_MODE] +values = ["default", "warn", "strict"] +default = "default" +description = "Controls lint strictness for type annotations. `default`: only check annotated code. `warn`: also warn about missing annotations (non-fatal). `strict`: missing annotations are errors. CLI flags (`--strict`, `--warn-untyped`) override this." +example = "NTNT_LINT_MODE=strict ntnt lint server.tnt" +affects = ["lint", "type-checking"] + [env_vars.NTNT_STRICT] values = ["1", "true"] default = "unset (disabled)" -description = "Enable strict type checking. For `ntnt run`, blocks execution if type errors are found. For `ntnt lint`, warns about untyped function signatures. Also configurable via `ntnt lint --strict` or `ntnt.toml` config." +description = "**Deprecated — use `NTNT_LINT_MODE=strict` instead.** Enable strict type checking. Still works but emits a deprecation warning." example = "NTNT_STRICT=1 ntnt run server.tnt" affects = ["type-checking", "lint"] +# ============================================================================= +# TYPE SAFETY MODES (DD-009) +# ============================================================================= + +[type_safety_modes] +content = """ntnt provides two independent axes for type control, configured via environment variables. + +### Axis 1: Runtime Type Mode (`NTNT_TYPE_MODE`) + +Controls what happens when types mismatch at runtime (e.g., bad data from a database, wrong API response type). + +| Mode | Behavior | Use Case | +|------|----------|----------| +| `strict` | Type mismatches crash the request (fail-closed) | Apps with auth, payments, safety-critical logic | +| `warn` | Log `[WARN]` to stderr and continue **(default)** | Production apps with log monitoring | +| `forgiving` | Silent degradation, no warnings | Content sites where uptime > correctness | + +```bash +# Crash on type mismatches (safest) +NTNT_TYPE_MODE=strict ntnt run server.tnt + +# Log warnings and continue (default) +NTNT_TYPE_MODE=warn ntnt run server.tnt + +# Silent degradation (pre-v0.4 behavior) +NTNT_TYPE_MODE=forgiving ntnt run server.tnt +``` + +**Affected operations:** index (`[]`) type mismatch, `for..in` on non-collections, field access on wrong types, template expression errors. Warnings are deduplicated per evaluation context — the same mismatch won't spam 50 times in a loop. + +**Security note:** Apps with authentication, authorization, or financial logic should use `strict`. Forgiving mode is fail-open — a type mismatch on a permission check can silently bypass it. + +### Axis 2: Lint Mode (`NTNT_LINT_MODE`) + +Controls how the linter treats missing type annotations. + +| Mode | Behavior | CI Exit Code | +|------|----------|--------------| +| `default` | Only report errors in annotated code | 0 (if no type conflicts) | +| `warn` | Also warn about missing annotations | 0 (warnings are non-fatal) | +| `strict` | Missing annotations are errors | Non-zero on missing annotations | + +```bash +# Default: only check annotated code +ntnt lint app.tnt + +# Warn about missing annotations (non-fatal) +ntnt lint --warn-untyped app.tnt +NTNT_LINT_MODE=warn ntnt lint app.tnt + +# Require all annotations (CI-safe) +ntnt lint --strict app.tnt +NTNT_LINT_MODE=strict ntnt lint app.tnt +``` + +### Precedence + +``` +CLI flag > Environment variable > ntnt.toml > built-in default +``` + +### Docker Configuration + +```yaml +# SaaS app with auth + payments +environment: + - NTNT_TYPE_MODE=strict + - NTNT_LINT_MODE=strict + - NTNT_ENV=production + +# Content site / blog +environment: + - NTNT_TYPE_MODE=warn + - NTNT_LINT_MODE=default + - NTNT_ENV=production +```""" + # ============================================================================= # HOT-RELOAD # ============================================================================= @@ -174,7 +265,8 @@ description = "Check source file(s) for syntax errors and common mistakes" options = [ { name = "--quiet", short = "-q", type = "flag", description = "Show only errors, not warnings or suggestions" }, { name = "--fix", short = "", type = "flag", description = "Output auto-fix suggestions as JSON patch" }, - { name = "--strict", short = "", type = "flag", description = "Warn about untyped function parameters and missing return types (also: NTNT_STRICT=1 or ntnt.toml)" } + { name = "--warn-untyped", short = "", type = "flag", description = "Enable strict typechecker warnings without failing the build: warns on missing type annotations and other strict-mode issues (e.g., Float→Int precision-loss, complex interpolation). Exit code remains 0. Also: `NTNT_LINT_MODE=warn`." }, + { name = "--strict", short = "", type = "flag", description = "Require type annotations on all functions — missing annotations are errors (non-zero exit). Also: `NTNT_LINT_MODE=strict`. Replaces deprecated `NTNT_STRICT`." } ] example = "ntnt lint server.tnt" diff --git a/docs/syntax.toml b/docs/syntax.toml index df76492..775690e 100644 --- a/docs/syntax.toml +++ b/docs/syntax.toml @@ -513,3 +513,30 @@ example = "fn add(a: Int, b: Int = 10) -> Int { return a + b }" syntax = "fn name(a = 0, b = a + 10) { }" description = "Default expressions can reference earlier parameters" example = "fn make_range(start = 0, end = start + 10) { }" + +# === Type System (v0.4.0) === + +[types.optional_shorthand] +syntax = "T?" +description = "Shorthand for Optional in type annotations" +example = "fn find(id: Int) -> User? { }" + +[types.type_alias] +syntax = "type Name = Type" +description = "Type alias declaration" +example = "type UserId = Int" + +[types.function_type] +syntax = "(ParamTypes) -> ReturnType" +description = "Function type annotation for parameters and type aliases" +example = "type Handler = (Request) -> Response" + +[types.array_type] +syntax = "[ElementType]" +description = "Array type annotation" +example = "fn sum(nums: [Int]) -> Int { }" + +[types.generics] +syntax = "fn name(param: T) -> T { }" +description = "Generic type parameters on functions" +example = "fn identity(x: T) -> T { return x }" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..633f2b1 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,132 @@ +//! Configuration for NTNT runtime and lint modes. +//! +//! Provides [`TypeMode`] (runtime behavior for type mismatches) and [`LintMode`] +//! (lint-time behavior for missing annotations), read from environment variables. +//! Values are cached via `OnceLock` in non-test builds (`#[cfg(not(test))]`); +//! re-read on every call in test builds so that tests can manipulate env vars +//! with isolation. + +/// Runtime type safety mode, controlled by the `NTNT_TYPE_MODE` env var. +/// +/// Controls how runtime type mismatches are handled in: +/// - Index operations (`[]`) on unexpected types +/// - `for..in` on non-collection values +/// - Template expression errors +/// +/// | Value | Behaviour | +/// |-------|-----------| +/// | `strict` | Type mismatches are runtime errors (program halts) | +/// | `warn` | Type mismatches emit a warning to stderr and continue **(default)** | +/// | `forgiving` | Type mismatches are silently ignored | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TypeMode { + /// Type mismatches are runtime errors — the program halts. + Strict, + /// Type mismatches emit `[WARN]` to stderr and continue (default). + Warn, + /// Type mismatches are silently ignored (pre-v0.4 behaviour). + Forgiving, +} + +/// Lint-time type annotation mode, controlled by `NTNT_LINT_MODE` or CLI flags. +/// +/// Controls how the linter treats functions without type annotations. +/// +/// | Value | Behaviour | +/// |-------|-----------| +/// | `default` | Only report type errors where annotations exist **(default)** | +/// | `warn` | Also emit warnings for functions missing type annotations | +/// | `strict` | Missing annotations are lint errors (non-zero exit code) | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LintMode { + /// Only report type errors where annotations exist (default behaviour). + Default, + /// Also emit warnings for functions missing type annotations. + Warn, + /// Missing annotations are lint errors (non-zero exit code). + Strict, +} + +fn read_type_mode_from_env() -> TypeMode { + match std::env::var("NTNT_TYPE_MODE").as_deref().unwrap_or("warn") { + "strict" => TypeMode::Strict, + "forgiving" => TypeMode::Forgiving, + _ => TypeMode::Warn, + } +} + +/// Get the current runtime type mode from the `NTNT_TYPE_MODE` env var. +/// +/// Default is [`TypeMode::Warn`]. In production builds the result is cached on +/// first call; in test builds it reads fresh from the environment every call +/// so tests can manipulate `NTNT_TYPE_MODE` with per-test isolation. +#[cfg(not(test))] +pub fn get_type_mode() -> TypeMode { + use std::sync::OnceLock; + static TYPE_MODE: OnceLock = OnceLock::new(); + *TYPE_MODE.get_or_init(read_type_mode_from_env) +} + +#[cfg(test)] +pub fn get_type_mode() -> TypeMode { + read_type_mode_from_env() +} + +fn read_lint_mode_from_env() -> LintMode { + match std::env::var("NTNT_LINT_MODE") + .as_deref() + .unwrap_or("default") + { + "warn" => LintMode::Warn, + "strict" => LintMode::Strict, + _ => LintMode::Default, + } +} + +/// Get the current lint mode from the `NTNT_LINT_MODE` env var. +/// +/// Default is [`LintMode::Default`]. CLI flags take precedence over this value +/// (caller is responsible for applying the override). In production builds the +/// result is cached; in test builds it reads fresh each call. +#[cfg(not(test))] +pub fn get_lint_mode() -> LintMode { + use std::sync::OnceLock; + static LINT_MODE: OnceLock = OnceLock::new(); + *LINT_MODE.get_or_init(read_lint_mode_from_env) +} + +#[cfg(test)] +pub fn get_lint_mode() -> LintMode { + read_lint_mode_from_env() +} + +use std::cell::RefCell; +use std::collections::HashSet; + +thread_local! { + /// Tracks already-warned type mismatch locations to prevent duplicate warnings + /// in the same evaluation context (e.g., a template for-loop iterating 50 bad rows). + static WARNED_LOCATIONS: RefCell> = RefCell::new(HashSet::new()); +} + +/// Log a type-mode warning to stderr, deduplicating by message key. +/// Returns `true` if the warning was emitted, `false` if it was suppressed +/// as a duplicate. +pub fn type_warn_dedup(key: &str, message: &str) -> bool { + WARNED_LOCATIONS.with(|warned| { + let mut set = warned.borrow_mut(); + if set.contains(key) { + false + } else { + set.insert(key.to_string()); + eprintln!("[WARN] {}", message); + true + } + }) +} + +/// Clear the warning dedup set. Call at the start of each request +/// or evaluation to allow warnings to fire again for the next request. +pub fn clear_type_warnings() { + WARNED_LOCATIONS.with(|warned| warned.borrow_mut().clear()); +} diff --git a/src/error.rs b/src/error.rs index 1fddd19..96afd08 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,6 +5,45 @@ use thiserror::Error; /// Result type alias for Intent operations pub type Result = std::result::Result; +/// Rich context for type mismatch errors. +/// +/// Provides structured expected/got information and optional hints, +/// inspired by Elm and Rust's error messages. +#[derive(Debug, Clone)] +pub struct TypeContext { + /// What type was expected + pub expected: String, + /// What type was actually found + pub got: String, + /// Optional hint for how to fix + pub hint: Option, +} + +impl TypeContext { + pub fn new(expected: impl Into, got: impl Into) -> Self { + Self { + expected: expected.into(), + got: got.into(), + hint: None, + } + } + + pub fn with_hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } +} + +impl std::fmt::Display for TypeContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "expected {}, found {}", self.expected, self.got)?; + if let Some(hint) = &self.hint { + write!(f, "\n hint: {}", hint)?; + } + Ok(()) + } +} + /// Main error type for Intent language operations #[derive(Error, Debug)] pub enum IntentError { @@ -22,25 +61,39 @@ pub enum IntentError { message: String, }, - #[error("Type error: {0}")] - TypeError(String), + #[error("Type error: {message}")] + TypeError { + message: String, + context: Option, + /// Source line (set by interpreter from current_line, 0 = unknown) + line: usize, + }, #[error("Contract violation: {0}")] ContractViolation(String), - #[error("Runtime error: {0}")] - RuntimeError(String), + #[error("Runtime error: {message}")] + RuntimeError { + message: String, + context: Option, + /// Source line (set by interpreter from current_line, 0 = unknown) + line: usize, + }, #[error("Undefined variable: {name}")] UndefinedVariable { name: String, suggestion: Option, + + line: usize, }, #[error("Undefined function: {name}")] UndefinedFunction { name: String, suggestion: Option, + + line: usize, }, #[error("Arity mismatch: function '{name}' expected {expected} arguments, got {got}")] @@ -48,6 +101,8 @@ pub enum IntentError { name: String, expected: String, got: usize, + + line: usize, }, #[error("Division by zero")] @@ -64,14 +119,97 @@ pub enum IntentError { } impl IntentError { + /// Create a TypeError with just a message (backward compatible) + pub fn type_error(message: impl Into) -> Self { + IntentError::TypeError { + message: message.into(), + context: None, + line: 0, + } + } + + /// Create a TypeError with rich context (expected/got/hint) + pub fn type_error_with_context(message: impl Into, context: TypeContext) -> Self { + IntentError::TypeError { + message: message.into(), + context: Some(context), + line: 0, + } + } + + /// Create a RuntimeError with just a message (backward compatible) + pub fn runtime_error(message: impl Into) -> Self { + IntentError::RuntimeError { + message: message.into(), + context: None, + line: 0, + } + } + + /// Create a RuntimeError with rich context (expected/got/hint) + pub fn runtime_error_with_context(message: impl Into, context: TypeContext) -> Self { + IntentError::RuntimeError { + message: message.into(), + context: Some(context), + line: 0, + } + } + + /// Set the line number on a TypeError or RuntimeError (builder pattern). + /// Usage: `IntentError::type_error("msg").at_line(42)` + pub fn at_line(mut self, line: usize) -> Self { + match &mut self { + IntentError::TypeError { line: l, .. } => *l = line, + IntentError::RuntimeError { line: l, .. } => *l = line, + IntentError::UndefinedVariable { line: l, .. } => *l = line, + IntentError::UndefinedFunction { line: l, .. } => *l = line, + IntentError::ArityMismatch { line: l, .. } => *l = line, + _ => {} + } + self + } + + /// Get the TypeContext if this error has one + pub fn type_context(&self) -> Option<&TypeContext> { + match self { + IntentError::TypeError { context, .. } => context.as_ref(), + IntentError::RuntimeError { context, .. } => context.as_ref(), + _ => None, + } + } + + /// Format the error with rich context for terminal display + pub fn rich_display(&self) -> String { + let base = self.to_string(); + match self.type_context() { + Some(ctx) => { + let mut out = base; + out.push_str(&format!("\n │ expected: {}", ctx.expected)); + out.push_str(&format!("\n │ found: {}", ctx.got)); + if let Some(hint) = &ctx.hint { + out.push_str(&format!("\n │\n └─ hint: {}", hint)); + } + out + } + None => { + // Add suggestion for known error types + if let Some(suggestion) = self.suggestion() { + format!("{}\n └─ did you mean: {}?", base, suggestion) + } else { + base + } + } + } + } + /// Return a unique error code for this error variant pub fn error_code(&self) -> &'static str { match self { IntentError::LexerError { .. } => "E001", IntentError::ParserError { .. } => "E002", - IntentError::TypeError(_) => "E003", + IntentError::TypeError { .. } => "E003", IntentError::ContractViolation(_) => "E004", - IntentError::RuntimeError(_) => "E005", + IntentError::RuntimeError { .. } => "E005", IntentError::UndefinedVariable { .. } => "E006", IntentError::UndefinedFunction { .. } => "E007", IntentError::ArityMismatch { .. } => "E008", @@ -87,6 +225,11 @@ impl IntentError { match self { IntentError::LexerError { line, .. } => Some(*line), IntentError::ParserError { line, .. } => Some(*line), + IntentError::TypeError { line, .. } if *line > 0 => Some(*line), + IntentError::RuntimeError { line, .. } if *line > 0 => Some(*line), + IntentError::UndefinedVariable { line, .. } if *line > 0 => Some(*line), + IntentError::UndefinedFunction { line, .. } if *line > 0 => Some(*line), + IntentError::ArityMismatch { line, .. } if *line > 0 => Some(*line), _ => None, } } @@ -275,21 +418,24 @@ mod tests { column: 0, message: String::new(), }, - IntentError::TypeError(String::new()), + IntentError::type_error(""), IntentError::ContractViolation(String::new()), - IntentError::RuntimeError(String::new()), + IntentError::runtime_error(""), IntentError::UndefinedVariable { name: String::new(), suggestion: None, + line: 0, }, IntentError::UndefinedFunction { name: String::new(), suggestion: None, + line: 0, }, IntentError::ArityMismatch { name: String::new(), expected: "0".to_string(), got: 0, + line: 0, }, IntentError::DivisionByZero, IntentError::IndexOutOfBounds { @@ -317,7 +463,7 @@ mod tests { assert_eq!(e.line(), Some(42)); assert_eq!(e.column(), Some(10)); - let e = IntentError::RuntimeError("test".into()); + let e = IntentError::runtime_error("test"); assert_eq!(e.line(), None); assert_eq!(e.column(), None); } @@ -327,13 +473,60 @@ mod tests { let e = IntentError::UndefinedVariable { name: "usres".into(), suggestion: Some("users".into()), + line: 0, }; assert_eq!(e.suggestion(), Some("users")); let e = IntentError::UndefinedVariable { name: "xyz".into(), suggestion: None, + line: 0, }; assert_eq!(e.suggestion(), None); } + + #[test] + fn test_type_context_display() { + let ctx = TypeContext::new("Int", "String").with_hint("Use int(x) to convert"); + let display = format!("{}", ctx); + assert!(display.contains("expected Int")); + assert!(display.contains("found String")); + assert!(display.contains("hint: Use int(x) to convert")); + } + + #[test] + fn test_rich_display_with_context() { + let err = IntentError::type_error_with_context( + "Cannot index Array with String", + TypeContext::new("Int", "String").with_hint("Use int(key) or iterate instead"), + ); + let display = err.rich_display(); + assert!(display.contains("Cannot index Array with String")); + assert!(display.contains("expected: Int")); + assert!(display.contains("found: String")); + assert!(display.contains("hint: Use int(key)")); + } + + #[test] + fn test_rich_display_without_context() { + let err = IntentError::type_error("simple error"); + let display = err.rich_display(); + assert_eq!(display, "Type error: simple error"); + } + + #[test] + fn test_type_error_constructors() { + let e1 = IntentError::type_error("msg"); + assert!(matches!(e1, IntentError::TypeError { context: None, .. })); + + let e2 = IntentError::type_error_with_context("msg", TypeContext::new("Int", "String")); + assert!(matches!( + e2, + IntentError::TypeError { + context: Some(_), + .. + } + )); + assert!(e2.type_context().is_some()); + } } diff --git a/src/intent.rs b/src/intent.rs index fa8d8c8..6d7cf57 100644 --- a/src/intent.rs +++ b/src/intent.rs @@ -2772,8 +2772,9 @@ pub struct FeatureCoverage { impl IntentFile { /// Parse an intent file from a path pub fn parse(path: &Path) -> Result { - let content = fs::read_to_string(path) - .map_err(|e| IntentError::RuntimeError(format!("Failed to read intent file: {}", e)))?; + let content = fs::read_to_string(path).map_err(|e| { + IntentError::runtime_error(format!("Failed to read intent file: {}", e)) + })?; Self::parse_content(&content, path.to_string_lossy().to_string()) } @@ -3733,13 +3734,13 @@ pub fn run_intent_check( // Read NTNT source let source = fs::read_to_string(ntnt_path) - .map_err(|e| IntentError::RuntimeError(format!("Failed to read NTNT file: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to read NTNT file: {}", e)))?; // Count total tests let total_tests: usize = intent.features.iter().map(|f| f.tests.len()).sum(); if total_tests == 0 { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "No tests found in intent file".to_string(), )); } diff --git a/src/interpreter.rs b/src/interpreter.rs index 4c327b1..b3b3286 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -11,8 +11,9 @@ //! - `result` to reference the return value in postconditions use crate::ast::*; +use crate::config::{get_type_mode, TypeMode}; use crate::contracts::{ContractChecker, OldValues, StoredValue}; -use crate::error::{IntentError, Result}; +use crate::error::{IntentError, Result, TypeContext}; use std::cell::RefCell; use std::collections::HashMap; use std::fmt; @@ -492,6 +493,46 @@ fn is_production_mode() -> bool { }) } +/// # Type Error Categories (DD-009) +/// +/// The interpreter has ~130 `IntentError::TypeError` / `RuntimeError` / `InvalidOperation` +/// throw sites. Only a subset are gated behind `get_type_mode()` (the TypeMode-aware +/// boundaries). The rest are intentional hard errors. Here's the categorization: +/// +/// ## TypeMode-Aware (gated behind `get_type_mode()`) +/// These are **data boundary** errors — the mismatch comes from external data +/// (database, API, user input) rather than a code bug: +/// - Index type mismatch (`obj[key]` where types don't match) +/// - `for..in` on non-collection values +/// - Field access on non-struct/map +/// - Template expression/filter/for-loop errors +/// +/// ## Hard Errors — Code Bugs (always crash, TypeMode does NOT apply) +/// These indicate a bug in the source code, not a data mismatch: +/// - **Arity**: wrong number of arguments to a function +/// - **Binary op mismatch**: `5 + [1,2]`, `"a" - 3` +/// - **Unary op mismatch**: `-"hello"`, `!42` (when bool expected) +/// - **Missing field on known struct**: struct schema is static, field absence is a typo +/// - **push/pop on non-array**: calling collection methods on wrong types +/// - **Assignment to immutable**: `const` or `let` reassignment where disallowed +/// +/// ## Hard Errors — Explicit Conversion Failures (always crash) +/// User explicitly requested a type conversion that failed: +/// - `int("not_a_number")`, `float("abc")` — parse failures +/// - `round()`, `floor()`, `ceil()` on non-numeric — wrong type passed to math +/// - `abs()`, `sqrt()`, `pow()` on non-numeric +/// +/// ## Hard Errors — Arithmetic Invariants (always crash) +/// Mathematical invariants that can't produce a meaningful result: +/// - Division by zero +/// - `sqrt()` of negative number +/// - `clamp()` with min > max +/// +/// ## Hard Errors — Control Flow / Internal (always crash) +/// Interpreter-internal errors that shouldn't reach user code: +/// - Calling a non-function value +/// - Pattern match exhaustiveness failures +/// - `break`/`continue` outside a loop impl Interpreter { pub fn new() -> Self { let env = Rc::new(RefCell::new(Environment::new())); @@ -628,6 +669,8 @@ impl Interpreter { /// This is useful for external callers (like the IAL test runner) that want /// to invoke NTNT functions after loading a module. pub fn call_function_by_name(&mut self, name: &str, args: Vec) -> Result { + // Clear warning dedup state for each request/call + crate::config::clear_type_warnings(); // Look up the function in the environment let func = self.environment.borrow().get(name).ok_or_else(|| { let candidates = self.environment.borrow().keys(); @@ -635,6 +678,7 @@ impl Interpreter { IntentError::UndefinedVariable { name: name.to_string(), suggestion, + line: 0, } })?; @@ -642,7 +686,7 @@ impl Interpreter { match &func { Value::Function { .. } | Value::NativeFunction { .. } => {} _ => { - return Err(IntentError::TypeError(format!( + return Err(IntentError::type_error(format!( "Expected function, got {}", func.type_name() ))) @@ -1060,8 +1104,10 @@ impl Interpreter { Value::String(s) => Ok(Value::Int(s.len() as i64)), Value::Array(a) => Ok(Value::Int(a.len() as i64)), Value::Map(m) => Ok(Value::Int(m.len() as i64)), - _ => Err(IntentError::TypeError( - "len() requires a string, array, or map".to_string(), + other => Err(IntentError::type_error_with_context( + format!("len() requires a collection, got {}", other.type_name()), + TypeContext::new("String, Array, or Map", other.type_name()) + .with_hint("Use type(x) to check the type before calling len()"), )), }, }, @@ -1168,9 +1214,9 @@ impl Interpreter { Value::String(s) => s .parse::() .map(Value::Int) - .map_err(|_| IntentError::TypeError("Cannot parse as int".to_string())), + .map_err(|_| IntentError::type_error("Cannot parse as int".to_string())), Value::Bool(b) => Ok(Value::Int(if *b { 1 } else { 0 })), - _ => Err(IntentError::TypeError("Cannot convert to int".to_string())), + _ => Err(IntentError::type_error("Cannot convert to int".to_string())), }, }, ); @@ -1201,8 +1247,8 @@ impl Interpreter { Value::String(s) => s .parse::() .map(Value::Float) - .map_err(|_| IntentError::TypeError("Cannot parse as float".to_string())), - _ => Err(IntentError::TypeError( + .map_err(|_| IntentError::type_error("Cannot parse as float".to_string())), + _ => Err(IntentError::type_error( "Cannot convert to float".to_string(), )), }, @@ -1234,7 +1280,7 @@ impl Interpreter { arr.push(args[1].clone()); Ok(Value::Array(arr)) } else { - Err(IntentError::TypeError( + Err(IntentError::type_error( "push() requires an array".to_string(), )) } @@ -1295,7 +1341,7 @@ impl Interpreter { func: |args| match &args[0] { Value::Int(n) => Ok(Value::Int(n.abs())), Value::Float(f) => Ok(Value::Float(f.abs())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "abs() requires a number".to_string(), )), }, @@ -1327,7 +1373,9 @@ impl Interpreter { (Value::Float(a), Value::Float(b)) => Ok(Value::Float(a.min(*b))), (Value::Int(a), Value::Float(b)) => Ok(Value::Float((*a as f64).min(*b))), (Value::Float(a), Value::Int(b)) => Ok(Value::Float(a.min(*b as f64))), - _ => Err(IntentError::TypeError("min() requires numbers".to_string())), + _ => Err(IntentError::type_error( + "min() requires numbers".to_string(), + )), }, }, ); @@ -1357,7 +1405,9 @@ impl Interpreter { (Value::Float(a), Value::Float(b)) => Ok(Value::Float(a.max(*b))), (Value::Int(a), Value::Float(b)) => Ok(Value::Float((*a as f64).max(*b))), (Value::Float(a), Value::Int(b)) => Ok(Value::Float(a.max(*b as f64))), - _ => Err(IntentError::TypeError("max() requires numbers".to_string())), + _ => Err(IntentError::type_error( + "max() requires numbers".to_string(), + )), }, }, ); @@ -1387,7 +1437,7 @@ impl Interpreter { max_arity: 0, func: |args| { if args.is_empty() || args.len() > 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "round() requires 1 or 2 arguments".to_string(), )); } @@ -1396,7 +1446,7 @@ impl Interpreter { Value::Int(n) => *n as f64, Value::Float(f) => *f, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "round() requires a number as first argument".to_string(), )) } @@ -1411,14 +1461,14 @@ impl Interpreter { let decimals = match &args[1] { Value::Int(n) => *n, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "round() requires an integer for decimal places".to_string(), )) } }; if decimals < 0 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "round() decimal places must be non-negative".to_string(), )); } @@ -1452,7 +1502,7 @@ impl Interpreter { func: |args| match &args[0] { Value::Int(n) => Ok(Value::Int(*n)), Value::Float(f) => Ok(Value::Int(f.floor() as i64)), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "floor() requires a number".to_string(), )), }, @@ -1481,7 +1531,7 @@ impl Interpreter { func: |args| match &args[0] { Value::Int(n) => Ok(Value::Int(*n)), Value::Float(f) => Ok(Value::Int(f.ceil() as i64)), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "ceil() requires a number".to_string(), )), }, @@ -1512,7 +1562,7 @@ impl Interpreter { func: |args| match &args[0] { Value::Int(n) => Ok(Value::Int(*n)), Value::Float(f) => Ok(Value::Int(f.trunc() as i64)), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "trunc() requires a number".to_string(), )), }, @@ -1542,7 +1592,7 @@ impl Interpreter { func: |args| match &args[0] { Value::Int(n) => { if *n < 0 { - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "sqrt() of negative number".to_string(), )) } else { @@ -1551,14 +1601,14 @@ impl Interpreter { } Value::Float(f) => { if *f < 0.0 { - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "sqrt() of negative number".to_string(), )) } else { Ok(Value::Float(f.sqrt())) } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "sqrt() requires a number".to_string(), )), }, @@ -1600,7 +1650,9 @@ impl Interpreter { Ok(Value::Float((*base as f64).powf(*exp))) } (Value::Float(base), Value::Float(exp)) => Ok(Value::Float(base.powf(*exp))), - _ => Err(IntentError::TypeError("pow() requires numbers".to_string())), + _ => Err(IntentError::type_error( + "pow() requires numbers".to_string(), + )), }, }, ); @@ -1637,7 +1689,7 @@ impl Interpreter { Ok(Value::Int(0)) } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "sign() requires a number".to_string(), )), }, @@ -1674,7 +1726,7 @@ impl Interpreter { (Value::Float(val), Value::Float(min), Value::Float(max)) => { Ok(Value::Float(val.max(*min).min(*max))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "clamp() requires numbers of same type".to_string(), )), }, @@ -1810,7 +1862,7 @@ impl Interpreter { Value::EnumValue { enum_name, variant, .. } if enum_name == "Option" => Ok(Value::Bool(variant == "Some")), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_some() requires an Option".to_string(), )), }, @@ -1840,7 +1892,7 @@ impl Interpreter { Value::EnumValue { enum_name, variant, .. } if enum_name == "Option" => Ok(Value::Bool(variant == "None")), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_none() requires an Option".to_string(), )), }, @@ -1870,7 +1922,7 @@ impl Interpreter { Value::EnumValue { enum_name, variant, .. } if enum_name == "Result" => Ok(Value::Bool(variant == "Ok")), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_ok() requires a Result".to_string(), )), }, @@ -1900,7 +1952,7 @@ impl Interpreter { Value::EnumValue { enum_name, variant, .. } if enum_name == "Result" => Ok(Value::Bool(variant == "Err")), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_err() requires a Result".to_string(), )), }, @@ -2068,22 +2120,22 @@ impl Interpreter { ("Option", "Some") | ("Result", "Ok") => values .first() .cloned() - .ok_or_else(|| IntentError::RuntimeError("Empty variant".to_string())), - ("Option", "None") => Err(IntentError::RuntimeError( + .ok_or_else(|| IntentError::runtime_error("Empty variant".to_string())), + ("Option", "None") => Err(IntentError::runtime_error( "Called unwrap() on None".to_string(), )), ("Result", "Err") => { let err_val = values.first().map(|v| v.to_string()).unwrap_or_default(); - Err(IntentError::RuntimeError(format!( + Err(IntentError::runtime_error(format!( "Called unwrap() on Err({})", err_val ))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "unwrap() requires Option or Result".to_string(), )), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "unwrap() requires Option or Result".to_string(), )), }, @@ -2120,13 +2172,13 @@ impl Interpreter { ("Option", "Some") | ("Result", "Ok") => values .first() .cloned() - .ok_or_else(|| IntentError::RuntimeError("Empty variant".to_string())), + .ok_or_else(|| IntentError::runtime_error("Empty variant".to_string())), ("Option", "None") | ("Result", "Err") => Ok(args[1].clone()), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "unwrap_or() requires Option or Result".to_string(), )), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "unwrap_or() requires Option or Result".to_string(), )), }, @@ -2154,7 +2206,7 @@ impl Interpreter { func: |_args| { // This is a placeholder - actual implementation is in eval_call // because we need access to the interpreter to call handlers - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "listen() must be called directly, not stored in a variable".to_string(), )) }, @@ -2180,7 +2232,7 @@ impl Interpreter { arity: 2, max_arity: 2, func: |_args| { - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "HTTP route functions must be called directly".to_string(), )) }, @@ -2206,7 +2258,7 @@ impl Interpreter { arity: 2, max_arity: 2, func: |_args| { - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "HTTP route functions must be called directly".to_string(), )) }, @@ -2232,7 +2284,7 @@ impl Interpreter { arity: 2, max_arity: 2, func: |_args| { - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "HTTP route functions must be called directly".to_string(), )) }, @@ -2258,7 +2310,7 @@ impl Interpreter { arity: 2, max_arity: 2, func: |_args| { - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "HTTP route functions must be called directly".to_string(), )) }, @@ -2284,7 +2336,7 @@ impl Interpreter { arity: 2, max_arity: 2, func: |_args| { - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "HTTP route functions must be called directly".to_string(), )) }, @@ -2309,7 +2361,7 @@ impl Interpreter { max_arity: 0, func: |_args| { // Placeholder - actual implementation clears server_state - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "new_server() must be called directly".to_string(), )) }, @@ -2345,7 +2397,7 @@ impl Interpreter { max_arity: 0, func: |_args| { // Placeholder - actual implementation is in eval_call - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "enable_cors() must be called directly, not stored in a variable" .to_string(), )) @@ -2398,7 +2450,7 @@ impl Interpreter { if let Some(s) = suggestion { msg.push_str(&format!("\n Did you mean: {}?", s)); } - IntentError::RuntimeError(msg) + IntentError::runtime_error(msg) })?; self.bind_imports(items, &module, source, alias) @@ -2444,7 +2496,7 @@ impl Interpreter { if sorted_exports.len() > 8 { msg.push_str(&format!(", ... ({} total)", sorted_exports.len())); } - IntentError::RuntimeError(msg) + IntentError::runtime_error(msg) })?; let bind_name = item.alias.as_ref().unwrap_or(&item.name); self.environment @@ -2489,7 +2541,7 @@ impl Interpreter { // Read and parse the file let source_code = fs::read_to_string(&file_path).map_err(|e| { - IntentError::RuntimeError(format!( + IntentError::runtime_error(format!( "Failed to read module '{}': {}", file_path.display(), e @@ -2702,7 +2754,7 @@ impl Interpreter { use std::fs; let source_code = fs::read_to_string(file_path).map_err(|e| { - IntentError::RuntimeError(format!("Failed to read '{}': {}", file_path.display(), e)) + IntentError::runtime_error(format!("Failed to read '{}': {}", file_path.display(), e)) })?; let lexer = Lexer::new(&source_code); @@ -2717,9 +2769,11 @@ impl Interpreter { self.environment = Rc::new(RefCell::new(Environment::new())); self.current_file = Some(file_path.to_string_lossy().to_string()); - // Re-define builtins and types in the new environment + // Re-define builtins, types, and stdlib in the new environment + // (lib modules should have the same execution context as route handlers) self.define_builtins(); self.define_builtin_types(); + self.define_stdlib(); // Evaluate the module self.eval(&ast)?; @@ -2762,14 +2816,14 @@ impl Interpreter { let mut routes = Vec::new(); if !dir.exists() || !dir.is_dir() { - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "Routes directory does not exist: {}", dir.display() ))); } let mut entries: Vec<_> = fs::read_dir(dir) - .map_err(|e| IntentError::RuntimeError(format!("Failed to read directory: {}", e)))? + .map_err(|e| IntentError::runtime_error(format!("Failed to read directory: {}", e)))? .flatten() .collect(); @@ -2818,13 +2872,13 @@ impl Interpreter { // Convert file path to URL pattern let relative_path = file_path .strip_prefix(base_dir) - .map_err(|_| IntentError::RuntimeError("Failed to get relative path".to_string()))?; + .map_err(|_| IntentError::runtime_error("Failed to get relative path".to_string()))?; let url_pattern = self.file_path_to_url_pattern(relative_path); // Read and parse the file let source_code = fs::read_to_string(file_path).map_err(|e| { - IntentError::RuntimeError(format!("Failed to read '{}': {}", file_path.display(), e)) + IntentError::runtime_error(format!("Failed to read '{}': {}", file_path.display(), e)) })?; let lexer = Lexer::new(&source_code); @@ -2840,9 +2894,10 @@ impl Interpreter { self.environment = Rc::new(RefCell::new(Environment::new())); self.current_file = Some(file_path.to_string_lossy().to_string()); - // Re-define builtins and types + // Re-define builtins, types, and stdlib modules self.define_builtins(); self.define_builtin_types(); + self.define_stdlib(); // Inject lib modules into the environment for (name, exports) in lib_modules { @@ -2909,7 +2964,7 @@ impl Interpreter { // Read and parse the file let source_code = fs::read_to_string(path).map_err(|e| { - IntentError::RuntimeError(format!("Failed to read '{}': {}", file_path, e)) + IntentError::runtime_error(format!("Failed to read '{}': {}", file_path, e)) })?; let lexer = Lexer::new(&source_code); @@ -2925,9 +2980,10 @@ impl Interpreter { self.environment = Rc::new(RefCell::new(Environment::new())); self.current_file = Some(file_path.to_string()); - // Re-define builtins and types + // Re-define builtins, types, and stdlib modules self.define_builtins(); self.define_builtin_types(); + self.define_stdlib(); // Inject lib modules (same as initial route processing) for (name, exports) in &self.lib_modules { @@ -2962,7 +3018,7 @@ impl Interpreter { self.imported_files = previous_imports; let handler = handler.ok_or_else(|| { - IntentError::RuntimeError(format!( + IntentError::runtime_error(format!( "Handler '{}' not found in {}", method_name, file_path )) @@ -3015,6 +3071,8 @@ impl Interpreter { /// Evaluate a program pub fn eval(&mut self, program: &Program) -> Result { + // Clear warning dedup state so each eval/request gets fresh warnings + crate::config::clear_type_warnings(); let mut result = Value::Unit; for stmt in &program.statements { result = self.eval_statement(stmt)?; @@ -3031,7 +3089,14 @@ impl Interpreter { Statement::Located { line, col, stmt } => { self.current_line = *line; self.current_col = *col; - return self.eval_statement(stmt); + return self.eval_statement(stmt).map_err(|e| { + // Annotate errors with line info if they don't already have it + if e.line().is_none() { + e.at_line(*line) + } else { + e + } + }); } Statement::Let { name, @@ -3107,7 +3172,7 @@ impl Interpreter { return Ok(result) } _ => { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "otherwise block must diverge (use return, break, or continue)".to_string(), )) } @@ -3382,30 +3447,59 @@ impl Interpreter { let iterable_value = self.eval_expression(iterable)?; // Convert iterable to something we can iterate over - let items: Vec = match &iterable_value { - Value::Array(arr) => arr.clone(), + // Resolve the iterable into items. Non-collection values are handled + // according to the current NTNT_TYPE_MODE: + // strict → RuntimeError (halts) + // warn → [WARN] to stderr, then empty iteration (default) + // forgiving → empty iteration, silently + let items_result: Result> = match &iterable_value { + Value::Array(arr) => Ok(arr.clone()), Value::Range { start, end, inclusive, } => { let end_val = if *inclusive { *end + 1 } else { *end }; - (*start..end_val).map(Value::Int).collect() + Ok((*start..end_val).map(Value::Int).collect()) } - Value::Map(map) => map.keys().map(|k| Value::String(k.clone())).collect(), - // String and non-collection types: zero iterations with dev-mode warning. + Value::Map(map) => Ok(map.keys().map(|k| Value::String(k.clone())).collect()), + // String and non-collection types: behaviour depends on TypeMode. // Use chars() builtin for explicit string character iteration. - _ => { - if !is_production_mode() { - eprintln!( - "[WARN] for..in on {} — skipping (not a collection). \ + _ => match get_type_mode() { + TypeMode::Strict => { + let hint = if iterable_value.type_name() == "String" { + "Use chars(s) to iterate over string characters" + } else { + "Use ?? to provide a fallback collection, or check the type before iterating" + }; + Err(IntentError::runtime_error_with_context( + format!( + "for..in requires a collection, got {}", + iterable_value.type_name() + ), + TypeContext::new( + "Array, Map, or Range", + iterable_value.type_name(), + ) + .with_hint(hint), + )) + } + TypeMode::Warn => { + let msg = format!( + "for..in on {} — skipping (not a collection). \ Use chars() for string iteration.", iterable_value.type_name() ); + crate::config::type_warn_dedup( + &format!("for_in:{}", iterable_value.type_name()), + &msg, + ); + Ok(vec![]) } - vec![] - } + TypeMode::Forgiving => Ok(vec![]), + }, }; + let items = items_result?; let mut result = Value::Unit; for item in items { @@ -3511,6 +3605,7 @@ impl Interpreter { IntentError::UndefinedVariable { name: name.clone(), suggestion, + line: 0, } }), @@ -3570,7 +3665,7 @@ impl Interpreter { UnaryOp::Neg => match val { Value::Int(n) => Ok(Value::Int(-n)), Value::Float(f) => Ok(Value::Float(-f)), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "Cannot negate non-numeric value".to_string(), )), }, @@ -3617,7 +3712,7 @@ impl Interpreter { return self.run_async_http_server(effective_port); } } else { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "listen() requires an integer port".to_string(), )); } @@ -3663,7 +3758,7 @@ impl Interpreter { return Ok(Value::Unit); } _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "serve_static() requires two string arguments: (url_prefix, directory)".to_string() )); } @@ -3679,7 +3774,7 @@ impl Interpreter { if let Value::String(dir_str) = directory { return self.load_file_based_routes(&dir_str); } else { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "routes() requires a string directory path".to_string(), )); } @@ -3726,7 +3821,7 @@ impl Interpreter { match self.eval_expression(&arguments[0])? { Value::Map(m) => m, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "enable_cors() options must be a map".to_string(), )) } @@ -3759,7 +3854,7 @@ impl Interpreter { let path_str = match &path { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "template() first argument must be a string path".to_string(), )) } @@ -3768,7 +3863,7 @@ impl Interpreter { let data_map = match &data { Value::Map(m) => m.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "template() second argument must be a map".to_string(), )) } @@ -3790,7 +3885,7 @@ impl Interpreter { let path_str = match &path { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "compile() argument must be a string path".to_string(), )) } @@ -3846,14 +3941,14 @@ impl Interpreter { Value::Map(m) => match m.get("_template_id") { Some(Value::Int(id)) => *id as u64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "render() first argument must be a compiled template" .to_string(), )) } }, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "render() first argument must be a compiled template" .to_string(), )) @@ -3863,7 +3958,7 @@ impl Interpreter { let data_map = match &data { Value::Map(m) => m.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "render() second argument must be a map".to_string(), )) } @@ -3874,7 +3969,7 @@ impl Interpreter { match crate::stdlib::template::get_compiled_template(template_id) { Some(t) => t.content, None => { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "Template not found in cache".to_string(), )) } @@ -3955,7 +4050,7 @@ impl Interpreter { } return Ok(Value::Array(result)); } else { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "filter() requires an array as first argument".to_string(), )); } @@ -3975,7 +4070,7 @@ impl Interpreter { } return Ok(Value::Array(result)); } else { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "transform() requires an array as first argument".to_string(), )); } @@ -4015,7 +4110,7 @@ impl Interpreter { items = keyed.into_iter().map(|(_, v)| v).collect(); return Ok(Value::Array(items)); } else { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "sort() requires an array as first argument".to_string(), )); } @@ -4054,7 +4149,7 @@ impl Interpreter { items = keyed.into_iter().map(|(_, v)| v).collect(); return Ok(Value::Array(items)); } else { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "sort_desc() requires an array as first argument".to_string(), )); } @@ -4075,7 +4170,7 @@ impl Interpreter { } return Ok(Value::none()); } else { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "find() requires an array as first argument".to_string(), )); } @@ -4095,7 +4190,7 @@ impl Interpreter { } return Ok(Value::Bool(false)); } else { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "any() requires an array as first argument".to_string(), )); } @@ -4115,7 +4210,7 @@ impl Interpreter { } return Ok(Value::Bool(true)); } else { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "all() requires an array as first argument".to_string(), )); } @@ -4151,7 +4246,7 @@ impl Interpreter { } return Ok(acc); } else { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "reduce() requires an array as first argument".to_string(), )); } @@ -4173,7 +4268,7 @@ impl Interpreter { } return Ok(Value::Array(result)); } else { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "flat_map() requires an array as first argument".to_string(), )); } @@ -4263,13 +4358,38 @@ impl Interpreter { // Struct access with string key: struct["field"] (Value::Struct { fields, .. }, Value::String(key)) => { fields.get(&key).cloned().ok_or_else(|| { - IntentError::RuntimeError(format!("Unknown field: {}", key)) + IntentError::runtime_error(format!("Unknown field: {}", key)) }) } - // Type mismatch on index returns None instead of crashing. - // This makes ?? the universal safety net: data["key"] ?? "default" - // works regardless of whether data is a map, string, int, or None. - _ => Ok(Value::none()), + // Type mismatch on index: behaviour depends on NTNT_TYPE_MODE. + // strict → RuntimeError + // warn → [WARN] to stderr, return None (default) + // forgiving → return None silently + // ?? remains the universal safety net in warn/forgiving modes. + (obj, idx) => match get_type_mode() { + TypeMode::Strict => Err(IntentError::runtime_error_with_context( + format!("Cannot index {} with {}", obj.type_name(), idx.type_name()), + TypeContext::new( + "Array[Int], Map[String], or String[Int]", + format!("{}[{}]", obj.type_name(), idx.type_name()), + ) + .with_hint("Use ?? to provide a default: value[key] ?? fallback"), + )), + TypeMode::Warn => { + let msg = format!( + "Type mismatch: indexing {} with {} — returning None. \ + Use ?? for a safe default.", + obj.type_name(), + idx.type_name() + ); + crate::config::type_warn_dedup( + &format!("index:{}:{}", obj.type_name(), idx.type_name()), + &msg, + ); + Ok(Value::none()) + } + TypeMode::Forgiving => Ok(Value::none()), + }, } } @@ -4277,12 +4397,34 @@ impl Interpreter { let obj = self.eval_expression(object)?; match obj { Value::Struct { fields, .. } => fields.get(field).cloned().ok_or_else(|| { - IntentError::RuntimeError(format!("Unknown field: {}", field)) + IntentError::runtime_error(format!("Unknown field: {}", field)) }), Value::Map(map) => Ok(map.get(field).cloned().unwrap_or_else(|| Value::none())), - _ => Err(IntentError::TypeError( - "Field access on non-struct value".to_string(), - )), + // Field access on non-struct/map: behaviour depends on TypeMode. + // Real-world scenario: JSON from DB decoded as wrong type. + _ => match get_type_mode() { + TypeMode::Strict => Err(IntentError::runtime_error_with_context( + format!("Field access .{} on {}", field, obj.type_name()), + TypeContext::new("Struct or Map", obj.type_name()).with_hint(format!( + "Use .{} ?? fallback to handle unexpected types", + field + )), + )), + TypeMode::Warn => { + let msg = format!( + "Field access .{} on {} — returning None. \ + Expected Struct or Map.", + field, + obj.type_name() + ); + crate::config::type_warn_dedup( + &format!("field:{}:{}", field, obj.type_name()), + &msg, + ); + Ok(Value::none()) + } + TypeMode::Forgiving => Ok(Value::none()), + }, } } @@ -4341,6 +4483,7 @@ impl Interpreter { Err(IntentError::UndefinedVariable { name: name.clone(), suggestion, + line: 0, }) } } @@ -4356,6 +4499,7 @@ impl Interpreter { IntentError::UndefinedVariable { name: var_name.clone(), suggestion, + line: 0, } })?; @@ -4380,18 +4524,18 @@ impl Interpreter { self.environment.borrow_mut().set(var_name, new_struct); Ok(val) } else { - Err(IntentError::RuntimeError(format!( + Err(IntentError::runtime_error(format!( "Unknown field '{}' on struct '{}'", field, struct_name ))) } } else { - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "Cannot assign field on non-struct value".to_string(), )) } } else { - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "Cannot assign to complex field access".to_string(), )) } @@ -4409,7 +4553,7 @@ impl Interpreter { } Expression::Identifier(_) => break, _ => { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "Invalid nested assignment target".to_string(), )) } @@ -4426,7 +4570,7 @@ impl Interpreter { // Check mutability if !self.environment.borrow().is_mutable(&root_name) { - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "Cannot mutate '{}': variable is not declared with 'let mut'", root_name ))); @@ -4441,6 +4585,7 @@ impl Interpreter { IntentError::UndefinedVariable { name: root_name.clone(), suggestion, + line: 0, } })?; @@ -4462,7 +4607,7 @@ impl Interpreter { let index = match idx_val { Value::Int(n) => *n as usize, _ => { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "Array index must be an integer" .to_string(), )) @@ -4480,7 +4625,7 @@ impl Interpreter { let key = match idx_val { Value::String(s) => s.clone(), _ => { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "Map key must be a string".to_string(), )) } @@ -4488,7 +4633,7 @@ impl Interpreter { map.insert(key, val.clone()); } _ => { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "Cannot index into non-collection value" .to_string(), )) @@ -4501,7 +4646,7 @@ impl Interpreter { let index = match idx_val { Value::Int(n) => *n as usize, _ => { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "Array index must be an integer" .to_string(), )) @@ -4519,20 +4664,20 @@ impl Interpreter { let key = match idx_val { Value::String(s) => s.clone(), _ => { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "Map key must be a string".to_string(), )) } }; map.get_mut(&key).ok_or_else(|| { - IntentError::RuntimeError(format!( + IntentError::runtime_error(format!( "Key '{}' not found in map", key )) })? } _ => { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "Cannot index into non-collection value" .to_string(), )) @@ -4546,7 +4691,7 @@ impl Interpreter { self.environment.borrow_mut().set(&root_name, root_val); Ok(val) } - _ => Err(IntentError::RuntimeError( + _ => Err(IntentError::runtime_error( "Invalid assignment target".to_string(), )), } @@ -4606,7 +4751,7 @@ impl Interpreter { .strip_prefix("module:") .or_else(|| name.strip_prefix("lib:")) .unwrap_or(name); - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "Module '{}' has no function '{}'", module_name, method ))); @@ -4643,6 +4788,7 @@ impl Interpreter { Err(IntentError::UndefinedFunction { name: method.clone(), suggestion: None, + line: 0, }) } } @@ -4704,14 +4850,14 @@ impl Interpreter { } } - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "No pattern matched in match expression".to_string(), )) } Expression::Await(_) => { // TODO: Implement async - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "Async/Await not yet implemented".to_string(), )) } @@ -4762,7 +4908,7 @@ impl Interpreter { Value::String(s) => s.clone(), Value::Int(n) => n.to_string(), _ => { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "Map keys must be strings or integers".to_string(), )) } @@ -4786,7 +4932,7 @@ impl Interpreter { end: *e, inclusive: *inclusive, }), - _ => Err(IntentError::RuntimeError( + _ => Err(IntentError::runtime_error( "Range bounds must be integers".to_string(), )), } @@ -4872,7 +5018,7 @@ impl Interpreter { config.providers.push(provider); } } else { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "enable_auth() requires a provider or config with 'providers' array" .to_string(), )); @@ -4897,7 +5043,7 @@ impl Interpreter { Ok(config) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "enable_auth() requires a provider or config map".to_string(), )), } @@ -4987,7 +5133,7 @@ impl Interpreter { for candidate in &candidates { if candidate.is_file() { return std::fs::read_to_string(candidate).map_err(|e| { - IntentError::RuntimeError(format!( + IntentError::runtime_error(format!( "Failed to read partial '{}' from {}: {}", name, candidate.display(), @@ -4997,7 +5143,7 @@ impl Interpreter { } } - Err(IntentError::RuntimeError(format!( + Err(IntentError::runtime_error(format!( "Partial '{}' not found. Searched:\n{}", name, candidates @@ -5024,9 +5170,9 @@ impl Interpreter { let tokens: Vec<_> = lexer.collect(); let mut parser = Parser::new(tokens); - let template_expr = parser - .expression() - .map_err(|e| IntentError::RuntimeError(format!("Failed to compile template: {}", e)))?; + let template_expr = parser.expression().map_err(|e| { + IntentError::runtime_error(format!("Failed to compile template: {}", e)) + })?; // Create a new scope for template data let previous = Rc::clone(&self.environment); @@ -5048,6 +5194,44 @@ impl Interpreter { result } + /// Format a template error for HTML output in warn mode. + /// In development, renders detailed error as HTML comment. + /// In production, renders a generic marker to avoid leaking internals. + fn template_warn_comment(error: &IntentError) -> String { + if is_production_mode() { + "".to_string() + } else { + format!( + "", + sanitize_html_comment(&error.to_string()) + ) + } + } + + /// Handle a template error according to `NTNT_TYPE_MODE`. + /// + /// - `Strict`: returns `Err(e)` (caller should propagate) + /// - `Warn`: logs to stderr, appends HTML comment to `result` + /// - `Forgiving`: silently ignores the error + /// + /// Returns `Ok(())` when the error is handled (warn/forgiving), or `Err(e)` in strict mode. + fn handle_template_error(e: IntentError, context: &str, result: &mut String) -> Result<()> { + match get_type_mode() { + TypeMode::Strict => Err(e), + TypeMode::Warn => { + let key = format!("template:{}:{}", context, e); + if crate::config::type_warn_dedup( + &key, + &format!("Template {} failed: {}", context, e), + ) { + result.push_str(&Self::template_warn_comment(&e)); + } + Ok(()) + } + TypeMode::Forgiving => Ok(()), + } + } + /// Evaluate template string parts fn eval_template_parts(&mut self, parts: &[TemplatePart]) -> Result { let mut result = String::new(); @@ -5056,7 +5240,10 @@ impl Interpreter { match part { TemplatePart::Literal(s) => result.push_str(s), TemplatePart::Expr(expr) => { - // Error boundary: catch all errors, render gracefully + // Error boundary: behaviour depends on NTNT_TYPE_MODE. + // strict → propagate error (HTTP 500) + // warn → [WARN] to stderr + HTML comment (default) + // forgiving → render empty string silently match self.eval_expression(expr) { Ok(v) => { let s = v.to_string(); @@ -5064,37 +5251,18 @@ impl Interpreter { } // Undefined variables render as empty string (standard Mustache behavior) Err(IntentError::UndefinedVariable { .. }) => {} - Err(e) => { - let is_prod = is_production_mode(); - eprintln!("[ERROR] Template expression failed: {}", e); - if !is_prod { - result.push_str(&format!( - "", - sanitize_html_comment(&e.to_string()) - )); - } - // prod: empty string (push nothing) - } + Err(e) => Self::handle_template_error(e, "expression", &mut result)?, } } TemplatePart::RawExpr(expr) => { - // Error boundary: catch all errors, render gracefully + // Error boundary: behaviour depends on NTNT_TYPE_MODE. match self.eval_expression(expr) { Ok(v) => { result.push_str(&v.to_string()); } // Undefined variables render as empty string (standard Mustache behavior) Err(IntentError::UndefinedVariable { .. }) => {} - Err(e) => { - let is_prod = is_production_mode(); - eprintln!("[ERROR] Template expression failed: {}", e); - if !is_prod { - result.push_str(&format!( - "", - sanitize_html_comment(&e.to_string()) - )); - } - } + Err(e) => Self::handle_template_error(e, "raw expression", &mut result)?, } } TemplatePart::FilteredExpr { expr, filters } => { @@ -5106,26 +5274,17 @@ impl Interpreter { Err(e) => { if has_default { // Log non-variable errors even with default filter - if !matches!(e, IntentError::UndefinedVariable { .. }) { - let is_prod = is_production_mode(); - if !is_prod { - eprintln!( - "[WARN] Template expression error (using default): {}", - e - ); - } + if !matches!(e, IntentError::UndefinedVariable { .. }) + && get_type_mode() == TypeMode::Warn + { + eprintln!( + "[WARN] Template expression error (using default): {}", + e + ); } Value::Unit } else { - // Error boundary: render gracefully - let is_prod = is_production_mode(); - eprintln!("[ERROR] Template expression failed: {}", e); - if !is_prod { - result.push_str(&format!( - "", - sanitize_html_comment(&e.to_string()) - )); - } + Self::handle_template_error(e, "filtered expression", &mut result)?; continue; } } @@ -5152,26 +5311,17 @@ impl Interpreter { Err(e) => { if has_default { // Log non-variable errors even with default filter - if !matches!(e, IntentError::UndefinedVariable { .. }) { - let is_prod = is_production_mode(); - if !is_prod { - eprintln!( - "[WARN] Template expression error (using default): {}", - e - ); - } + if !matches!(e, IntentError::UndefinedVariable { .. }) + && get_type_mode() == TypeMode::Warn + { + eprintln!( + "[WARN] Template expression error (using default): {}", + e + ); } Value::Unit } else { - // Error boundary: render gracefully - let is_prod = is_production_mode(); - eprintln!("[ERROR] Template expression failed: {}", e); - if !is_prod { - result.push_str(&format!( - "", - sanitize_html_comment(&e.to_string()) - )); - } + Self::handle_template_error(e, "filtered expression", &mut result)?; continue; } } @@ -5190,15 +5340,7 @@ impl Interpreter { let iterable_value = match self.eval_expression(iterable) { Ok(v) => v, Err(e) => { - // Error boundary: treat errored iterable as empty - let is_prod = is_production_mode(); - eprintln!("[ERROR] Template for-loop iterable failed: {}", e); - if !is_prod { - result.push_str(&format!( - "", - sanitize_html_comment(&e.to_string()) - )); - } + Self::handle_template_error(e, "for-loop iterable", &mut result)?; // Render empty_body if present, otherwise skip if !empty_body.is_empty() { if let Ok(Value::String(s)) = self.eval_template_parts(empty_body) { @@ -5320,18 +5462,12 @@ impl Interpreter { } } _ => { - // Non-iterable value: skip with warning (consistent with for..in behavior) - let is_prod = is_production_mode(); - if !is_prod { - eprintln!( - "[WARN] Template for loop on {} — skipping (not a collection)", - iterable_value.type_name() - ); - result.push_str(&format!( - "", - sanitize_html_comment(iterable_value.type_name()) - )); - } + // Non-iterable value: behaviour depends on NTNT_TYPE_MODE + let err = IntentError::runtime_error(format!( + "Template for loop requires a collection (Array or Map), got {}", + iterable_value.type_name() + )); + Self::handle_template_error(err, "for-loop non-iterable", &mut result)?; if !empty_body.is_empty() { if let Ok(Value::String(s)) = self.eval_template_parts(empty_body) { result.push_str(&s); @@ -5405,7 +5541,7 @@ impl Interpreter { match self.eval_expression(expr)? { Value::Map(m) => m, other => { - return Err(IntentError::TypeError(format!( + return Err(IntentError::type_error(format!( "Partial '{}' data expression must be a map, got {}", name, other.type_name() @@ -5468,7 +5604,7 @@ impl Interpreter { let max_len = match args.first() { Some(Value::Int(n)) => *n as usize, _ => { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "truncate filter requires an integer argument".to_string(), )) } @@ -5484,7 +5620,7 @@ impl Interpreter { let (from, to) = match (args.first(), args.get(1)) { (Some(Value::String(f)), Some(Value::String(t))) => (f.as_str(), t.as_str()), _ => { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "replace filter requires two string arguments".to_string(), )) } @@ -5530,7 +5666,7 @@ impl Interpreter { Value::String(s) => Ok(Value::Int(s.len() as i64)), Value::Array(arr) => Ok(Value::Int(arr.len() as i64)), Value::Map(m) => Ok(Value::Int(m.len() as i64)), - _ => Err(IntentError::RuntimeError(format!( + _ => Err(IntentError::runtime_error(format!( "length filter not supported for {}", value.type_name() ))), @@ -5540,7 +5676,7 @@ impl Interpreter { Value::String(s) => Ok(Value::String( s.chars().next().map(|c| c.to_string()).unwrap_or_default(), )), - _ => Err(IntentError::RuntimeError(format!( + _ => Err(IntentError::runtime_error(format!( "first filter not supported for {}", value.type_name() ))), @@ -5550,7 +5686,7 @@ impl Interpreter { Value::String(s) => Ok(Value::String( s.chars().last().map(|c| c.to_string()).unwrap_or_default(), )), - _ => Err(IntentError::RuntimeError(format!( + _ => Err(IntentError::runtime_error(format!( "last filter not supported for {}", value.type_name() ))), @@ -5562,7 +5698,7 @@ impl Interpreter { Ok(Value::Array(reversed)) } Value::String(s) => Ok(Value::String(s.chars().rev().collect())), - _ => Err(IntentError::RuntimeError(format!( + _ => Err(IntentError::runtime_error(format!( "reverse filter not supported for {}", value.type_name() ))), @@ -5577,7 +5713,7 @@ impl Interpreter { let strings: Vec = arr.iter().map(|v| v.to_string()).collect(); Ok(Value::String(strings.join(separator))) } - _ => Err(IntentError::RuntimeError(format!( + _ => Err(IntentError::runtime_error(format!( "join filter not supported for {}", value.type_name() ))), @@ -5604,7 +5740,7 @@ impl Interpreter { let start = start.min(end); Ok(Value::String(chars[start..end].iter().collect())) } - _ => Err(IntentError::RuntimeError(format!( + _ => Err(IntentError::runtime_error(format!( "slice filter not supported for {}", value.type_name() ))), @@ -5636,7 +5772,7 @@ impl Interpreter { Ok(Value::String(urlencoding::encode(&s).to_string())) } - _ => Err(IntentError::RuntimeError(format!( + _ => Err(IntentError::runtime_error(format!( "Unknown template filter: {}", filter.name ))), @@ -5866,7 +6002,7 @@ impl Interpreter { } Ok(()) } - None => Err(IntentError::RuntimeError( + None => Err(IntentError::runtime_error( "Pattern destructuring failed: value does not match pattern".to_string(), )), } @@ -5909,7 +6045,7 @@ impl Interpreter { .collect(); if !missing.is_empty() { - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "Non-exhaustive match: missing variants {:?}", missing ))); @@ -5930,7 +6066,7 @@ impl Interpreter { } => { // Check recursion depth limit if self.call_depth >= self.max_recursion_depth { - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "Maximum recursion depth ({}) exceeded. Use NTNT_MAX_RECURSION env var to increase.", self.max_recursion_depth ))); @@ -5954,6 +6090,7 @@ impl Interpreter { name: fn_name.clone(), expected: format!("{}", arity), got: args.len(), + line: 0, }); } } else { @@ -5967,6 +6104,7 @@ impl Interpreter { format!("{}-{}", arity, max_arity) }, got: args.len(), + line: 0, }); } } @@ -5983,6 +6121,7 @@ impl Interpreter { name: format!("{}::{}", enum_name, variant), expected: format!("{}", arity), got: args.len(), + line: 0, }); } Ok(Value::EnumValue { @@ -5992,7 +6131,7 @@ impl Interpreter { }) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "Can only call functions".to_string(), )), } @@ -6023,6 +6162,7 @@ impl Interpreter { name: name.clone(), expected, got: args.len(), + line: 0, }); } @@ -6152,7 +6292,7 @@ impl Interpreter { let has_static = !self.server_state.static_dirs.is_empty(); if !has_routes && !has_static { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "No routes or static directories registered. Use get(), post(), serve_static(), etc. before calling listen()".to_string() )); } @@ -6562,7 +6702,7 @@ impl Interpreter { // Check if any routes are registered if self.server_state.route_count() == 0 && self.server_state.static_dirs.is_empty() { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "No routes or static directories registered. Use get(), post(), serve_static(), etc. before calling listen()".to_string() )); } @@ -6612,7 +6752,7 @@ impl Interpreter { let sync_rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|e| IntentError::RuntimeError(format!("Failed to create runtime: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to create runtime: {}", e)))?; // Initial route sync from interpreter to async state sync_routes_to_async(&self.server_state, &async_routes, &sync_rt); @@ -7291,12 +7431,48 @@ impl Interpreter { (BinaryOp::Ge, Value::Int(a), Value::Float(b)) => Ok(Value::Bool((a as f64) >= b)), (BinaryOp::Ge, Value::Float(a), Value::Int(b)) => Ok(Value::Bool(a >= (b as f64))), - (op, lhs, rhs) => Err(IntentError::InvalidOperation(format!( - "Cannot apply {:?} to {} and {}", - op, - lhs.type_name(), - rhs.type_name() - ))), + (op, lhs, rhs) => { + let op_symbol = match op { + BinaryOp::Add => "+", + BinaryOp::Sub => "-", + BinaryOp::Mul => "*", + BinaryOp::Div => "/", + BinaryOp::Mod => "%", + BinaryOp::Pow => "**", + BinaryOp::Eq => "==", + BinaryOp::Ne => "!=", + BinaryOp::Lt => "<", + BinaryOp::Le => "<=", + BinaryOp::Gt => ">", + BinaryOp::Ge => ">=", + _ => "??", + }; + let hint = match (&op, lhs.type_name(), rhs.type_name()) { + (BinaryOp::Add, "String", _) => { + Some(format!("Convert to string first: \"...\" + string(value)")) + } + (BinaryOp::Add, _, "String") => { + Some(format!("Convert to string first: string(value) + \"...\"")) + } + _ => None, + }; + let mut ctx = TypeContext::new( + format!("compatible types for '{}'", op_symbol), + format!("{} {} {}", lhs.type_name(), op_symbol, rhs.type_name()), + ); + if let Some(h) = hint { + ctx = ctx.with_hint(h); + } + Err(IntentError::type_error_with_context( + format!( + "Cannot apply '{}' to {} and {}", + op_symbol, + lhs.type_name(), + rhs.type_name() + ), + ctx, + )) + } } } @@ -7355,7 +7531,7 @@ impl Interpreter { crate::stdlib::http_server::CorsConfig::from_value(&options); self.server_state.enable_cors(cors_config); } else { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "cors directive expects a map".to_string(), )); } @@ -7373,7 +7549,7 @@ impl Interpreter { self.server_state.add_middleware(mw_val); } _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "middleware directive expects a function or array of functions" .to_string(), )); @@ -7398,7 +7574,7 @@ impl Interpreter { let port_num = match port_val { Value::Int(p) => p as u16, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "Server port must be an integer".to_string(), )) } @@ -7417,7 +7593,7 @@ impl Interpreter { match &handler { Value::Function { .. } | Value::NativeFunction { .. } => {} _ => { - return Err(IntentError::TypeError(format!( + return Err(IntentError::type_error(format!( "Route handler must be a function, got {}", handler.type_name() ))); @@ -7433,7 +7609,7 @@ impl Interpreter { .server_state .detect_route_conflict(&route.method, &segments) { - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "Route conflict: {} {} conflicts with existing route {}. Routes with the same method and parameter positions are ambiguous.", route.method, full_pattern, conflicting_pattern ))); @@ -7480,7 +7656,7 @@ impl Interpreter { self.server_state.add_middleware(mw_val); } _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "Group middleware expects a function or array of functions".to_string(), )); } @@ -9309,6 +9485,8 @@ c") #[test] fn test_for_in_string_skips() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap(); + let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); // for..in on a string now yields zero iterations (use chars() instead) let result = eval( r#" @@ -10663,6 +10841,8 @@ c") #[test] fn test_for_in_int_skips() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap(); + let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); // for..in on an int should yield zero iterations, not crash let result = eval( r#" @@ -10679,6 +10859,8 @@ c") #[test] fn test_for_in_none_skips() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap(); + let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); // for..in on None should yield zero iterations, not crash let result = eval( r#" @@ -10695,6 +10877,8 @@ c") #[test] fn test_for_in_bool_skips() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap(); + let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); // for..in on a bool should yield zero iterations let result = eval( r#" @@ -10779,9 +10963,13 @@ c") // ============================================ // Change 1: [] returns None on type mismatch // ============================================ + // These tests assume forgiving/warn mode (pre-DD-009 behavior). + // Lock the mutex and set forgiving to prevent strict mode from leaking in. #[test] fn test_index_string_with_string_key_returns_none() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap(); + let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); // string["key"] should return None, not TypeError let result = eval(r#"let s = "hello"; s["key"]"#).unwrap(); assert!(matches!( @@ -10795,6 +10983,8 @@ c") #[test] fn test_index_int_with_string_key_returns_none() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap(); + let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); // 42["key"] should return None, not TypeError let result = eval(r#"42["key"]"#).unwrap(); assert!(matches!( @@ -10808,6 +10998,8 @@ c") #[test] fn test_index_none_with_string_key_returns_none() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap(); + let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); // None["key"] should return None, not TypeError let result = eval(r#"let x = None; x["key"]"#).unwrap(); assert!(matches!( @@ -10821,6 +11013,8 @@ c") #[test] fn test_index_array_out_of_bounds_returns_none() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap(); + let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); // [1,2,3][99] should return None, not IndexOutOfBounds let result = eval(r#"[1, 2, 3][99]"#).unwrap(); assert!(matches!( @@ -10834,6 +11028,8 @@ c") #[test] fn test_index_array_negative_out_of_bounds_returns_none() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap(); + let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); // [1,2,3][-99] should return None, not IndexOutOfBounds let result = eval(r#"[1, 2, 3][-99]"#).unwrap(); assert!(matches!( @@ -10847,6 +11043,8 @@ c") #[test] fn test_index_string_char_out_of_bounds_returns_none() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap(); + let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); // "hi"[99] should return None, not IndexOutOfBounds let result = eval(r#""hi"[99]"#).unwrap(); assert!(matches!( @@ -10860,6 +11058,8 @@ c") #[test] fn test_index_type_mismatch_with_null_coalescing() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap(); + let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); // string["key"] ?? "fallback" should return "fallback" let result = eval(r#"let s = "hello"; s["key"] ?? "fallback""#).unwrap(); assert!(matches!(result, Value::String(ref s) if s == "fallback")); @@ -11001,8 +11201,9 @@ page #[test] fn test_recursion_limit_exceeded() { - // Use a small limit to avoid stack overflow in debug mode - let result = eval_with_recursion_limit("fn inf(n) { return inf(n + 1) } inf(0)", 10); + // Use a very small limit (3) to avoid stack overflow on platforms + // with small default thread stacks (macOS CI). + let result = eval_with_recursion_limit("fn inf(n) { return inf(n + 1) } inf(0)", 3); assert!(result.is_err()); let err = format!("{}", result.unwrap_err()); assert!( @@ -11011,7 +11212,7 @@ page err ); assert!( - err.contains("10"), + err.contains("3"), "Error should show the limit value: {}", err ); @@ -11231,4 +11432,147 @@ page "No error handler should be registered by default" ); } + + // ── TypeMode tests (DD-009) ────────────────────────────────────────────── + // + // These tests manipulate NTNT_TYPE_MODE. Because get_type_mode() bypasses + // caching in test builds, each test reads fresh from the environment. + // A process-wide mutex serialises env var access to avoid races when + // tests run in parallel (cargo test default). + + use std::sync::Mutex; + static TYPE_MODE_MUTEX: Mutex<()> = Mutex::new(()); + + struct EnvGuard { + key: &'static str, + prev: Option, + } + impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let prev = std::env::var(key).ok(); + std::env::set_var(key, value); + Self { key, prev } + } + } + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.prev { + Some(v) => std::env::set_var(self.key, v), + None => std::env::remove_var(self.key), + } + } + } + + #[test] + fn test_strict_mode_crashes_on_type_mismatch() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap(); + let _guard = EnvGuard::set("NTNT_TYPE_MODE", "strict"); + // Indexing an Int with a String key — strict mode should return RuntimeError + let result = eval(r#"let x = 42; x["key"]"#); + assert!( + result.is_err(), + "strict mode: indexing Int with String should return Err, got {:?}", + result + ); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("Type mismatch") + || msg.contains("Cannot index") + || msg.contains("cannot index"), + "error should mention type mismatch, got: {}", + msg + ); + } + + #[test] + fn test_warn_mode_logs_and_continues() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap(); + let _guard = EnvGuard::set("NTNT_TYPE_MODE", "warn"); + // Indexing an Int with a String key — warn mode returns None, no crash + let result = eval(r#"let x = 42; x["key"]"#); + assert!( + result.is_ok(), + "warn mode: indexing Int with String should return Ok(None), got {:?}", + result + ); + // The result should be None (represented as EnumValue { Option, None }) + match result.unwrap() { + Value::EnumValue { + enum_name, variant, .. + } if enum_name == "Option" && variant == "None" => {} + other => panic!("expected Option::None, got {:?}", other), + } + } + + #[test] + fn test_forgiving_mode_silent() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap(); + let _guard = EnvGuard::set("NTNT_TYPE_MODE", "forgiving"); + // Forgiving mode: same None result, no warnings (we can't capture stderr here + // but we verify no panic and correct return value) + let result = eval(r#"let x = 42; x["key"]"#); + assert!( + result.is_ok(), + "forgiving mode: indexing Int with String should return Ok(None), got {:?}", + result + ); + match result.unwrap() { + Value::EnumValue { + enum_name, variant, .. + } if enum_name == "Option" && variant == "None" => {} + other => panic!("expected Option::None, got {:?}", other), + } + } + + #[test] + fn test_for_in_strict_crashes() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap(); + let _guard = EnvGuard::set("NTNT_TYPE_MODE", "strict"); + // for..in on an Int — strict mode should return RuntimeError + let result = eval( + r#" + let count = 0 + for i in 42 { + count = count + 1 + } + count + "#, + ); + assert!( + result.is_err(), + "strict mode: for..in on Int should return Err, got {:?}", + result + ); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("collection") || msg.contains("for..in"), + "error should mention collection requirement, got: {}", + msg + ); + } + + #[test] + fn test_for_in_warn_skips() { + let _lock = TYPE_MODE_MUTEX.lock().unwrap(); + let _guard = EnvGuard::set("NTNT_TYPE_MODE", "warn"); + // for..in on an Int — warn mode skips the loop body, count stays 0 + let result = eval( + r#" + let count = 0 + for i in 42 { + count = count + 1 + } + count + "#, + ); + assert!( + result.is_ok(), + "warn mode: for..in on Int should return Ok, got {:?}", + result + ); + assert!( + matches!(result.unwrap(), Value::Int(0)), + "warn mode: for..in on Int should skip loop body (count should be 0)" + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 69b9f75..16954f6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ //! first-class contracts, a static type system, and human-in-the-loop governance. pub mod ast; +pub mod config; pub mod contracts; pub mod error; pub mod ial; diff --git a/src/main.rs b/src/main.rs index c348744..a7168ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -195,9 +195,13 @@ enum Commands { #[arg(long)] fix: bool, - /// Enable strict type checking (warn on untyped function signatures) - #[arg(long)] + /// Enable strict type checking (require type annotations on all functions) + #[arg(long, conflicts_with = "warn_untyped")] strict: bool, + + /// Warn about untyped function signatures (non-fatal) + #[arg(long, conflicts_with = "strict")] + warn_untyped: bool, }, /// Intent-Driven Development commands /// @@ -379,7 +383,12 @@ fn format_error(error: &anyhow::Error, file_path: Option<&PathBuf>) { code.red().bold(), "]".dimmed(), ); - eprintln!(" {} {}", "-->".blue().bold(), intent_err); + // Show file:line location if available + if let (Some(line), Some(path)) = (line_info, file_path) { + let display_path = path.display(); + eprintln!(" {} {}:{}", "-->".blue().bold(), display_path, line); + } + eprintln!(" {} {}", "=".blue().bold(), intent_err); // Source code snippet (for errors with line numbers and a known file) if let (Some(line), Some(path)) = (line_info, file_path) { @@ -434,6 +443,15 @@ fn format_error(error: &anyhow::Error, file_path: Option<&PathBuf>) { } } + // Rich type context (expected/got) + if let Some(ctx) = intent_err.type_context() { + eprintln!(" {} {}", "expected:".cyan().bold(), ctx.expected.green()); + eprintln!(" {} {}", "found:".cyan().bold(), ctx.got.red()); + if let Some(hint) = &ctx.hint { + eprintln!(" {} {}", "hint:".cyan().bold(), hint); + } + } + // Suggestion ("Did you mean?") if let Some(suggestion) = intent_err.suggestion() { eprintln!( @@ -493,7 +511,8 @@ fn main() { quiet, fix, strict, - }) => lint_project(&path, quiet, fix, strict), + warn_untyped, + }) => lint_project(&path, quiet, fix, strict, warn_untyped), Some(Commands::Intent(intent_cmd)) => run_intent_command(intent_cmd), Some(Commands::Docs { query, @@ -1763,13 +1782,26 @@ fn lint_project( quiet: bool, show_fixes: bool, strict_flag: bool, + warn_untyped_flag: bool, ) -> anyhow::Result<()> { use serde_json::{json, Value as JsonValue}; - // Strict lint mode: CLI flag OR env var OR project config - let strict = - strict_flag || ntnt::typechecker::is_strict_mode() || read_project_config_strict(path); - + // Resolve lint mode: CLI flag > NTNT_LINT_MODE env > NTNT_STRICT env > project config > default + let lint_mode = if strict_flag { + ntnt::config::LintMode::Strict + } else if warn_untyped_flag { + ntnt::config::LintMode::Warn + } else if std::env::var("NTNT_LINT_MODE").is_ok() { + // NTNT_LINT_MODE was explicitly set — respect it, even if "default" + ntnt::config::get_lint_mode() + } else { + // No NTNT_LINT_MODE set — fall back to legacy NTNT_STRICT and project config + if ntnt::typechecker::is_strict_mode() || read_project_config_strict(path) { + ntnt::config::LintMode::Strict + } else { + ntnt::config::LintMode::Default + } + }; let files = collect_tnt_files(path)?; let mut results: Vec = Vec::new(); @@ -1805,17 +1837,14 @@ fn lint_project( // Run comprehensive lint checks let mut issues = lint_ast(&ast, &source, &relative_path); - // Run type checker (strict mode adds warnings for untyped signatures) + // Run type checker with lint mode let lint_file_path_str = file_path.to_string_lossy(); - let type_diagnostics = if strict { - ntnt::typechecker::check_program_strict_with_file( - &ast, - &source, - &lint_file_path_str, - ) - } else { - ntnt::typechecker::check_program_with_file(&ast, &source, &lint_file_path_str) - }; + let type_diagnostics = ntnt::typechecker::check_program_with_lint_mode( + &ast, + &source, + lint_mode, + Some(&lint_file_path_str), + ); for diag in type_diagnostics { let severity = match diag.severity { ntnt::typechecker::Severity::Error => "error", @@ -2096,7 +2125,7 @@ fn lint_ast(ast: &ntnt::ast::Program, source: &str, _filename: &str) -> Vec, http_route_functions: &std::collections::HashSet<&str>, ) { - match stmt { + match unwrap_located(stmt) { Statement::Expression(expr) => { check_expr_for_issues(expr, source_lines, issues, http_route_functions); } @@ -2215,7 +2244,7 @@ fn lint_ast(ast: &ntnt::ast::Program, source: &str, _filename: &str) -> Vec 0 { last_import_line = current_line; @@ -2483,22 +2512,27 @@ fn run_intent_check_command( .spawn() .map_err(|e| anyhow::anyhow!("Failed to start app server: {}", e))?; - // Wait for the server to be ready (poll /health or root, up to 30 seconds) + // Wait for the server to be ready (TCP connect poll, up to 30 seconds) let start = std::time::Instant::now(); let timeout = std::time::Duration::from_secs(30); let mut server_ready = false; + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); while start.elapsed() < timeout { std::thread::sleep(std::time::Duration::from_millis(500)); - if let Ok(resp) = reqwest::blocking::get(format!("http://127.0.0.1:{}/health", port)) { - if resp.status().is_success() || resp.status().is_redirection() { - server_ready = true; - break; - } - } else if let Ok(resp) = reqwest::blocking::get(format!("http://127.0.0.1:{}/", port)) { - if resp.status().is_success() || resp.status().is_redirection() { - server_ready = true; - break; - } + // Check if subprocess died + if let Some(status) = app_process.try_wait().ok().flatten() { + let _ = app_process.kill(); + anyhow::bail!( + "Server process exited with status {} before becoming ready", + status + ); + } + // Try TCP connect with a short timeout + if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)) + .is_ok() + { + server_ready = true; + break; } } if !server_ready { @@ -3357,6 +3391,7 @@ fn analyze_ast_warnings(ast: &ntnt::ast::Program, _source: &str) -> Vec) { use ntnt::ast::{Expression, Statement, StringPart}; + let stmt = unwrap_located(stmt); fn collect_from_expr(expr: &Expression, names: &mut std::collections::HashSet) { match expr { @@ -4888,6 +4923,11 @@ fn generate_syntax_markdown(docs_dir: &std::path::Path) -> anyhow::Result<()> { "option_result", "union", "annotation", + "optional_shorthand", + "type_alias", + "function_type", + "array_type", + "generics", ]; for cat in &type_categories { if let Some(t) = types.get(*cat) { @@ -5517,6 +5557,16 @@ fn generate_runtime_markdown(docs_dir: &std::path::Path) -> anyhow::Result<()> { md.push_str("---\n\n"); } + // Type Safety Modes section (DD-009) + if let Some(tsm) = runtime.get("type_safety_modes") { + md.push_str("## Type Safety Modes\n\n"); + if let Some(content) = tsm.get("content").and_then(|v| v.as_str()) { + md.push_str(content); + md.push_str("\n\n"); + } + md.push_str("---\n\n"); + } + // Hot-Reload if let Some(hot_reload) = runtime.get("hot_reload") { md.push_str("## Hot-Reload\n\n"); diff --git a/src/parser.rs b/src/parser.rs index b6a652d..2f3e67b 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -91,31 +91,38 @@ impl Parser { if self.check(kind) { Ok(self.advance().unwrap().clone()) } else { - let line = self.peek().map(|t| t.line).unwrap_or(0); - let column = self.peek().map(|t| t.column).unwrap_or(0); Err(IntentError::ParserError { - line, - column, + line: self.current_line(), + column: self.current_column(), message: message.to_string(), }) } } fn current_line(&self) -> usize { - self.peek().map(|t| t.line).unwrap_or(0) + self.peek() + .map(|t| t.line) + .or_else(|| self.previous().map(|t| t.line)) + .unwrap_or(0) } fn current_column(&self) -> usize { - self.peek().map(|t| t.column).unwrap_or(0) + self.peek() + .map(|t| t.column) + .or_else(|| self.previous().map(|t| t.column)) + .unwrap_or(0) } // Parsing methods fn declaration(&mut self) -> Result { + let decl_line = self.current_line(); + let decl_col = self.current_column(); + // Check for attributes let attributes = self.parse_attributes()?; - if self.match_token(&[TokenKind::Let]) { + let inner = if self.match_token(&[TokenKind::Let]) { self.let_declaration() } else if self.match_token(&[TokenKind::Fn]) { self.function_declaration(attributes) @@ -142,8 +149,20 @@ impl Parser { } else if self.match_token(&[TokenKind::Server]) { self.server_declaration() } else { - self.statement() - } + // statement() already wraps with Located, so return directly + return self.statement(); + }?; + + // Wrap declarations with Located for line tracking + // (statement() already handles its own Located wrapping) + Ok(match inner { + Statement::Located { .. } => inner, + other => Statement::Located { + line: decl_line, + col: decl_col, + stmt: Box::new(other), + }, + }) } fn parse_attributes(&mut self) -> Result> { @@ -2609,6 +2628,51 @@ impl Parser { } fn parse_single_type(&mut self) -> Result { + // Check for function type: (T1, T2) -> ReturnType + if self.match_token(&[TokenKind::LeftParen]) { + let mut params = Vec::new(); + if !self.check(&TokenKind::RightParen) { + loop { + params.push(self.parse_type()?); + if !self.match_token(&[TokenKind::Comma]) { + break; + } + } + } + self.consume( + &TokenKind::RightParen, + "Expected ')' after function type parameters", + )?; + self.consume( + &TokenKind::Arrow, + "Expected '->' after function type parameters", + )?; + let return_type = self.parse_type()?; + let fn_type = TypeExpr::Function { + params, + return_type: Box::new(return_type), + }; + // Check for optional function type + if self.match_token(&[TokenKind::Question]) { + return Ok(TypeExpr::Optional(Box::new(fn_type))); + } + return Ok(fn_type); + } + + // Check for array type literal: [T] + if self.match_token(&[TokenKind::LeftBracket]) { + let element_type = self.parse_type()?; + self.consume( + &TokenKind::RightBracket, + "Expected ']' after array element type", + )?; + let arr_type = TypeExpr::Array(Box::new(element_type)); + if self.match_token(&[TokenKind::Question]) { + return Ok(TypeExpr::Optional(Box::new(arr_type))); + } + return Ok(arr_type); + } + let name = self.consume_identifier("Expected type name")?; // Check for generic parameters @@ -2709,6 +2773,14 @@ mod tests { parser.parse() } + /// Unwrap a Located wrapper to get the inner statement + fn unwrap_located(stmt: &Statement) -> &Statement { + match stmt { + Statement::Located { stmt, .. } => stmt, + other => other, + } + } + #[test] fn test_let_statement() { let program = parse("let x = 42;").unwrap(); @@ -2742,7 +2814,7 @@ mod tests { ) .unwrap(); assert_eq!(program.statements.len(), 1); - match &program.statements[0] { + match unwrap_located(&program.statements[0]) { Statement::Server { port, routes, .. } => { match port { Expression::Integer(p) => assert_eq!(*p, 8080), @@ -2767,7 +2839,7 @@ mod tests { "#, ) .unwrap(); - match &program.statements[0] { + match unwrap_located(&program.statements[0]) { Statement::Server { routes, .. } => { assert_eq!(routes.len(), 2); // Check typed param @@ -2794,7 +2866,7 @@ mod tests { "#, ) .unwrap(); - match &program.statements[0] { + match unwrap_located(&program.statements[0]) { Statement::Server { directives, .. } => { assert_eq!(directives.len(), 1); match &directives[0] { @@ -2822,7 +2894,7 @@ mod tests { "#, ) .unwrap(); - match &program.statements[0] { + match unwrap_located(&program.statements[0]) { Statement::Server { groups, .. } => { assert_eq!(groups.len(), 1); assert_eq!(groups[0].prefix, "/admin"); @@ -2845,7 +2917,7 @@ mod tests { "#, ) .unwrap(); - match &program.statements[0] { + match unwrap_located(&program.statements[0]) { Statement::Server { directives, .. } => { assert_eq!(directives.len(), 1); match &directives[0] { @@ -2868,7 +2940,7 @@ mod tests { "#, ) .unwrap(); - match &program.statements[0] { + match unwrap_located(&program.statements[0]) { Statement::Server { directives, .. } => { assert_eq!(directives.len(), 1); match &directives[0] { @@ -2894,7 +2966,7 @@ mod tests { "#, ) .unwrap(); - match &program.statements[0] { + match unwrap_located(&program.statements[0]) { Statement::Server { routes, .. } => { assert_eq!(routes.len(), 5); assert_eq!(routes[0].method, "GET"); diff --git a/src/stdlib/auth.rs b/src/stdlib/auth.rs index 6d33e7c..a3a5d12 100644 --- a/src/stdlib/auth.rs +++ b/src/stdlib/auth.rs @@ -603,14 +603,14 @@ pub fn fetch_oidc_discovery(issuer: &str) -> Result { .get(&discovery_url) .header("Accept", "application/json") .send() - .map_err(|e| IntentError::RuntimeError(format!("[auth] OIDC discovery failed: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("[auth] OIDC discovery failed: {}", e)))?; let body = response.text().map_err(|e| { - IntentError::RuntimeError(format!("[auth] Failed to read discovery response: {}", e)) + IntentError::runtime_error(format!("[auth] Failed to read discovery response: {}", e)) })?; let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { - IntentError::RuntimeError(format!("[auth] Failed to parse discovery document: {}", e)) + IntentError::runtime_error(format!("[auth] Failed to parse discovery document: {}", e)) })?; let get_str = |key: &str| -> Option { @@ -632,17 +632,19 @@ pub fn fetch_oidc_discovery(issuer: &str) -> Result { Ok(OidcDiscovery { issuer: get_str("issuer").ok_or_else(|| { - IntentError::RuntimeError("[auth] Discovery missing issuer".to_string()) + IntentError::runtime_error("[auth] Discovery missing issuer".to_string()) })?, authorization_endpoint: get_str("authorization_endpoint").ok_or_else(|| { - IntentError::RuntimeError("[auth] Discovery missing authorization_endpoint".to_string()) + IntentError::runtime_error( + "[auth] Discovery missing authorization_endpoint".to_string(), + ) })?, token_endpoint: get_str("token_endpoint").ok_or_else(|| { - IntentError::RuntimeError("[auth] Discovery missing token_endpoint".to_string()) + IntentError::runtime_error("[auth] Discovery missing token_endpoint".to_string()) })?, userinfo_endpoint: get_str("userinfo_endpoint"), jwks_uri: get_str("jwks_uri").ok_or_else(|| { - IntentError::RuntimeError("[auth] Discovery missing jwks_uri".to_string()) + IntentError::runtime_error("[auth] Discovery missing jwks_uri".to_string()) })?, scopes_supported: get_arr("scopes_supported"), response_types_supported: get_arr("response_types_supported"), @@ -731,14 +733,14 @@ pub fn exchange_code_for_tokens( .header("User-Agent", "NTNT/0.3.13") // Required by GitHub .form(¶ms) .send() - .map_err(|e| IntentError::RuntimeError(format!("[auth] Token exchange failed: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("[auth] Token exchange failed: {}", e)))?; let body = response.text().map_err(|e| { - IntentError::RuntimeError(format!("[auth] Failed to read token response: {}", e)) + IntentError::runtime_error(format!("[auth] Failed to read token response: {}", e)) })?; let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { - IntentError::RuntimeError(format!( + IntentError::runtime_error(format!( "[auth] Failed to parse token response: {} - Body: {}", e, body )) @@ -750,7 +752,7 @@ pub fn exchange_code_for_tokens( .get("error_description") .and_then(|v| v.as_str()) .unwrap_or("Unknown error"); - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "[auth] OAuth error: {} - {}", error.as_str().unwrap_or("unknown"), error_desc @@ -762,7 +764,7 @@ pub fn exchange_code_for_tokens( .and_then(|v| v.as_str()) .map(|s| s.to_string()) .ok_or_else(|| { - IntentError::RuntimeError(format!("[auth] No access_token in response: {}", body)) + IntentError::runtime_error(format!("[auth] No access_token in response: {}", body)) })?; // Default expires_in to 1 hour if not provided (security: don't allow infinite-lived tokens) @@ -813,14 +815,14 @@ pub fn refresh_access_token( .header("Accept", "application/json") .form(¶ms) .send() - .map_err(|e| IntentError::RuntimeError(format!("[auth] Token refresh failed: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("[auth] Token refresh failed: {}", e)))?; let body = response.text().map_err(|e| { - IntentError::RuntimeError(format!("[auth] Failed to read refresh response: {}", e)) + IntentError::runtime_error(format!("[auth] Failed to read refresh response: {}", e)) })?; let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { - IntentError::RuntimeError(format!("[auth] Failed to parse refresh response: {}", e)) + IntentError::runtime_error(format!("[auth] Failed to parse refresh response: {}", e)) })?; if let Some(error) = json.get("error") { @@ -828,7 +830,7 @@ pub fn refresh_access_token( .get("error_description") .and_then(|v| v.as_str()) .unwrap_or("Unknown error"); - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "[auth] Refresh error: {} - {}", error.as_str().unwrap_or("unknown"), error_desc @@ -840,7 +842,7 @@ pub fn refresh_access_token( .and_then(|v| v.as_str()) .map(|s| s.to_string()) .ok_or_else(|| { - IntentError::RuntimeError("[auth] No access_token in refresh response".to_string()) + IntentError::runtime_error("[auth] No access_token in refresh response".to_string()) })?; // Default expires_in to 1 hour if not provided @@ -896,15 +898,15 @@ pub fn client_credentials_grant( .form(¶ms) .send() .map_err(|e| { - IntentError::RuntimeError(format!("[auth] Client credentials grant failed: {}", e)) + IntentError::runtime_error(format!("[auth] Client credentials grant failed: {}", e)) })?; let body = response.text().map_err(|e| { - IntentError::RuntimeError(format!("[auth] Failed to read token response: {}", e)) + IntentError::runtime_error(format!("[auth] Failed to read token response: {}", e)) })?; let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { - IntentError::RuntimeError(format!("[auth] Failed to parse token response: {}", e)) + IntentError::runtime_error(format!("[auth] Failed to parse token response: {}", e)) })?; if let Some(error) = json.get("error") { @@ -912,7 +914,7 @@ pub fn client_credentials_grant( .get("error_description") .and_then(|v| v.as_str()) .unwrap_or("Unknown error"); - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "[auth] Client credentials error: {} - {}", error.as_str().unwrap_or("unknown"), error_desc @@ -924,7 +926,7 @@ pub fn client_credentials_grant( .and_then(|v| v.as_str()) .map(|s| s.to_string()) .ok_or_else(|| { - IntentError::RuntimeError("[auth] No access_token in response".to_string()) + IntentError::runtime_error("[auth] No access_token in response".to_string()) })?; // Default expires_in to 1 hour if not provided @@ -954,17 +956,17 @@ pub fn client_credentials_grant( pub fn decode_id_token(id_token: &str) -> Result> { let parts: Vec<&str> = id_token.split('.').collect(); if parts.len() != 3 { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "[auth] Invalid ID token format".to_string(), )); } let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(parts[1]) - .map_err(|e| IntentError::RuntimeError(format!("[auth] ID token decode error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("[auth] ID token decode error: {}", e)))?; let json: serde_json::Value = serde_json::from_slice(&payload) - .map_err(|e| IntentError::RuntimeError(format!("[auth] ID token parse error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("[auth] ID token parse error: {}", e)))?; json_to_value_map(&json) } @@ -988,11 +990,11 @@ pub fn validate_id_token_claims( } }) .ok_or_else(|| { - IntentError::RuntimeError("[auth] ID token missing issuer".to_string()) + IntentError::runtime_error("[auth] ID token missing issuer".to_string()) })?; if iss != expected_iss { - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "[auth] ID token issuer mismatch: expected {}, got {}", expected_iss, iss ))); @@ -1013,7 +1015,7 @@ pub fn validate_id_token_claims( _ => false, }; if !aud_valid { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "[auth] ID token audience mismatch".to_string(), )); } @@ -1030,11 +1032,11 @@ pub fn validate_id_token_claims( } }) .ok_or_else(|| { - IntentError::RuntimeError("[auth] ID token missing nonce".to_string()) + IntentError::runtime_error("[auth] ID token missing nonce".to_string()) })?; if !constant_time_compare(nonce, expected_n) { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "[auth] ID token nonce mismatch (possible replay attack)".to_string(), )); } @@ -1050,11 +1052,11 @@ pub fn validate_id_token_claims( None } }) - .ok_or_else(|| IntentError::RuntimeError("[auth] ID token missing expiry".to_string()))?; + .ok_or_else(|| IntentError::runtime_error("[auth] ID token missing expiry".to_string()))?; let now = chrono::Utc::now().timestamp(); if now > exp { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "[auth] ID token expired".to_string(), )); } @@ -1080,14 +1082,16 @@ pub fn fetch_userinfo( .header("Accept", "application/json") .header("User-Agent", "NTNT/0.3.13") // Required by GitHub .send() - .map_err(|e| IntentError::RuntimeError(format!("[auth] Userinfo request failed: {}", e)))?; + .map_err(|e| { + IntentError::runtime_error(format!("[auth] Userinfo request failed: {}", e)) + })?; let body = response.text().map_err(|e| { - IntentError::RuntimeError(format!("[auth] Failed to read userinfo response: {}", e)) + IntentError::runtime_error(format!("[auth] Failed to read userinfo response: {}", e)) })?; let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { - IntentError::RuntimeError(format!( + IntentError::runtime_error(format!( "[auth] Failed to parse userinfo: {} - Body: {}", e, body )) @@ -1117,18 +1121,18 @@ pub fn introspect_token( .form(¶ms) .send() .map_err(|e| { - IntentError::RuntimeError(format!("[auth] Token introspection failed: {}", e)) + IntentError::runtime_error(format!("[auth] Token introspection failed: {}", e)) })?; let body = response.text().map_err(|e| { - IntentError::RuntimeError(format!( + IntentError::runtime_error(format!( "[auth] Failed to read introspection response: {}", e )) })?; let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { - IntentError::RuntimeError(format!( + IntentError::runtime_error(format!( "[auth] Failed to parse introspection response: {}", e )) @@ -1146,7 +1150,7 @@ fn json_to_value_map(json: &serde_json::Value) -> Result> } Ok(map) } - _ => Err(IntentError::TypeError("Expected JSON object".to_string())), + _ => Err(IntentError::type_error("Expected JSON object".to_string())), } } @@ -3631,12 +3635,12 @@ pub fn handle_auth_start(args: &[Value]) -> Result { }; let provider_name = provider_name.ok_or_else(|| { - IntentError::RuntimeError("[auth] No provider specified in /auth/{provider}".to_string()) + IntentError::runtime_error("[auth] No provider specified in /auth/{provider}".to_string()) })?; // Get auth config let config = get_auth_config().ok_or_else(|| { - IntentError::RuntimeError( + IntentError::runtime_error( "[auth] Auth not configured - call enable_auth() first".to_string(), ) })?; @@ -3658,7 +3662,7 @@ pub fn handle_auth_start(args: &[Value]) -> Result { provider_name, available_providers() ) }; - IntentError::RuntimeError(msg) + IntentError::runtime_error(msg) })?; // Generate state for CSRF protection @@ -3728,7 +3732,7 @@ pub fn handle_auth_callback(args: &[Value]) -> Result { let req = &args[0]; let config = get_auth_config() - .ok_or_else(|| IntentError::RuntimeError("[auth] Auth not configured".to_string()))?; + .ok_or_else(|| IntentError::runtime_error("[auth] Auth not configured".to_string()))?; // Extract code and state from query params let (code, state, error) = if let Value::Map(req_map) = req { @@ -3858,7 +3862,7 @@ pub fn handle_auth_callback(args: &[Value]) -> Result { }, config.session_ttl, ) - .map_err(|e| IntentError::RuntimeError(format!("[auth] Failed to create session: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("[auth] Failed to create session: {}", e)))?; let session_id = session.id.clone(); store_session(session); @@ -3969,7 +3973,7 @@ pub fn value_to_provider(value: &Value) -> Result { let get_str = |key: &str| -> Result { match map.get(key) { Some(Value::String(s)) => Ok(s.clone()), - _ => Err(IntentError::TypeError(format!( + _ => Err(IntentError::type_error(format!( "Provider {} must be a string", key ))), @@ -4052,7 +4056,9 @@ pub fn value_to_provider(value: &Value) -> Result { supports_oidc: get_bool("supports_oidc", false), }) } - _ => Err(IntentError::TypeError("Provider must be a map".to_string())), + _ => Err(IntentError::type_error( + "Provider must be a map".to_string(), + )), } } @@ -4091,14 +4097,14 @@ pub fn init() -> HashMap { max_arity: 0, func: |args| { if args.is_empty() { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] oauth() requires at least a provider name".to_string() )); } let provider_name = match &args[0] { Value::String(s) => s.clone(), - _ => return Err(IntentError::TypeError( + _ => return Err(IntentError::type_error( "[auth] oauth() first argument must be a provider name string".to_string() )), }; @@ -4109,7 +4115,7 @@ pub fn init() -> HashMap { // Signature: oauth(provider, client_id, client_secret, options?) let client_secret = match args.get(2) { Some(Value::String(s)) => s.clone(), - Some(_) => return Err(IntentError::TypeError( + Some(_) => return Err(IntentError::type_error( "[auth] oauth() client_secret must be a string".to_string() )), None => String::new(), // Allow empty for PKCE public clients @@ -4117,7 +4123,7 @@ pub fn init() -> HashMap { let options = match args.get(3) { Some(Value::Map(m)) => Some(m.clone()), - Some(_) => return Err(IntentError::TypeError( + Some(_) => return Err(IntentError::type_error( "[auth] oauth() options must be a map".to_string() )), None => None, @@ -4150,7 +4156,7 @@ pub fn init() -> HashMap { provider_name, available_providers(), provider_name ) }; - return Err(IntentError::RuntimeError(msg)); + return Err(IntentError::runtime_error(msg)); }; // Check if PKCE is explicitly requested or required @@ -4161,7 +4167,7 @@ pub fn init() -> HashMap { .unwrap_or(provider_name == "twitter"); // Twitter requires PKCE if use_pkce && !supports_pkce { - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "[auth] Provider \"{}\" does not support PKCE", provider_name ))); @@ -4228,7 +4234,7 @@ pub fn init() -> HashMap { }; let client_id = get_str("client_id").ok_or_else(|| { - IntentError::TypeError(format!( + IntentError::type_error(format!( "[auth] Custom provider \"{}\" missing required field \"client_id\"", provider_name )) @@ -4237,14 +4243,14 @@ pub fn init() -> HashMap { let client_secret = get_str("client_secret").unwrap_or_default(); let authorize_url = get_str("authorize_url").ok_or_else(|| { - IntentError::TypeError(format!( + IntentError::type_error(format!( "[auth] Custom provider \"{}\" missing required field \"authorize_url\"", provider_name )) })?; let token_url = get_str("token_url").ok_or_else(|| { - IntentError::TypeError(format!( + IntentError::type_error(format!( "[auth] Custom provider \"{}\" missing required field \"token_url\"", provider_name )) @@ -4287,10 +4293,10 @@ pub fn init() -> HashMap { Ok(provider_to_value(&config)) } - Some(_) => Err(IntentError::TypeError( + Some(_) => Err(IntentError::type_error( "[auth] oauth() second argument must be client_id (string) or config (map)".to_string() )), - None => Err(IntentError::TypeError( + None => Err(IntentError::type_error( "[auth] oauth() requires credentials or configuration".to_string() )), } @@ -4322,7 +4328,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| { if args.len() < 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] oauth_discover() requires issuer and client_id".to_string(), )); } @@ -4330,7 +4336,7 @@ pub fn init() -> HashMap { let issuer = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] oauth_discover() issuer must be a string".to_string(), )) } @@ -4339,7 +4345,7 @@ pub fn init() -> HashMap { let client_id = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] oauth_discover() client_id must be a string".to_string(), )) } @@ -4458,7 +4464,7 @@ pub fn init() -> HashMap { max_arity: 4, func: |args| { if args.len() < 4 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] oauth_m2m() requires token_url, client_id, client_secret, scopes" .to_string(), )); @@ -4467,7 +4473,7 @@ pub fn init() -> HashMap { let token_url = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] token_url must be a string".to_string(), )) } @@ -4475,7 +4481,7 @@ pub fn init() -> HashMap { let client_id = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] client_id must be a string".to_string(), )) } @@ -4483,7 +4489,7 @@ pub fn init() -> HashMap { let client_secret = match &args[2] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] client_secret must be a string".to_string(), )) } @@ -4500,7 +4506,7 @@ pub fn init() -> HashMap { }) .collect(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] scopes must be an array".to_string(), )) } @@ -4549,25 +4555,25 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| { if args.is_empty() { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] oauth_refresh() requires a request".to_string(), )); } let config = get_auth_config().ok_or_else(|| { - IntentError::RuntimeError("[auth] Auth not configured".to_string()) + IntentError::runtime_error("[auth] Auth not configured".to_string()) })?; let session_id = get_session_id_from_request(&args[0]).ok_or_else(|| { - IntentError::RuntimeError("[auth] No session found".to_string()) + IntentError::runtime_error("[auth] No session found".to_string()) })?; let session = get_session_by_id(&session_id).ok_or_else(|| { - IntentError::RuntimeError("[auth] Session expired".to_string()) + IntentError::runtime_error("[auth] Session expired".to_string()) })?; let refresh_token = session.refresh_token.as_ref().ok_or_else(|| { - IntentError::RuntimeError( + IntentError::runtime_error( "[auth] No refresh token stored (enable store_tokens in auth config)" .to_string(), ) @@ -4578,7 +4584,7 @@ pub fn init() -> HashMap { .iter() .find(|p| p.name == session.provider) .ok_or_else(|| { - IntentError::RuntimeError(format!( + IntentError::runtime_error(format!( "[auth] Provider {} not found", session.provider )) @@ -4628,7 +4634,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| { if args.len() < 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] oauth_validate() requires token and options".to_string(), )); } @@ -4643,7 +4649,7 @@ pub fn init() -> HashMap { } } _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] token must be a string".to_string(), )) } @@ -4652,7 +4658,7 @@ pub fn init() -> HashMap { let options = match &args[1] { Value::Map(m) => m.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] options must be a map".to_string(), )) } @@ -4716,26 +4722,26 @@ pub fn init() -> HashMap { max_arity: 4, func: |args| { if args.len() < 4 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] oauth_introspect() requires introspection_url, token, client_id, client_secret".to_string() )); } let introspection_url = match &args[0] { Value::String(s) => s.clone(), - _ => return Err(IntentError::TypeError("[auth] introspection_url must be a string".to_string())), + _ => return Err(IntentError::type_error("[auth] introspection_url must be a string".to_string())), }; let token = match &args[1] { Value::String(s) => s.clone(), - _ => return Err(IntentError::TypeError("[auth] token must be a string".to_string())), + _ => return Err(IntentError::type_error("[auth] token must be a string".to_string())), }; let client_id = match &args[2] { Value::String(s) => s.clone(), - _ => return Err(IntentError::TypeError("[auth] client_id must be a string".to_string())), + _ => return Err(IntentError::type_error("[auth] client_id must be a string".to_string())), }; let client_secret = match &args[3] { Value::String(s) => s.clone(), - _ => return Err(IntentError::TypeError("[auth] client_secret must be a string".to_string())), + _ => return Err(IntentError::type_error("[auth] client_secret must be a string".to_string())), }; match introspect_token(&introspection_url, &token, &client_id, &client_secret) { @@ -4767,7 +4773,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| { if args.is_empty() { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] get_user() requires a request".to_string(), )); } @@ -4804,7 +4810,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| { if args.is_empty() { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] get_session() requires a request".to_string(), )); } @@ -4843,7 +4849,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| { if args.is_empty() { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] session_data() requires a request".to_string(), )); } @@ -4903,7 +4909,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| { if args.is_empty() { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] validate_csrf() requires a request".to_string(), )); } @@ -5040,7 +5046,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| { if args.len() < 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] set_session() requires request and data".to_string(), )); } @@ -5048,7 +5054,7 @@ pub fn init() -> HashMap { let data_map = match &args[1] { Value::Map(m) => m.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] set_session() data must be a map".to_string(), )) } @@ -5139,7 +5145,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| { if args.is_empty() { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] user_sessions() requires a request".to_string(), )); } @@ -5203,7 +5209,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| { if args.len() < 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] logout_all() requires request and keep_current".to_string(), )); } @@ -5211,7 +5217,7 @@ pub fn init() -> HashMap { let keep_current = match &args[1] { Value::Bool(b) => *b, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] keep_current must be a boolean".to_string(), )) } @@ -5258,7 +5264,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| { if args.is_empty() { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] csrf_token() requires a request".to_string(), )); } @@ -5296,7 +5302,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| { if args.is_empty() { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] csrf_field() requires a request".to_string(), )); } @@ -5340,7 +5346,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| { if args.len() < 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] verify_csrf() requires request and token".to_string(), )); } @@ -5348,7 +5354,7 @@ pub fn init() -> HashMap { let submitted_token = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] token must be a string".to_string(), )) } @@ -5393,7 +5399,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| { if args.len() < 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] jwt_sign() requires claims and secret".to_string(), )); } @@ -5401,7 +5407,7 @@ pub fn init() -> HashMap { let claims = match &args[0] { Value::Map(m) => m.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] claims must be a map".to_string(), )) } @@ -5410,7 +5416,7 @@ pub fn init() -> HashMap { let secret = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] secret must be a string".to_string(), )) } @@ -5491,7 +5497,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| { if args.len() < 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] jwt_verify() requires token and secret".to_string(), )); } @@ -5499,7 +5505,7 @@ pub fn init() -> HashMap { let token = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] token must be a string".to_string(), )) } @@ -5508,7 +5514,7 @@ pub fn init() -> HashMap { let secret = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] secret must be a string".to_string(), )) } @@ -5556,7 +5562,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| { if args.is_empty() { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] jwt_decode() requires a token".to_string(), )); } @@ -5564,7 +5570,7 @@ pub fn init() -> HashMap { let token = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] token must be a string".to_string(), )) } @@ -5663,7 +5669,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| { if args.is_empty() { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] logout_user() requires a request".to_string(), )); } @@ -5715,7 +5721,7 @@ pub fn init() -> HashMap { max_arity: 0, func: |args| { if args.is_empty() { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] enable_auth() requires a providers array".to_string(), )); } @@ -5724,7 +5730,7 @@ pub fn init() -> HashMap { let providers_arr = match &args[0] { Value::Array(arr) => arr.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] enable_auth() first argument must be an array of providers" .to_string(), )) @@ -5734,7 +5740,7 @@ pub fn init() -> HashMap { let options = match args.get(1) { Some(Value::Map(m)) => Some(m.clone()), Some(_) => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] enable_auth() second argument must be an options map" .to_string(), )) @@ -5748,7 +5754,7 @@ pub fn init() -> HashMap { match pval { Value::Map(pmap) => { let provider = value_map_to_provider(pmap).map_err(|e| { - IntentError::TypeError(format!( + IntentError::type_error(format!( "[auth] Invalid provider at index {}: {}", idx, e )) @@ -5756,7 +5762,7 @@ pub fn init() -> HashMap { providers.push(provider); } _ => { - return Err(IntentError::TypeError(format!( + return Err(IntentError::type_error(format!( "[auth] Provider at index {} must be a map (use oauth() to create)", idx ))); @@ -5856,7 +5862,7 @@ pub fn init() -> HashMap { SessionStore::Sqlite(path) => { if let Err(e) = init_sqlite_sessions(path) { eprintln!("[auth] Failed to initialize SQLite sessions: {}", e); - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "Failed to initialize SQLite session store: {}", e ))); @@ -5865,7 +5871,7 @@ pub fn init() -> HashMap { SessionStore::Postgres(url) => { if let Err(e) = init_postgres_sessions(url) { eprintln!("[auth] Failed to initialize PostgreSQL sessions: {}", e); - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "Failed to initialize PostgreSQL session store: {}", e ))); @@ -5874,7 +5880,7 @@ pub fn init() -> HashMap { SessionStore::Redis(url) => { if let Err(e) = init_redis_sessions(url) { eprintln!("[auth] Failed to initialize Redis sessions: {}", e); - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "Failed to initialize Redis session store: {}", e ))); @@ -5948,7 +5954,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| { if args.len() < 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] oauth_start() requires (provider, redirect_uri)".to_string(), )); } @@ -5964,7 +5970,7 @@ pub fn init() -> HashMap { let redirect_uri = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] redirect_uri must be a string".to_string(), )) } @@ -6036,7 +6042,7 @@ pub fn init() -> HashMap { max_arity: 4, func: |args| { if args.len() < 4 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] oauth_exchange() requires (provider, code, state, redirect_uri)" .to_string(), )); @@ -6053,7 +6059,7 @@ pub fn init() -> HashMap { let code = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] code must be a string".to_string(), )) } @@ -6062,7 +6068,7 @@ pub fn init() -> HashMap { let state = match &args[2] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] state must be a string".to_string(), )) } @@ -6071,7 +6077,7 @@ pub fn init() -> HashMap { let redirect_uri = match &args[3] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] redirect_uri must be a string".to_string(), )) } @@ -6159,7 +6165,7 @@ pub fn init() -> HashMap { max_arity: 0, func: |args| { if args.len() < 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] create_session_from_oauth() requires (provider_name, user_info, tokens?)".to_string() )); } @@ -6171,12 +6177,12 @@ pub fn init() -> HashMap { let provider_name = match &args[0] { Value::String(s) => s.clone(), - _ => return Err(IntentError::TypeError("[auth] provider_name must be a string".to_string())), + _ => return Err(IntentError::type_error("[auth] provider_name must be a string".to_string())), }; let user_info = match &args[1] { Value::Map(m) => m.clone(), - _ => return Err(IntentError::TypeError("[auth] user_info must be a map".to_string())), + _ => return Err(IntentError::type_error("[auth] user_info must be a map".to_string())), }; // Parse optional tokens @@ -6380,7 +6386,7 @@ pub fn init() -> HashMap { func: |args| { eprintln!("[DEPRECATED] hash_password() in std/auth is deprecated. Use hash_password() from std/crypto instead."); if args.is_empty() { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] hash_password() requires a password".to_string(), )); } @@ -6388,7 +6394,7 @@ pub fn init() -> HashMap { let password = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] password must be a string".to_string(), )) } @@ -6424,7 +6430,7 @@ pub fn init() -> HashMap { func: |args| { eprintln!("[DEPRECATED] verify_password() in std/auth is deprecated. Use verify_password() from std/crypto instead."); if args.len() < 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] verify_password() requires (password, hash)".to_string(), )); } @@ -6432,7 +6438,7 @@ pub fn init() -> HashMap { let password = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] password must be a string".to_string(), )) } @@ -6441,7 +6447,7 @@ pub fn init() -> HashMap { let hash = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] hash must be a string".to_string(), )) } @@ -6501,7 +6507,7 @@ pub fn init() -> HashMap { max_arity: 3, func: |args| { if args.len() < 3 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] totp_uri() requires (secret, email, issuer)".to_string(), )); } @@ -6509,7 +6515,7 @@ pub fn init() -> HashMap { let secret = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] secret must be a string".to_string(), )) } @@ -6518,7 +6524,7 @@ pub fn init() -> HashMap { let email = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] email must be a string".to_string(), )) } @@ -6527,7 +6533,7 @@ pub fn init() -> HashMap { let issuer = match &args[2] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] issuer must be a string".to_string(), )) } @@ -6563,7 +6569,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| { if args.len() < 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] verify_totp() requires (secret, code)".to_string(), )); } @@ -6571,7 +6577,7 @@ pub fn init() -> HashMap { let secret = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] secret must be a string".to_string(), )) } @@ -6580,7 +6586,7 @@ pub fn init() -> HashMap { let code = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "[auth] code must be a string".to_string(), )) } diff --git a/src/stdlib/collections.rs b/src/stdlib/collections.rs index 67108d6..9967806 100644 --- a/src/stdlib/collections.rs +++ b/src/stdlib/collections.rs @@ -36,7 +36,7 @@ pub fn init() -> HashMap { new_arr.push(args[1].clone()); Ok(Value::Array(new_arr)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "push() requires an array".to_string(), )), }, @@ -76,7 +76,7 @@ pub fn init() -> HashMap { // Return tuple of (new array, popped value) Ok(Value::Array(vec![Value::Array(new_arr), opt_val])) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "pop() requires an array".to_string(), )), } @@ -109,7 +109,7 @@ pub fn init() -> HashMap { max_arity: 0, func: |args| { if args.is_empty() || args.len() > 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "first() requires 1 or 2 arguments".to_string(), )); } @@ -117,7 +117,7 @@ pub fn init() -> HashMap { let arr = match &args[0] { Value::Array(arr) => arr, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "first() requires an array as first argument".to_string(), )) } @@ -162,7 +162,7 @@ pub fn init() -> HashMap { max_arity: 0, func: |args| { if args.is_empty() || args.len() > 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "last() requires 1 or 2 arguments".to_string(), )); } @@ -170,7 +170,7 @@ pub fn init() -> HashMap { let arr = match &args[0] { Value::Array(arr) => arr, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "last() requires an array as first argument".to_string(), )) } @@ -215,7 +215,7 @@ pub fn init() -> HashMap { new_arr.reverse(); Ok(Value::Array(new_arr)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "reverse() requires an array".to_string(), )), }, @@ -250,11 +250,13 @@ pub fn init() -> HashMap { let start = *start as usize; let end = (*end as usize).min(arr.len()); if start > arr.len() || start > end { - return Err(IntentError::RuntimeError("Invalid slice range".to_string())); + return Err(IntentError::runtime_error( + "Invalid slice range".to_string(), + )); } Ok(Value::Array(arr[start..end].to_vec())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "slice() requires array, int, int".to_string(), )), }, @@ -288,7 +290,7 @@ pub fn init() -> HashMap { new_arr.extend(arr2.clone()); Ok(Value::Array(new_arr)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "concat() requires two arrays".to_string(), )), }, @@ -319,7 +321,7 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Array(arr) => Ok(Value::Bool(arr.is_empty())), Value::String(s) => Ok(Value::Bool(s.is_empty())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_empty() requires array or string".to_string(), )), }, @@ -352,7 +354,7 @@ pub fn init() -> HashMap { let keys: Vec = map.keys().map(|k| Value::String(k.clone())).collect(); Ok(Value::Array(keys)) } - _ => Err(IntentError::TypeError("keys() requires a map".to_string())), + _ => Err(IntentError::type_error("keys() requires a map".to_string())), }, }, ); @@ -382,7 +384,7 @@ pub fn init() -> HashMap { let values: Vec = map.values().cloned().collect(); Ok(Value::Array(values)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "values() requires a map".to_string(), )), }, @@ -423,7 +425,7 @@ pub fn init() -> HashMap { .collect(); Ok(Value::Array(entries)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "entries() requires a map".to_string(), )), }, @@ -454,7 +456,7 @@ pub fn init() -> HashMap { // Non-map first argument: return false instead of crashing. // has_key() is a check — it should be safe to call on anything. (_, Value::String(_)) => Ok(Value::Bool(false)), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "has_key() requires a map and string key".to_string(), )), }, @@ -486,7 +488,7 @@ pub fn init() -> HashMap { func: |args| { eprintln!("[DEPRECATED] get_key() is deprecated. Use get_or() instead."); if args.len() < 2 || args.len() > 3 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "get_key() requires 2 or 3 arguments: get_key(map, key) or get_key(map, key, default)".to_string() )); } @@ -514,7 +516,7 @@ pub fn init() -> HashMap { } } } - _ => Err(IntentError::TypeError("get_key() requires a map and string key".to_string())), + _ => Err(IntentError::type_error("get_key() requires a map and string key".to_string())), } }, }); @@ -547,7 +549,7 @@ pub fn init() -> HashMap { max_arity: 0, func: |args| { if args.len() < 2 || args.len() > 3 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "get_index() requires 2 or 3 arguments: get_index(arr, index) or get_index(arr, index, default)".to_string() )); } @@ -555,7 +557,7 @@ pub fn init() -> HashMap { let arr = match &args[0] { Value::Array(arr) => arr, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "get_index() requires an array as first argument".to_string(), )) } @@ -564,7 +566,7 @@ pub fn init() -> HashMap { let index = match &args[1] { Value::Int(i) => *i, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "get_index() requires an integer as second argument".to_string(), )) } @@ -628,7 +630,7 @@ pub fn init() -> HashMap { } Ok(Value::Map(result)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "merge() requires two maps".to_string(), )), }, @@ -665,7 +667,7 @@ pub fn init() -> HashMap { // Non-map first argument: return the default value instead of crashing. // get_or() exists for defensive access — it should never be the thing that crashes. (_, Value::String(_)) => Ok(args[2].clone()), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "get_or() requires a map (or any value), string key, and default value" .to_string(), )), @@ -703,7 +705,7 @@ pub fn init() -> HashMap { let found = arr.iter().any(|item| values_equal(item, needle)); Ok(Value::Bool(found)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "includes() requires an array as first argument".to_string(), )), }, @@ -735,7 +737,7 @@ pub fn init() -> HashMap { let found = arr.iter().any(|item| values_equal(item, needle)); Ok(Value::Bool(found)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "has_value() requires an array as first argument".to_string(), )), } diff --git a/src/stdlib/concurrent.rs b/src/stdlib/concurrent.rs index 762e07c..557007c 100644 --- a/src/stdlib/concurrent.rs +++ b/src/stdlib/concurrent.rs @@ -105,7 +105,7 @@ impl SerializedValue { serialized.insert("__values".to_string(), SerializedValue::Array(vals?)); Ok(SerializedValue::Map(serialized)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "Only primitive types (Int, Float, String, Bool, Array, Map) can be sent through channels".to_string() )), } @@ -181,10 +181,10 @@ fn get_channel_id(ch: &Value) -> Result { if let Some(Value::Int(id)) = map.get("_channel_id") { Ok(*id as u64) } else { - Err(IntentError::TypeError("Expected a Channel".to_string())) + Err(IntentError::type_error("Expected a Channel".to_string())) } } - _ => Err(IntentError::TypeError("Expected a Channel".to_string())), + _ => Err(IntentError::type_error("Expected a Channel".to_string())), } } @@ -215,7 +215,7 @@ fn concurrent_send(ch: &Value, value: &Value) -> Result { let registry = CHANNEL_REGISTRY .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock registry: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock registry: {}", e)))?; if let Some(pair) = registry.get(&id) { // Check if closed @@ -228,7 +228,7 @@ fn concurrent_send(ch: &Value, value: &Value) -> Result { Err(_) => Ok(Value::Bool(false)), // Receiver dropped } } else { - Err(IntentError::RuntimeError("Invalid channel".to_string())) + Err(IntentError::runtime_error("Invalid channel".to_string())) } } @@ -241,18 +241,18 @@ fn concurrent_recv(ch: &Value) -> Result { let receiver = { let registry = CHANNEL_REGISTRY .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock registry: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock registry: {}", e)))?; if let Some(pair) = registry.get(&id) { Arc::clone(&pair.receiver) } else { - return Err(IntentError::RuntimeError("Invalid channel".to_string())); + return Err(IntentError::runtime_error("Invalid channel".to_string())); } }; let rx = receiver .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock receiver: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock receiver: {}", e)))?; match rx.recv() { Ok(serialized) => Ok(serialized.to_value()), @@ -268,18 +268,18 @@ fn concurrent_recv_timeout(ch: &Value, timeout_ms: i64) -> Result { let receiver = { let registry = CHANNEL_REGISTRY .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock registry: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock registry: {}", e)))?; if let Some(pair) = registry.get(&id) { Arc::clone(&pair.receiver) } else { - return Err(IntentError::RuntimeError("Invalid channel".to_string())); + return Err(IntentError::runtime_error("Invalid channel".to_string())); } }; let rx = receiver .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock receiver: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock receiver: {}", e)))?; match rx.recv_timeout(Duration::from_millis(timeout_ms as u64)) { Ok(serialized) => Ok(Value::some(serialized.to_value())), @@ -296,18 +296,18 @@ fn concurrent_try_recv(ch: &Value) -> Result { let receiver = { let registry = CHANNEL_REGISTRY .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock registry: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock registry: {}", e)))?; if let Some(pair) = registry.get(&id) { Arc::clone(&pair.receiver) } else { - return Err(IntentError::RuntimeError("Invalid channel".to_string())); + return Err(IntentError::runtime_error("Invalid channel".to_string())); } }; let rx = receiver .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock receiver: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock receiver: {}", e)))?; match rx.try_recv() { Ok(serialized) => Ok(Value::some(serialized.to_value())), @@ -323,7 +323,7 @@ fn concurrent_close(ch: &Value) -> Result { let registry = CHANNEL_REGISTRY .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock registry: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock registry: {}", e)))?; if let Some(pair) = registry.get(&id) { let mut closed = pair.closed.lock().unwrap(); @@ -430,7 +430,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| match &args[1] { Value::Int(ms) => concurrent_recv_timeout(&args[0], *ms), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "recv_timeout requires (channel, int_millis)".to_string(), )), }, @@ -492,7 +492,7 @@ pub fn init() -> HashMap { ); match &args[0] { Value::Int(ms) => concurrent_sleep_ms(*ms), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "sleep_ms requires an integer".to_string(), )), } diff --git a/src/stdlib/crypto.rs b/src/stdlib/crypto.rs index 73724f4..a25ed98 100644 --- a/src/stdlib/crypto.rs +++ b/src/stdlib/crypto.rs @@ -57,7 +57,7 @@ pub fn init() -> HashMap { .iter() .map(|v| match v { Value::Int(i) => Ok(*i as u8), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "sha256() array must contain integers".to_string(), )), }) @@ -68,7 +68,7 @@ pub fn init() -> HashMap { let result = hasher.finalize(); Ok(Value::String(hex::encode(result))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "sha256() requires a string or byte array".to_string(), )), } @@ -99,7 +99,7 @@ pub fn init() -> HashMap { let bytes: Vec = result.iter().map(|b| Value::Int(*b as i64)).collect(); Ok(Value::Array(bytes)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "sha256_bytes() requires a string".to_string(), )), }, @@ -126,12 +126,12 @@ pub fn init() -> HashMap { (Value::String(key), Value::String(data)) => { type HmacSha256 = Hmac; let mut mac = ::new_from_slice(key.as_bytes()) - .map_err(|e| IntentError::RuntimeError(format!("HMAC error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("HMAC error: {}", e)))?; mac.update(data.as_bytes()); let result = mac.finalize(); Ok(Value::String(hex::encode(result.into_bytes()))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "hmac_sha256() requires two strings (key, data)".to_string(), )), }, @@ -171,7 +171,7 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Int(n) => { if *n < 0 || *n > 1024 * 1024 { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "random_bytes() size must be 0-1048576".to_string(), )); } @@ -180,7 +180,7 @@ pub fn init() -> HashMap { let values: Vec = bytes.iter().map(|b| Value::Int(*b as i64)).collect(); Ok(Value::Array(values)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "random_bytes() requires an integer".to_string(), )), }, @@ -204,7 +204,7 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Int(n) => { if *n < 0 || *n > 1024 * 1024 { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "random_hex() size must be 0-1048576".to_string(), )); } @@ -212,7 +212,7 @@ pub fn init() -> HashMap { rand::thread_rng().fill_bytes(&mut bytes); Ok(Value::String(hex::encode(bytes))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "random_hex() requires an integer".to_string(), )), }, @@ -240,7 +240,7 @@ pub fn init() -> HashMap { .iter() .map(|v| match v { Value::Int(i) => Ok(*i as u8), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "hex_encode() array must contain integers".to_string(), )), }) @@ -248,7 +248,7 @@ pub fn init() -> HashMap { Ok(Value::String(hex::encode(byte_vec?))) } Value::String(s) => Ok(Value::String(hex::encode(s.as_bytes()))), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "hex_encode() requires array or string".to_string(), )), }, @@ -280,7 +280,7 @@ pub fn init() -> HashMap { } Err(e) => Ok(Value::err(Value::String(e.to_string()))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "hex_decode() requires a string".to_string(), )), }, @@ -314,7 +314,7 @@ pub fn init() -> HashMap { max_arity: 0, func: |args| { if args.is_empty() || args.len() > 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "hash_password() requires 1 or 2 arguments (password, optional cost)" .to_string(), )); @@ -323,7 +323,7 @@ pub fn init() -> HashMap { let password = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "hash_password() requires a string password".to_string(), )) } @@ -341,7 +341,7 @@ pub fn init() -> HashMap { *c as u32 } _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "hash_password() cost must be an integer".to_string(), )) } @@ -384,7 +384,7 @@ pub fn init() -> HashMap { let password = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "verify_password() requires a string password".to_string(), )) } @@ -393,7 +393,7 @@ pub fn init() -> HashMap { let hash = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "verify_password() requires a string hash".to_string(), )) } @@ -433,7 +433,7 @@ pub fn init() -> HashMap { let hash = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "is_valid_hash() requires a string".to_string(), )) } @@ -467,7 +467,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(data) => Ok(Value::String(STANDARD.encode(data.as_bytes()))), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "base64_encode() requires a string".to_string(), )), }, @@ -503,7 +503,7 @@ pub fn init() -> HashMap { e )))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "base64_decode() requires a string".to_string(), )), }, @@ -529,7 +529,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(data) => Ok(Value::String(URL_SAFE_NO_PAD.encode(data.as_bytes()))), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "base64url_encode() requires a string".to_string(), )), }, @@ -565,7 +565,7 @@ pub fn init() -> HashMap { e )))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "base64url_decode() requires a string".to_string(), )), }, @@ -619,7 +619,7 @@ pub fn init() -> HashMap { let plaintext = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "aes_encrypt() requires a string plaintext".to_string(), )) } @@ -627,7 +627,7 @@ pub fn init() -> HashMap { let key_hex = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "aes_encrypt() requires a hex string key".to_string(), )) } @@ -689,7 +689,7 @@ pub fn init() -> HashMap { let ciphertext_b64 = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "aes_decrypt() requires a string ciphertext".to_string(), )) } @@ -697,7 +697,7 @@ pub fn init() -> HashMap { let key_hex = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "aes_decrypt() requires a hex string key".to_string(), )) } @@ -772,21 +772,21 @@ pub fn init() -> HashMap { let password = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "argon2_hash() requires a string password".to_string(), )) } }; let params = Params::new(19456, 2, 1, None).map_err(|e| { - IntentError::RuntimeError(format!("Argon2 params error: {}", e)) + IntentError::runtime_error(format!("Argon2 params error: {}", e)) })?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let salt = SaltString::generate(&mut OsRng); match argon2.hash_password(password.as_bytes(), &salt) { Ok(hash) => Ok(Value::String(hash.to_string())), - Err(e) => Err(IntentError::RuntimeError(format!( + Err(e) => Err(IntentError::runtime_error(format!( "Argon2 hash error: {}", e ))), @@ -818,7 +818,7 @@ pub fn init() -> HashMap { let password = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "argon2_verify() requires a string password".to_string(), )) } @@ -826,7 +826,7 @@ pub fn init() -> HashMap { let hash_str = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "argon2_verify() requires a string hash".to_string(), )) } @@ -872,7 +872,7 @@ pub fn init() -> HashMap { type HmacSha256 = Hmac; let mut mac = ::new_from_slice(secret.as_bytes()) - .map_err(|e| IntentError::RuntimeError(format!("HMAC error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("HMAC error: {}", e)))?; mac.update(token.as_bytes()); let hash = hex::encode(mac.finalize().into_bytes()); @@ -910,13 +910,13 @@ pub fn init() -> HashMap { type HmacSha256 = Hmac; let mut mac = ::new_from_slice(secret.as_bytes()) - .map_err(|e| IntentError::RuntimeError(format!("HMAC error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("HMAC error: {}", e)))?; mac.update(token.as_bytes()); let expected = hex::encode(mac.finalize().into_bytes()); Ok(Value::Bool(expected == *hash)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "csrf_validate() requires two string arguments (token, hash)".to_string(), )), }, diff --git a/src/stdlib/csv.rs b/src/stdlib/csv.rs index f499b2f..f1783d9 100644 --- a/src/stdlib/csv.rs +++ b/src/stdlib/csv.rs @@ -33,7 +33,7 @@ pub fn init() -> HashMap { let csv_string = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "parse_csv() requires a string".to_string(), )) } @@ -84,7 +84,7 @@ pub fn init() -> HashMap { let csv_string = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "csv.parse_with_headers() requires a string".to_string(), )) } @@ -149,7 +149,7 @@ pub fn init() -> HashMap { let rows = match &args[0] { Value::Array(arr) => arr, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "csv.stringify() requires an array".to_string(), )) } @@ -174,7 +174,7 @@ pub fn init() -> HashMap { let row = match row_value { Value::Array(arr) => arr, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "csv.stringify: each row must be an array".to_string(), )) } @@ -219,7 +219,7 @@ pub fn init() -> HashMap { let rows = match &args[0] { Value::Array(arr) => arr, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "csv.stringify_with_headers() first arg must be array".to_string(), )) } @@ -230,13 +230,13 @@ pub fn init() -> HashMap { .iter() .map(|v| match v { Value::String(s) => Ok(s.clone()), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "Headers must be strings".to_string(), )), }) .collect::, _>>()?, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "csv.stringify_with_headers() second arg must be headers array" .to_string(), )) @@ -269,7 +269,7 @@ pub fn init() -> HashMap { let row = match row_value { Value::Map(m) => m, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "csv.stringify_with_headers: each row must be a map".to_string(), )) } diff --git a/src/stdlib/env.rs b/src/stdlib/env.rs index 853772c..2302d1f 100644 --- a/src/stdlib/env.rs +++ b/src/stdlib/env.rs @@ -33,7 +33,7 @@ pub fn init() -> HashMap { Ok(val) => Ok(Value::some(Value::String(val))), Err(_) => Ok(Value::none()), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "get_env() requires a string".to_string(), )), }, @@ -80,7 +80,7 @@ pub fn init() -> HashMap { max_arity: 0, func: |_args| match std::env::current_dir() { Ok(path) => Ok(Value::String(path.to_string_lossy().to_string())), - Err(e) => Err(IntentError::RuntimeError(format!( + Err(e) => Err(IntentError::runtime_error(format!( "Failed to get cwd: {}", e ))), @@ -132,7 +132,7 @@ pub fn init() -> HashMap { )))), } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "load_env() requires a string path".to_string(), )), } diff --git a/src/stdlib/fs.rs b/src/stdlib/fs.rs index 1b15478..57a99a1 100644 --- a/src/stdlib/fs.rs +++ b/src/stdlib/fs.rs @@ -36,7 +36,7 @@ pub fn init() -> HashMap { Ok(content) => Ok(Value::ok(Value::String(content))), Err(e) => Ok(Value::err(Value::String(e.to_string()))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "read_file() requires a string path".to_string(), )), }, @@ -72,7 +72,7 @@ pub fn init() -> HashMap { } Err(e) => Ok(Value::err(Value::String(e.to_string()))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "read_bytes() requires a string path".to_string(), )), }, @@ -106,7 +106,7 @@ pub fn init() -> HashMap { Ok(()) => Ok(Value::ok(Value::Unit)), Err(e) => Ok(Value::err(Value::String(e.to_string()))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "write_file() requires path and content strings".to_string(), )), }, @@ -151,7 +151,7 @@ pub fn init() -> HashMap { Err(e) => Ok(Value::err(Value::String(e.to_string()))), } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "append_file() requires path and content strings".to_string(), )), } @@ -181,7 +181,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(path) => Ok(Value::Bool(std::path::Path::new(path).exists())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "exists() requires a string path".to_string(), )), }, @@ -210,7 +210,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(path) => Ok(Value::Bool(std::path::Path::new(path).is_file())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_file() requires a string path".to_string(), )), }, @@ -238,7 +238,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(path) => Ok(Value::Bool(std::path::Path::new(path).is_dir())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_dir() requires a string path".to_string(), )), }, @@ -271,7 +271,7 @@ pub fn init() -> HashMap { Ok(()) => Ok(Value::ok(Value::Unit)), Err(e) => Ok(Value::err(Value::String(e.to_string()))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "mkdir() requires a string path".to_string(), )), }, @@ -303,7 +303,7 @@ pub fn init() -> HashMap { Ok(()) => Ok(Value::ok(Value::Unit)), Err(e) => Ok(Value::err(Value::String(e.to_string()))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "mkdir_all() requires a string path".to_string(), )), }, @@ -342,7 +342,7 @@ pub fn init() -> HashMap { } Err(e) => Ok(Value::err(Value::String(e.to_string()))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "readdir() requires a string path".to_string(), )), }, @@ -374,7 +374,7 @@ pub fn init() -> HashMap { Ok(()) => Ok(Value::ok(Value::Unit)), Err(e) => Ok(Value::err(Value::String(e.to_string()))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "remove() requires a string path".to_string(), )), }, @@ -407,7 +407,7 @@ pub fn init() -> HashMap { Ok(()) => Ok(Value::ok(Value::Unit)), Err(e) => Ok(Value::err(Value::String(e.to_string()))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "remove_dir() requires a string path".to_string(), )), }, @@ -440,7 +440,7 @@ pub fn init() -> HashMap { Ok(()) => Ok(Value::ok(Value::Unit)), Err(e) => Ok(Value::err(Value::String(e.to_string()))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "remove_dir_all() requires a string path".to_string(), )), }, @@ -475,7 +475,7 @@ pub fn init() -> HashMap { Ok(()) => Ok(Value::ok(Value::Unit)), Err(e) => Ok(Value::err(Value::String(e.to_string()))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "rename() requires two string paths".to_string(), )), }, @@ -509,7 +509,7 @@ pub fn init() -> HashMap { Ok(bytes) => Ok(Value::ok(Value::Int(bytes as i64))), Err(e) => Ok(Value::err(Value::String(e.to_string()))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "copy() requires two string paths".to_string(), )), }, @@ -541,7 +541,7 @@ pub fn init() -> HashMap { Ok(meta) => Ok(Value::ok(Value::Int(meta.len() as i64))), Err(e) => Ok(Value::err(Value::String(e.to_string()))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "file_size() requires a string path".to_string(), )), }, diff --git a/src/stdlib/http.rs b/src/stdlib/http.rs index c70b2e5..fae55f5 100644 --- a/src/stdlib/http.rs +++ b/src/stdlib/http.rs @@ -624,7 +624,7 @@ fn http_fetch(opts: &HashMap) -> Result { let url = match opts.get("url") { Some(Value::String(u)) => u.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "fetch() requires 'url' option".to_string(), )) } @@ -647,7 +647,7 @@ fn http_fetch(opts: &HashMap) -> Result { let client = reqwest::blocking::Client::builder() .cookie_store(true) .build() - .map_err(|e| IntentError::RuntimeError(format!("Failed to create HTTP client: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to create HTTP client: {}", e)))?; let mut request = match method.as_str() { "GET" => client.get(&url), @@ -657,7 +657,7 @@ fn http_fetch(opts: &HashMap) -> Result { "PATCH" => client.patch(&url), "HEAD" => client.head(&url), _ => { - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "Unsupported HTTP method: {}", method ))) @@ -802,7 +802,7 @@ fn http_download(url: &str, file_path: &str) -> Result { if let Some(parent) = path.parent() { if !parent.exists() { std::fs::create_dir_all(parent).map_err(|e| { - IntentError::RuntimeError(format!("Failed to create directory: {}", e)) + IntentError::runtime_error(format!("Failed to create directory: {}", e)) })?; } } @@ -895,7 +895,7 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::String(url) => http_get(url), Value::Map(opts) => http_fetch(opts), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "fetch() requires a URL string or options map".to_string(), )), }, @@ -929,7 +929,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| match (&args[0], &args[1]) { (Value::String(url), Value::String(file_path)) => http_download(url, file_path), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "download() requires URL string and file path string".to_string(), )), }, @@ -973,7 +973,7 @@ pub fn init() -> HashMap { Ok(Value::Map(cache_obj)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "Cache() requires TTL in seconds (integer)".to_string(), )), }, @@ -1011,10 +1011,10 @@ pub fn init() -> HashMap { Value::Map(m) => match m.get("_cache_id") { Some(Value::Int(id)) => *id as u64, _ => { - return Err(IntentError::TypeError("Invalid cache object".to_string())) + return Err(IntentError::type_error("Invalid cache object".to_string())) } }, - _ => return Err(IntentError::TypeError("Expected cache object".to_string())), + _ => return Err(IntentError::type_error("Expected cache object".to_string())), }; match &args[1] { @@ -1023,14 +1023,14 @@ pub fn init() -> HashMap { let url = match opts.get("url") { Some(Value::String(u)) => u.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "Options must include 'url'".to_string(), )) } }; cache_fetch(cache_id, &url, Some(opts)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "cache.fetch() requires URL string or options map".to_string(), )), } @@ -1066,17 +1066,17 @@ pub fn init() -> HashMap { Value::Map(m) => match m.get("_cache_id") { Some(Value::Int(id)) => *id as u64, _ => { - return Err(IntentError::TypeError("Invalid cache object".to_string())) + return Err(IntentError::type_error("Invalid cache object".to_string())) } }, - _ => return Err(IntentError::TypeError("Expected cache object".to_string())), + _ => return Err(IntentError::type_error("Expected cache object".to_string())), }; if let Value::String(url) = &args[1] { cache_delete(cache_id, url); Ok(Value::Unit) } else { - Err(IntentError::TypeError( + Err(IntentError::type_error( "cache.delete() requires URL string".to_string(), )) } @@ -1110,10 +1110,10 @@ pub fn init() -> HashMap { Value::Map(m) => match m.get("_cache_id") { Some(Value::Int(id)) => *id as u64, _ => { - return Err(IntentError::TypeError("Invalid cache object".to_string())) + return Err(IntentError::type_error("Invalid cache object".to_string())) } }, - _ => return Err(IntentError::TypeError("Expected cache object".to_string())), + _ => return Err(IntentError::type_error("Expected cache object".to_string())), }; cache_clear(cache_id); diff --git a/src/stdlib/http_bridge.rs b/src/stdlib/http_bridge.rs index fba27b4..5283f12 100644 --- a/src/stdlib/http_bridge.rs +++ b/src/stdlib/http_bridge.rs @@ -220,11 +220,11 @@ impl InterpreterHandle { self.tx .send(handler_request) .await - .map_err(|_| IntentError::RuntimeError("Interpreter channel closed".to_string()))?; + .map_err(|_| IntentError::runtime_error("Interpreter channel closed".to_string()))?; reply_rx .await - .map_err(|_| IntentError::RuntimeError("Interpreter did not respond".to_string())) + .map_err(|_| IntentError::runtime_error("Interpreter did not respond".to_string())) } } diff --git a/src/stdlib/http_server.rs b/src/stdlib/http_server.rs index 2bd3530..b86a375 100644 --- a/src/stdlib/http_server.rs +++ b/src/stdlib/http_server.rs @@ -1160,7 +1160,7 @@ pub fn init() -> HashMap { ); Ok(create_response_value(200, headers, body.clone())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "text() requires a string".to_string(), )), } @@ -1198,7 +1198,7 @@ pub fn init() -> HashMap { max_arity: 3, func: |args| { if args.is_empty() || args.len() > 3 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "html() requires 1 to 3 arguments (body, optional status_code, optional headers)".to_string(), )); } @@ -1206,7 +1206,7 @@ pub fn init() -> HashMap { let body = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "html() body must be a string".to_string(), )) } @@ -1216,7 +1216,7 @@ pub fn init() -> HashMap { match &args[1] { Value::Int(code) => *code, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "html() status code must be an integer".to_string(), )) } @@ -1251,7 +1251,7 @@ pub fn init() -> HashMap { } } _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "html() headers must be a map".to_string(), )) } @@ -1290,7 +1290,7 @@ pub fn init() -> HashMap { max_arity: 3, func: |args| { if args.is_empty() || args.len() > 3 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "json() requires 1 to 3 arguments (data, optional status_code, optional headers)".to_string(), )); } @@ -1299,7 +1299,7 @@ pub fn init() -> HashMap { match &args[1] { Value::Int(code) => *code, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "json() status code must be an integer".to_string(), )) } @@ -1345,7 +1345,7 @@ pub fn init() -> HashMap { } } _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "json() headers must be a map".to_string(), )) } @@ -1387,7 +1387,7 @@ pub fn init() -> HashMap { ); Ok(create_response_value(*code, headers, body.clone())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "status() requires int and string".to_string(), )), }, @@ -1425,7 +1425,7 @@ pub fn init() -> HashMap { headers.insert("location".to_string(), Value::String(url.clone())); Ok(create_response_value(302, headers, String::new())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "redirect() requires a URL string".to_string(), )), }, @@ -1462,7 +1462,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| { if args.is_empty() || args.len() > 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "redirect_safe() requires 1 or 2 arguments (url, optional fallback)" .to_string(), )); @@ -1471,7 +1471,7 @@ pub fn init() -> HashMap { let url = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "redirect_safe() requires a URL string".to_string(), )) } @@ -1558,7 +1558,7 @@ pub fn init() -> HashMap { ); Ok(create_response_value(500, headers, msg.clone())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "error() requires a string".to_string(), )), }, @@ -1593,25 +1593,25 @@ pub fn init() -> HashMap { max_arity: 3, func: |args| { if args.len() < 2 || args.len() > 3 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "static_file() requires 2-3 arguments (content, content_type, optional max_age)".to_string() )); } let content = match &args[0] { Value::String(s) => s.clone(), - _ => return Err(IntentError::TypeError("static_file() content must be a string".to_string())), + _ => return Err(IntentError::type_error("static_file() content must be a string".to_string())), }; let content_type = match &args[1] { Value::String(s) => s.clone(), - _ => return Err(IntentError::TypeError("static_file() content_type must be a string".to_string())), + _ => return Err(IntentError::type_error("static_file() content_type must be a string".to_string())), }; let max_age = if args.len() == 3 { match &args[2] { Value::Int(n) => *n, - _ => return Err(IntentError::TypeError("static_file() max_age must be an integer".to_string())), + _ => return Err(IntentError::type_error("static_file() max_age must be an integer".to_string())), } } else { 3600 // Default 1 hour @@ -1659,7 +1659,7 @@ pub fn init() -> HashMap { let status = match &args[0] { Value::Int(code) => *code, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "response() status must be an integer".to_string(), )) } @@ -1668,7 +1668,7 @@ pub fn init() -> HashMap { let custom_headers = match &args[1] { Value::Map(map) => map.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "response() headers must be a map".to_string(), )) } @@ -1677,7 +1677,7 @@ pub fn init() -> HashMap { let body = match &args[2] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "response() body must be a string".to_string(), )) } @@ -1722,14 +1722,14 @@ pub fn init() -> HashMap { Value::Map(map) => match map.get("body") { Some(Value::String(b)) => b.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "parse_json() requires a request with body".to_string(), )) } }, Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "parse_json() requires a request map or body string".to_string(), )) } @@ -1775,14 +1775,14 @@ pub fn init() -> HashMap { Value::Map(map) => match map.get("body") { Some(Value::String(b)) => b.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "parse_form() requires a request with body".to_string(), )) } }, Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "parse_form() requires a request map or body string".to_string(), )) } @@ -1858,7 +1858,7 @@ pub fn init() -> HashMap { max_arity: 3, func: |args| { if args.len() < 2 || args.len() > 3 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "set_cookie() requires 2 or 3 arguments (name, value, optional options)" .to_string(), )); @@ -1867,7 +1867,7 @@ pub fn init() -> HashMap { let name = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "set_cookie() name must be a string".to_string(), )) } @@ -1875,7 +1875,7 @@ pub fn init() -> HashMap { // Validate cookie name (RFC 6265) if !is_valid_cookie_name(&name) { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "set_cookie() name contains invalid characters (must be alphanumeric, -, _, or .)".to_string(), )); } @@ -1883,7 +1883,7 @@ pub fn init() -> HashMap { let value = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "set_cookie() value must be a string".to_string(), )) } @@ -1893,7 +1893,7 @@ pub fn init() -> HashMap { match &args[2] { Value::Map(m) => m.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "set_cookie() options must be a map".to_string(), )) } @@ -1937,7 +1937,7 @@ pub fn init() -> HashMap { _ => HashMap::new(), }, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "get_cookie() requires a request map".to_string(), )) } @@ -1946,7 +1946,7 @@ pub fn init() -> HashMap { let name = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "get_cookie() name must be a string".to_string(), )) } @@ -2005,7 +2005,7 @@ pub fn init() -> HashMap { _ => HashMap::new(), }, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "get_cookies() requires a request map".to_string(), )) } @@ -2060,7 +2060,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| { if args.is_empty() || args.len() > 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "delete_cookie() requires 1 or 2 arguments (name, optional options)" .to_string(), )); @@ -2069,7 +2069,7 @@ pub fn init() -> HashMap { let name = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "delete_cookie() name must be a string".to_string(), )) } @@ -2079,7 +2079,7 @@ pub fn init() -> HashMap { match &args[1] { Value::Map(m) => m.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "delete_cookie() options must be a map".to_string(), )) } @@ -2125,7 +2125,7 @@ pub fn init() -> HashMap { max_arity: 4, func: |args| { if args.len() < 3 || args.len() > 4 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "with_cookie() requires 3 or 4 arguments (response, name, value, optional options)" .to_string(), )); @@ -2134,7 +2134,7 @@ pub fn init() -> HashMap { let mut response = match &args[0] { Value::Map(m) => m.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "with_cookie() response must be a map".to_string(), )) } @@ -2143,7 +2143,7 @@ pub fn init() -> HashMap { let name = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "with_cookie() name must be a string".to_string(), )) } @@ -2152,7 +2152,7 @@ pub fn init() -> HashMap { let value = match &args[2] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "with_cookie() value must be a string".to_string(), )) } @@ -2162,7 +2162,7 @@ pub fn init() -> HashMap { match &args[3] { Value::Map(m) => m.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "with_cookie() options must be a map".to_string(), )) } @@ -2277,7 +2277,7 @@ pub fn init() -> HashMap { (content_type, body) } _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "parse_multipart() requires a request map".to_string(), )) } @@ -2354,7 +2354,7 @@ pub fn init() -> HashMap { } }, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "save_upload() first argument must be a file map".to_string(), )) } @@ -2363,7 +2363,7 @@ pub fn init() -> HashMap { let path = match &args[1] { Value::String(p) => p.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "save_upload() second argument must be a path string".to_string(), )) } @@ -2737,7 +2737,7 @@ fn parse_cookie_header(header: &str) -> HashMap { pub fn start_server(port: u16) -> Result { let addr = format!("0.0.0.0:{}", port); tiny_http::Server::http(&addr) - .map_err(|e| IntentError::RuntimeError(format!("Failed to start server: {}", e))) + .map_err(|e| IntentError::runtime_error(format!("Failed to start server: {}", e))) } /// Start the HTTP server with timeout support (for test mode) @@ -2748,7 +2748,7 @@ pub fn start_server_with_timeout( ) -> Result { let addr = format!("127.0.0.1:{}", port); tiny_http::Server::http(&addr) - .map_err(|e| IntentError::RuntimeError(format!("Failed to start test server: {}", e))) + .map_err(|e| IntentError::runtime_error(format!("Failed to start test server: {}", e))) } /// Read request body and create request Value @@ -2768,7 +2768,7 @@ pub fn process_request( .and_then(|h| h.value.as_str().parse::().ok()) { if content_length > max_size { - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "Request body too large: {} bytes exceeds limit of {} bytes. \ Configure with NTNT_MAX_BODY_SIZE environment variable.", content_length, max_size @@ -2784,7 +2784,7 @@ pub fn process_request( match limited_reader.read_to_string(&mut body_string) { Ok(n) => { if n > max_size { - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "Request body too large: {} bytes exceeds limit of {} bytes. \ Configure with NTNT_MAX_BODY_SIZE environment variable.", n, max_size @@ -2792,7 +2792,7 @@ pub fn process_request( } } Err(e) => { - return Err(IntentError::RuntimeError(format!( + return Err(IntentError::runtime_error(format!( "Failed to read request body: {}", e ))); @@ -2829,7 +2829,11 @@ pub fn send_response(request: tiny_http::Request, response: &Value) -> Result<() (status, headers, body) } - _ => return Err(IntentError::TypeError("Response must be a map".to_string())), + _ => { + return Err(IntentError::type_error( + "Response must be a map".to_string(), + )) + } }; // Add security headers if enabled (apps can override by setting headers explicitly) @@ -2901,7 +2905,7 @@ pub fn send_response(request: tiny_http::Request, response: &Value) -> Result<() request .respond(response_builder) - .map_err(|e| IntentError::RuntimeError(format!("Failed to send response: {}", e))) + .map_err(|e| IntentError::runtime_error(format!("Failed to send response: {}", e))) } /// Create an error response @@ -3139,16 +3143,16 @@ pub fn serve_static_file(file_path: &str) -> Result { { // Text files - read as string fs::read_to_string(path) - .map_err(|e| IntentError::RuntimeError(format!("Failed to read file: {}", e)))? + .map_err(|e| IntentError::runtime_error(format!("Failed to read file: {}", e)))? } else { // Binary files - read as bytes and encode as base64 or raw // For now, we'll read as lossy UTF-8 (works for most text, not ideal for binary) // A proper solution would need binary response support let mut file = fs::File::open(path) - .map_err(|e| IntentError::RuntimeError(format!("Failed to open file: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to open file: {}", e)))?; let mut buffer = Vec::new(); file.read_to_end(&mut buffer) - .map_err(|e| IntentError::RuntimeError(format!("Failed to read file: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to read file: {}", e)))?; // For binary files, we need to handle them differently // For now, return raw bytes (this works with tiny_http's response) @@ -3185,7 +3189,7 @@ pub fn send_static_response(request: tiny_http::Request, file_path: &str) -> Res // Generate ETag from file metadata (size + mtime) let metadata = fs::metadata(path) - .map_err(|e| IntentError::RuntimeError(format!("Failed to stat file: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to stat file: {}", e)))?; let etag = if let Ok(mtime) = metadata.modified() { let duration = mtime .duration_since(std::time::UNIX_EPOCH) @@ -3210,19 +3214,19 @@ pub fn send_static_response(request: tiny_http::Request, file_path: &str) -> Res if let Some(ref client_etag) = if_none_match { if client_etag == &etag || client_etag == "*" { let etag_header = tiny_http::Header::from_bytes(b"ETag", etag.as_bytes()) - .map_err(|_| IntentError::RuntimeError("Invalid header".to_string()))?; + .map_err(|_| IntentError::runtime_error("Invalid header".to_string()))?; let cache_header = tiny_http::Header::from_bytes( b"Cache-Control", sync_cache_control_for(file_path).as_bytes(), ) - .map_err(|_| IntentError::RuntimeError("Invalid header".to_string()))?; + .map_err(|_| IntentError::runtime_error("Invalid header".to_string()))?; let response = tiny_http::Response::from_data(Vec::::new()) .with_status_code(304) .with_header(etag_header) .with_header(cache_header); - return request - .respond(response) - .map_err(|e| IntentError::RuntimeError(format!("Failed to send response: {}", e))); + return request.respond(response).map_err(|e| { + IntentError::runtime_error(format!("Failed to send response: {}", e)) + }); } } @@ -3231,25 +3235,25 @@ pub fn send_static_response(request: tiny_http::Request, file_path: &str) -> Res // Open and read the file let mut file = File::open(path) - .map_err(|e| IntentError::RuntimeError(format!("Failed to open file: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to open file: {}", e)))?; let mut buffer = Vec::new(); file.read_to_end(&mut buffer) - .map_err(|e| IntentError::RuntimeError(format!("Failed to read file: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to read file: {}", e)))?; // Build response with proper headers let content_type = tiny_http::Header::from_bytes(b"Content-Type", mime_type.as_bytes()) - .map_err(|_| IntentError::RuntimeError("Invalid header".to_string()))?; + .map_err(|_| IntentError::runtime_error("Invalid header".to_string()))?; let cache_control = tiny_http::Header::from_bytes( b"Cache-Control", sync_cache_control_for(file_path).as_bytes(), ) - .map_err(|_| IntentError::RuntimeError("Invalid header".to_string()))?; + .map_err(|_| IntentError::runtime_error("Invalid header".to_string()))?; let etag_header = tiny_http::Header::from_bytes(b"ETag", etag.as_bytes()) - .map_err(|_| IntentError::RuntimeError("Invalid header".to_string()))?; + .map_err(|_| IntentError::runtime_error("Invalid header".to_string()))?; let connection_close = tiny_http::Header::from_bytes(b"Connection", b"close") - .map_err(|_| IntentError::RuntimeError("Invalid header".to_string()))?; + .map_err(|_| IntentError::runtime_error("Invalid header".to_string()))?; let server_header = tiny_http::Header::from_bytes(b"Server", b"ntnt-http") - .map_err(|_| IntentError::RuntimeError("Invalid header".to_string()))?; + .map_err(|_| IntentError::runtime_error("Invalid header".to_string()))?; let response = tiny_http::Response::from_data(buffer) .with_status_code(200) @@ -3261,7 +3265,7 @@ pub fn send_static_response(request: tiny_http::Request, file_path: &str) -> Res request .respond(response) - .map_err(|e| IntentError::RuntimeError(format!("Failed to send response: {}", e))) + .map_err(|e| IntentError::runtime_error(format!("Failed to send response: {}", e))) } /// Returns appropriate Cache-Control value based on file type (sync server). diff --git a/src/stdlib/http_server_async.rs b/src/stdlib/http_server_async.rs index 7edc990..9a7075a 100644 --- a/src/stdlib/http_server_async.rs +++ b/src/stdlib/http_server_async.rs @@ -372,7 +372,7 @@ async fn axum_to_bridge_request( // Read body let body_bytes = axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024) .await - .map_err(|e| IntentError::RuntimeError(format!("Failed to read body: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to read body: {}", e)))?; let body = String::from_utf8_lossy(&body_bytes).to_string(); Ok(BridgeRequest { @@ -770,7 +770,7 @@ pub async fn start_server_with_bridge( ) -> Result<()> { let addr: SocketAddr = format!("{}:{}", config.host, config.port) .parse() - .map_err(|e| IntentError::RuntimeError(format!("Invalid address: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Invalid address: {}", e)))?; let route_count = routes.route_count().await; let static_count = routes.static_dir_count().await; @@ -821,13 +821,13 @@ pub async fn start_server_with_bridge( // Create the listener let listener = tokio::net::TcpListener::bind(addr) .await - .map_err(|e| IntentError::RuntimeError(format!("Failed to bind: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to bind: {}", e)))?; // Run the server with graceful shutdown axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await - .map_err(|e| IntentError::RuntimeError(format!("Server error: {}", e))) + .map_err(|e| IntentError::runtime_error(format!("Server error: {}", e))) } /// Signal handler for graceful shutdown diff --git a/src/stdlib/json.rs b/src/stdlib/json.rs index 8df734d..6acce1f 100644 --- a/src/stdlib/json.rs +++ b/src/stdlib/json.rs @@ -114,7 +114,7 @@ pub fn init() -> HashMap { Err(e) => Ok(Value::err(Value::String(e.to_string()))), } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "parse_json() requires a JSON string".to_string(), )), }, diff --git a/src/stdlib/kv.rs b/src/stdlib/kv.rs index b45ab25..a4d61df 100644 --- a/src/stdlib/kv.rs +++ b/src/stdlib/kv.rs @@ -196,7 +196,7 @@ impl SQLiteKV { } else { Connection::open(path) } - .map_err(|e| IntentError::RuntimeError(format!("Failed to open KV store: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to open KV store: {}", e)))?; // Create the KV table if it doesn't exist conn.execute( @@ -208,7 +208,7 @@ impl SQLiteKV { )", [], ) - .map_err(|e| IntentError::RuntimeError(format!("Failed to create KV table: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to create KV table: {}", e)))?; // Create indices for performance conn.execute( @@ -236,7 +236,7 @@ impl SQLiteKV { match result { Ok((value, type_hint)) => Ok(Some(deserialize_value(&value, &type_hint))), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(IntentError::RuntimeError(format!("KV get error: {}", e))), + Err(e) => Err(IntentError::runtime_error(format!("KV get error: {}", e))), } } @@ -255,7 +255,7 @@ impl SQLiteKV { "INSERT OR REPLACE INTO _kv (key, value, type, expires_at) VALUES (?, ?, ?, ?)", params![key, serialized, type_hint, expires_at], ) - .map_err(|e| IntentError::RuntimeError(format!("KV set error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("KV set error: {}", e)))?; Ok(()) } @@ -265,7 +265,7 @@ impl SQLiteKV { let changes = self .conn .execute("DELETE FROM _kv WHERE key = ?", params![key]) - .map_err(|e| IntentError::RuntimeError(format!("KV del error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("KV del error: {}", e)))?; Ok(changes > 0) } @@ -281,7 +281,7 @@ impl SQLiteKV { params![key, now], |row| row.get(0), ) - .map_err(|e| IntentError::RuntimeError(format!("KV has error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("KV has error: {}", e)))?; Ok(count > 0) } @@ -296,24 +296,24 @@ impl SQLiteKV { .prepare( "SELECT key FROM _kv WHERE key LIKE ? AND (expires_at IS NULL OR expires_at > ?)", ) - .map_err(|e| IntentError::RuntimeError(format!("KV list error: {}", e)))?, + .map_err(|e| IntentError::runtime_error(format!("KV list error: {}", e)))?, None => self .conn .prepare("SELECT key FROM _kv WHERE expires_at IS NULL OR expires_at > ?") - .map_err(|e| IntentError::RuntimeError(format!("KV list error: {}", e)))?, + .map_err(|e| IntentError::runtime_error(format!("KV list error: {}", e)))?, }; let keys: Vec = match prefix { Some(p) => { let pattern = format!("{}%", p); stmt.query_map(params![pattern, now], |row| row.get(0)) - .map_err(|e| IntentError::RuntimeError(format!("KV list error: {}", e)))? + .map_err(|e| IntentError::runtime_error(format!("KV list error: {}", e)))? .filter_map(|r| r.ok()) .collect() } None => stmt .query_map(params![now], |row| row.get(0)) - .map_err(|e| IntentError::RuntimeError(format!("KV list error: {}", e)))? + .map_err(|e| IntentError::runtime_error(format!("KV list error: {}", e)))? .filter_map(|r| r.ok()) .collect(), }; @@ -338,7 +338,7 @@ impl SQLiteKV { "UPDATE _kv SET expires_at = ? WHERE key = ? AND (expires_at IS NULL OR expires_at > ?)", params![expires_at, key, now], ) - .map_err(|e| IntentError::RuntimeError(format!("KV expire error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("KV expire error: {}", e)))?; Ok(changes > 0) } @@ -357,7 +357,7 @@ impl SQLiteKV { Ok(Some(expires_at)) => Ok(Some((expires_at - now).max(0))), Ok(None) => Ok(None), // Key exists but has no expiry Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), // Key doesn't exist - Err(e) => Err(IntentError::RuntimeError(format!("KV ttl error: {}", e))), + Err(e) => Err(IntentError::runtime_error(format!("KV ttl error: {}", e))), } } @@ -365,7 +365,7 @@ impl SQLiteKV { pub fn flush(&self) -> Result<()> { self.conn .execute("DELETE FROM _kv", []) - .map_err(|e| IntentError::RuntimeError(format!("KV flush error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("KV flush error: {}", e)))?; Ok(()) } @@ -386,12 +386,12 @@ impl RedisKV { }; let client = redis::Client::open(normalized_url.as_str()).map_err(|e| { - IntentError::RuntimeError(format!("Failed to create Redis client: {}", e)) + IntentError::runtime_error(format!("Failed to create Redis client: {}", e)) })?; - let conn = client - .get_connection() - .map_err(|e| IntentError::RuntimeError(format!("Failed to connect to Redis: {}", e)))?; + let conn = client.get_connection().map_err(|e| { + IntentError::runtime_error(format!("Failed to connect to Redis: {}", e)) + })?; Ok(RedisKV { conn }) } @@ -401,7 +401,7 @@ impl RedisKV { let value: Option = self .conn .get(key) - .map_err(|e| IntentError::RuntimeError(format!("Redis get error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Redis get error: {}", e)))?; match value { Some(data) => { @@ -431,12 +431,12 @@ impl RedisKV { Some(ttl) => { self.conn .set_ex::<_, _, ()>(key, &serialized, ttl as u64) - .map_err(|e| IntentError::RuntimeError(format!("Redis set error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Redis set error: {}", e)))?; } None => { self.conn .set::<_, _, ()>(key, &serialized) - .map_err(|e| IntentError::RuntimeError(format!("Redis set error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Redis set error: {}", e)))?; } } @@ -453,7 +453,7 @@ impl RedisKV { let deleted: i32 = self .conn .del(key) - .map_err(|e| IntentError::RuntimeError(format!("Redis del error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Redis del error: {}", e)))?; // Also delete the type key let _: i32 = self.conn.del(&type_key).unwrap_or(0); Ok(deleted > 0) @@ -464,7 +464,7 @@ impl RedisKV { let exists: bool = self .conn .exists(key) - .map_err(|e| IntentError::RuntimeError(format!("Redis exists error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Redis exists error: {}", e)))?; Ok(exists) } @@ -486,7 +486,7 @@ impl RedisKV { .arg("COUNT") .arg(100) .query(&mut self.conn) - .map_err(|e| IntentError::RuntimeError(format!("Redis scan error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Redis scan error: {}", e)))?; all_keys.extend(batch); cursor = next_cursor; @@ -514,7 +514,7 @@ impl RedisKV { let success: bool = self .conn .expire(key, seconds) - .map_err(|e| IntentError::RuntimeError(format!("Redis expire error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Redis expire error: {}", e)))?; Ok(success) } @@ -524,7 +524,7 @@ impl RedisKV { let ttl: i64 = self .conn .ttl(key) - .map_err(|e| IntentError::RuntimeError(format!("Redis ttl error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Redis ttl error: {}", e)))?; match ttl { -2 => Ok(None), // Key doesn't exist @@ -537,7 +537,7 @@ impl RedisKV { pub fn flush(&mut self) -> Result<()> { redis::cmd("FLUSHDB") .query::<()>(&mut self.conn) - .map_err(|e| IntentError::RuntimeError(format!("Redis flush error: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Redis flush error: {}", e)))?; Ok(()) } } @@ -554,18 +554,18 @@ fn get_backend_type(handle: &Value) -> Result { match backend.as_str() { "sqlite" => Ok(KVBackend::SQLite), "redis" | "valkey" => Ok(KVBackend::Redis), - _ => Err(IntentError::TypeError(format!( + _ => Err(IntentError::type_error(format!( "Unknown KV backend: {}", backend ))), } } else { - Err(IntentError::TypeError( + Err(IntentError::type_error( "Expected a KV store handle".to_string(), )) } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "Expected a KV store handle".to_string(), )), } @@ -581,16 +581,16 @@ fn get_sqlite_kv(handle: &Value) -> Result>> { return Ok(Arc::clone(kv)); } } - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "Invalid or closed KV store".to_string(), )) } else { - Err(IntentError::TypeError( + Err(IntentError::type_error( "Expected a KV store handle".to_string(), )) } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "Expected a KV store handle".to_string(), )), } @@ -606,16 +606,16 @@ fn get_redis_kv(handle: &Value) -> Result>> { return Ok(Arc::clone(kv)); } } - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "Invalid or closed KV store".to_string(), )) } else { - Err(IntentError::TypeError( + Err(IntentError::type_error( "Expected a KV store handle".to_string(), )) } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "Expected a KV store handle".to_string(), )), } @@ -651,7 +651,7 @@ pub fn create_kv_module() -> HashMap { max_arity: 1, func: |args| { if args.len() != 1 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "open() requires 1 argument (url)".to_string(), )); } @@ -659,7 +659,7 @@ pub fn create_kv_module() -> HashMap { let url = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "open() requires a string argument".to_string(), )) } @@ -731,7 +731,7 @@ pub fn create_kv_module() -> HashMap { max_arity: 2, func: |args| { if args.len() != 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "get() requires 2 arguments (kv, key)".to_string(), )); } @@ -739,7 +739,7 @@ pub fn create_kv_module() -> HashMap { let key = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "get() requires a string key".to_string(), )) } @@ -750,14 +750,14 @@ pub fn create_kv_module() -> HashMap { KVBackend::SQLite => { let kv_arc = get_sqlite_kv(&args[0])?; let kv = kv_arc.lock().map_err(|e| { - IntentError::RuntimeError(format!("KV lock error: {}", e)) + IntentError::runtime_error(format!("KV lock error: {}", e)) })?; kv.get(&key)? } KVBackend::Redis => { let kv_arc = get_redis_kv(&args[0])?; let mut kv = kv_arc.lock().map_err(|e| { - IntentError::RuntimeError(format!("KV lock error: {}", e)) + IntentError::runtime_error(format!("KV lock error: {}", e)) })?; kv.get(&key)? } @@ -795,7 +795,7 @@ pub fn create_kv_module() -> HashMap { max_arity: 4, func: |args| { if args.len() < 3 || args.len() > 4 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "set() requires 3-4 arguments (kv, key, value, opts?)".to_string(), )); } @@ -803,7 +803,7 @@ pub fn create_kv_module() -> HashMap { let key = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "set() requires a string key".to_string(), )) } @@ -828,14 +828,14 @@ pub fn create_kv_module() -> HashMap { KVBackend::SQLite => { let kv_arc = get_sqlite_kv(&args[0])?; let kv = kv_arc.lock().map_err(|e| { - IntentError::RuntimeError(format!("KV lock error: {}", e)) + IntentError::runtime_error(format!("KV lock error: {}", e)) })?; kv.set(&key, value, ttl)?; } KVBackend::Redis => { let kv_arc = get_redis_kv(&args[0])?; let mut kv = kv_arc.lock().map_err(|e| { - IntentError::RuntimeError(format!("KV lock error: {}", e)) + IntentError::runtime_error(format!("KV lock error: {}", e)) })?; kv.set(&key, value, ttl)?; } @@ -864,7 +864,7 @@ pub fn create_kv_module() -> HashMap { max_arity: 2, func: |args| { if args.len() != 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "del() requires 2 arguments (kv, key)".to_string(), )); } @@ -872,7 +872,7 @@ pub fn create_kv_module() -> HashMap { let key = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "del() requires a string key".to_string(), )) } @@ -883,14 +883,14 @@ pub fn create_kv_module() -> HashMap { KVBackend::SQLite => { let kv_arc = get_sqlite_kv(&args[0])?; let kv = kv_arc.lock().map_err(|e| { - IntentError::RuntimeError(format!("KV lock error: {}", e)) + IntentError::runtime_error(format!("KV lock error: {}", e)) })?; kv.del(&key)? } KVBackend::Redis => { let kv_arc = get_redis_kv(&args[0])?; let mut kv = kv_arc.lock().map_err(|e| { - IntentError::RuntimeError(format!("KV lock error: {}", e)) + IntentError::runtime_error(format!("KV lock error: {}", e)) })?; kv.del(&key)? } @@ -919,7 +919,7 @@ pub fn create_kv_module() -> HashMap { max_arity: 2, func: |args| { if args.len() != 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "has() requires 2 arguments (kv, key)".to_string(), )); } @@ -927,7 +927,7 @@ pub fn create_kv_module() -> HashMap { let key = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "has() requires a string key".to_string(), )) } @@ -938,14 +938,14 @@ pub fn create_kv_module() -> HashMap { KVBackend::SQLite => { let kv_arc = get_sqlite_kv(&args[0])?; let kv = kv_arc.lock().map_err(|e| { - IntentError::RuntimeError(format!("KV lock error: {}", e)) + IntentError::runtime_error(format!("KV lock error: {}", e)) })?; kv.has(&key)? } KVBackend::Redis => { let kv_arc = get_redis_kv(&args[0])?; let mut kv = kv_arc.lock().map_err(|e| { - IntentError::RuntimeError(format!("KV lock error: {}", e)) + IntentError::runtime_error(format!("KV lock error: {}", e)) })?; kv.has(&key)? } @@ -976,7 +976,7 @@ pub fn create_kv_module() -> HashMap { max_arity: 2, func: |args| { if args.is_empty() || args.len() > 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "list() requires 1-2 arguments (kv, prefix?)".to_string(), )); } @@ -986,7 +986,7 @@ pub fn create_kv_module() -> HashMap { Value::String(s) => Some(s.clone()), Value::Unit => None, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "list() prefix must be a string".to_string(), )) } @@ -1000,14 +1000,14 @@ pub fn create_kv_module() -> HashMap { KVBackend::SQLite => { let kv_arc = get_sqlite_kv(&args[0])?; let kv = kv_arc.lock().map_err(|e| { - IntentError::RuntimeError(format!("KV lock error: {}", e)) + IntentError::runtime_error(format!("KV lock error: {}", e)) })?; kv.list(prefix.as_deref())? } KVBackend::Redis => { let kv_arc = get_redis_kv(&args[0])?; let mut kv = kv_arc.lock().map_err(|e| { - IntentError::RuntimeError(format!("KV lock error: {}", e)) + IntentError::runtime_error(format!("KV lock error: {}", e)) })?; kv.list(prefix.as_deref())? } @@ -1039,7 +1039,7 @@ pub fn create_kv_module() -> HashMap { max_arity: 3, func: |args| { if args.len() != 3 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "expire() requires 3 arguments (kv, key, seconds)".to_string(), )); } @@ -1047,7 +1047,7 @@ pub fn create_kv_module() -> HashMap { let key = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "expire() requires a string key".to_string(), )) } @@ -1056,7 +1056,7 @@ pub fn create_kv_module() -> HashMap { let seconds = match &args[2] { Value::Int(i) => *i, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "expire() requires an integer for seconds".to_string(), )) } @@ -1067,14 +1067,14 @@ pub fn create_kv_module() -> HashMap { KVBackend::SQLite => { let kv_arc = get_sqlite_kv(&args[0])?; let kv = kv_arc.lock().map_err(|e| { - IntentError::RuntimeError(format!("KV lock error: {}", e)) + IntentError::runtime_error(format!("KV lock error: {}", e)) })?; kv.expire(&key, seconds)? } KVBackend::Redis => { let kv_arc = get_redis_kv(&args[0])?; let mut kv = kv_arc.lock().map_err(|e| { - IntentError::RuntimeError(format!("KV lock error: {}", e)) + IntentError::runtime_error(format!("KV lock error: {}", e)) })?; kv.expire(&key, seconds)? } @@ -1103,7 +1103,7 @@ pub fn create_kv_module() -> HashMap { max_arity: 2, func: |args| { if args.len() != 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "ttl() requires 2 arguments (kv, key)".to_string(), )); } @@ -1111,7 +1111,7 @@ pub fn create_kv_module() -> HashMap { let key = match &args[1] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "ttl() requires a string key".to_string(), )) } @@ -1122,14 +1122,14 @@ pub fn create_kv_module() -> HashMap { KVBackend::SQLite => { let kv_arc = get_sqlite_kv(&args[0])?; let kv = kv_arc.lock().map_err(|e| { - IntentError::RuntimeError(format!("KV lock error: {}", e)) + IntentError::runtime_error(format!("KV lock error: {}", e)) })?; kv.ttl(&key)? } KVBackend::Redis => { let kv_arc = get_redis_kv(&args[0])?; let mut kv = kv_arc.lock().map_err(|e| { - IntentError::RuntimeError(format!("KV lock error: {}", e)) + IntentError::runtime_error(format!("KV lock error: {}", e)) })?; kv.ttl(&key)? } @@ -1162,7 +1162,7 @@ pub fn create_kv_module() -> HashMap { max_arity: 1, func: |args| { if args.len() != 1 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "flush() requires 1 argument (kv)".to_string(), )); } @@ -1172,14 +1172,14 @@ pub fn create_kv_module() -> HashMap { KVBackend::SQLite => { let kv_arc = get_sqlite_kv(&args[0])?; let kv = kv_arc.lock().map_err(|e| { - IntentError::RuntimeError(format!("KV lock error: {}", e)) + IntentError::runtime_error(format!("KV lock error: {}", e)) })?; kv.flush()?; } KVBackend::Redis => { let kv_arc = get_redis_kv(&args[0])?; let mut kv = kv_arc.lock().map_err(|e| { - IntentError::RuntimeError(format!("KV lock error: {}", e)) + IntentError::runtime_error(format!("KV lock error: {}", e)) })?; kv.flush()?; } diff --git a/src/stdlib/log.rs b/src/stdlib/log.rs index 14e68ee..bbd6043 100644 --- a/src/stdlib/log.rs +++ b/src/stdlib/log.rs @@ -124,7 +124,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| { if args.is_empty() || args.len() > 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "log_debug() requires 1 or 2 arguments (message, optional data)" .to_string(), )); @@ -133,7 +133,7 @@ pub fn init() -> HashMap { let message = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "log_debug() message must be a string".to_string(), )) } @@ -175,7 +175,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| { if args.is_empty() || args.len() > 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "log_info() requires 1 or 2 arguments (message, optional data)".to_string(), )); } @@ -183,7 +183,7 @@ pub fn init() -> HashMap { let message = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "log_info() message must be a string".to_string(), )) } @@ -225,7 +225,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| { if args.is_empty() || args.len() > 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "log_warn() requires 1 or 2 arguments (message, optional data)".to_string(), )); } @@ -233,7 +233,7 @@ pub fn init() -> HashMap { let message = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "log_warn() message must be a string".to_string(), )) } @@ -275,7 +275,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| { if args.is_empty() || args.len() > 2 { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "log_error() requires 1 or 2 arguments (message, optional data)" .to_string(), )); @@ -284,7 +284,7 @@ pub fn init() -> HashMap { let message = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "log_error() message must be a string".to_string(), )) } @@ -328,7 +328,7 @@ pub fn init() -> HashMap { let level_str = match &args[0] { Value::String(s) => s.clone(), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "set_log_level() requires a string".to_string(), )) } @@ -339,7 +339,7 @@ pub fn init() -> HashMap { LOG_LEVEL.store(level, Ordering::Relaxed); Ok(Value::Unit) } - None => Err(IntentError::TypeError(format!( + None => Err(IntentError::type_error(format!( "Invalid log level '{}'. Use 'debug', 'info', 'warn', or 'error'", level_str ))), diff --git a/src/stdlib/markdown.rs b/src/stdlib/markdown.rs index 78c78de..2e4a4dd 100644 --- a/src/stdlib/markdown.rs +++ b/src/stdlib/markdown.rs @@ -33,7 +33,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(md) => Ok(markdown_to_html(md, false)), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "to_html() requires a String argument".to_string(), )), }, @@ -53,7 +53,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(md) => Ok(markdown_to_html(md, true)), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "to_html_safe() requires a String argument".to_string(), )), }, diff --git a/src/stdlib/math.rs b/src/stdlib/math.rs index 1b5df04..a8a924e 100644 --- a/src/stdlib/math.rs +++ b/src/stdlib/math.rs @@ -56,7 +56,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "sin() requires a number".to_string(), )) } @@ -91,7 +91,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "cos() requires a number".to_string(), )) } @@ -125,7 +125,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "tan() requires a number".to_string(), )) } @@ -161,7 +161,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "asin() requires a number".to_string(), )) } @@ -197,7 +197,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "acos() requires a number".to_string(), )) } @@ -232,7 +232,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "atan() requires a number".to_string(), )) } @@ -270,7 +270,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "atan2() requires numbers".to_string(), )) } @@ -279,7 +279,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "atan2() requires numbers".to_string(), )) } @@ -314,7 +314,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "sinh() requires a number".to_string(), )) } @@ -349,7 +349,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "cosh() requires a number".to_string(), )) } @@ -384,7 +384,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "tanh() requires a number".to_string(), )) } @@ -421,13 +421,13 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "log() requires a number".to_string(), )) } }; if x <= 0.0 { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "log() requires positive number".to_string(), )); } @@ -463,13 +463,13 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "log10() requires a number".to_string(), )) } }; if x <= 0.0 { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "log10() requires positive number".to_string(), )); } @@ -505,13 +505,13 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "log2() requires a number".to_string(), )) } }; if x <= 0.0 { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "log2() requires positive number".to_string(), )); } @@ -545,7 +545,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "exp() requires a number".to_string(), )) } @@ -581,7 +581,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "exp2() requires a number".to_string(), )) } @@ -617,7 +617,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "cbrt() requires a number".to_string(), )) } @@ -654,7 +654,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "hypot() requires numbers".to_string(), )) } @@ -663,7 +663,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "hypot() requires numbers".to_string(), )) } @@ -698,7 +698,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "degrees() requires a number".to_string(), )) } @@ -733,7 +733,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "radians() requires a number".to_string(), )) } @@ -794,7 +794,7 @@ pub fn init() -> HashMap { let min = match &args[0] { Value::Int(i) => *i, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "random_int() requires integers".to_string(), )) } @@ -802,13 +802,13 @@ pub fn init() -> HashMap { let max = match &args[1] { Value::Int(i) => *i, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "random_int() requires integers".to_string(), )) } }; if min > max { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "random_int() min must be <= max".to_string(), )); } @@ -846,7 +846,7 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "random_range() requires numbers".to_string(), )) } @@ -855,13 +855,13 @@ pub fn init() -> HashMap { Value::Float(f) => *f, Value::Int(i) => *i as f64, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "random_range() requires numbers".to_string(), )) } }; if min > max { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "random_range() min must be <= max".to_string(), )); } @@ -897,7 +897,7 @@ pub fn init() -> HashMap { Value::Float(f) => f.is_nan(), Value::Int(_) => false, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "is_nan() requires a number".to_string(), )) } @@ -933,7 +933,7 @@ pub fn init() -> HashMap { Value::Float(f) => f.is_infinite(), Value::Int(_) => false, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "is_infinite() requires a number".to_string(), )) } @@ -969,7 +969,7 @@ pub fn init() -> HashMap { Value::Float(f) => f.is_finite(), Value::Int(_) => true, _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "is_finite() requires a number".to_string(), )) } diff --git a/src/stdlib/path.rs b/src/stdlib/path.rs index b718b16..d2fee6a 100644 --- a/src/stdlib/path.rs +++ b/src/stdlib/path.rs @@ -34,7 +34,7 @@ pub fn init() -> HashMap { match part { Value::String(s) => path.push(s), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "join() requires array of strings".to_string(), )) } @@ -42,7 +42,7 @@ pub fn init() -> HashMap { } Ok(Value::String(path.to_string_lossy().to_string())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "join() requires an array of path parts".to_string(), )), }, @@ -75,7 +75,7 @@ pub fn init() -> HashMap { match part { Value::String(s) => path.push(s), _ => { - return Err(IntentError::TypeError( + return Err(IntentError::type_error( "join() requires array of strings".to_string(), )) } @@ -83,7 +83,7 @@ pub fn init() -> HashMap { } Ok(Value::String(path.to_string_lossy().to_string())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "join() requires an array of path parts".to_string(), )), } @@ -111,7 +111,7 @@ pub fn init() -> HashMap { Some(p) => Ok(Value::some(Value::String(p.to_string_lossy().to_string()))), None => Ok(Value::none()), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "dirname() requires a string path".to_string(), )), }, @@ -140,7 +140,7 @@ pub fn init() -> HashMap { ))), None => Ok(Value::none()), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "basename() requires a string path".to_string(), )), }, @@ -169,7 +169,7 @@ pub fn init() -> HashMap { ))), None => Ok(Value::none()), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "extension() requires a string path".to_string(), )), }, @@ -198,7 +198,7 @@ pub fn init() -> HashMap { ))), None => Ok(Value::none()), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "stem() requires a string path".to_string(), )), }, @@ -226,7 +226,7 @@ pub fn init() -> HashMap { Ok(abs) => Ok(Value::ok(Value::String(abs.to_string_lossy().to_string()))), Err(e) => Ok(Value::err(Value::String(e.to_string()))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "resolve() requires a string path".to_string(), )), }, @@ -251,7 +251,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(path) => Ok(Value::Bool(Path::new(path).is_absolute())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_absolute() requires a string path".to_string(), )), }, @@ -276,7 +276,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(path) => Ok(Value::Bool(Path::new(path).is_relative())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_relative() requires a string path".to_string(), )), }, @@ -304,7 +304,7 @@ pub fn init() -> HashMap { let new_path = Path::new(path).with_extension(ext); Ok(Value::String(new_path.to_string_lossy().to_string())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "with_extension() requires two strings".to_string(), )), }, @@ -344,7 +344,7 @@ pub fn init() -> HashMap { } Ok(Value::String(normalized.to_string_lossy().to_string())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "normalize() requires a string path".to_string(), )), }, diff --git a/src/stdlib/postgres.rs b/src/stdlib/postgres.rs index 89e2649..285c026 100644 --- a/src/stdlib/postgres.rs +++ b/src/stdlib/postgres.rs @@ -392,16 +392,16 @@ fn get_client(conn: &Value) -> Result>> { return Ok(Arc::clone(client)); } } - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "Invalid or closed database connection".to_string(), )) } else { - Err(IntentError::TypeError( + Err(IntentError::type_error( "Expected a database connection handle".to_string(), )) } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "Expected a database connection handle".to_string(), )), } @@ -437,7 +437,7 @@ fn pg_query(conn: &Value, sql: &str, params: &[Value]) -> Result { let client_arc = get_client(conn)?; let mut client = client_arc .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock connection: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock connection: {}", e)))?; // Convert params to SqlParam let sql_params: Vec = params.iter().map(value_to_sql_param).collect(); @@ -465,7 +465,7 @@ fn pg_query_one(conn: &Value, sql: &str, params: &[Value]) -> Result { let client_arc = get_client(conn)?; let mut client = client_arc .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock connection: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock connection: {}", e)))?; let sql_params: Vec = params.iter().map(value_to_sql_param).collect(); let param_refs: Vec<&(dyn ToSql + Sync)> = sql_params @@ -488,7 +488,7 @@ fn pg_execute(conn: &Value, sql: &str, params: &[Value]) -> Result { let client_arc = get_client(conn)?; let mut client = client_arc .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock connection: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock connection: {}", e)))?; let sql_params: Vec = params.iter().map(value_to_sql_param).collect(); let param_refs: Vec<&(dyn ToSql + Sync)> = sql_params @@ -517,7 +517,7 @@ fn pg_close(conn: &Value) -> Result { } Ok(Value::Bool(false)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "Expected a database connection handle".to_string(), )), } @@ -528,7 +528,7 @@ fn pg_begin(conn: &Value) -> Result { let client_arc = get_client(conn)?; let mut client = client_arc .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock connection: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock connection: {}", e)))?; match client.execute("BEGIN", &[]) { Ok(_) => { @@ -544,7 +544,7 @@ fn pg_commit(conn: &Value) -> Result { let client_arc = get_client(conn)?; let mut client = client_arc .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock connection: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock connection: {}", e)))?; match client.execute("COMMIT", &[]) { Ok(_) => Ok(Value::Bool(true)), @@ -557,7 +557,7 @@ fn pg_rollback(conn: &Value) -> Result { let client_arc = get_client(conn)?; let mut client = client_arc .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock connection: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock connection: {}", e)))?; match client.execute("ROLLBACK", &[]) { Ok(_) => Ok(Value::Bool(true)), @@ -594,7 +594,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(conn_str) => pg_connect(conn_str), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "connect() requires a connection string".to_string(), )), }, @@ -629,7 +629,7 @@ pub fn init() -> HashMap { func: |args| match (&args[0], &args[1], &args[2]) { (conn, Value::String(sql), Value::Array(params)) => pg_query(conn, sql, params), (conn, Value::String(sql), Value::Unit) => pg_query(conn, sql, &[]), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "query() requires (connection, sql_string, params_array)".to_string(), )), }, @@ -664,7 +664,7 @@ pub fn init() -> HashMap { func: |args| match (&args[0], &args[1], &args[2]) { (conn, Value::String(sql), Value::Array(params)) => pg_query_one(conn, sql, params), (conn, Value::String(sql), Value::Unit) => pg_query_one(conn, sql, &[]), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "query_one() requires (connection, sql_string, params_array)".to_string(), )), }, @@ -697,7 +697,7 @@ pub fn init() -> HashMap { func: |args| match (&args[0], &args[1], &args[2]) { (conn, Value::String(sql), Value::Array(params)) => pg_execute(conn, sql, params), (conn, Value::String(sql), Value::Unit) => pg_execute(conn, sql, &[]), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "execute() requires (connection, sql_string, params_array)".to_string(), )), }, diff --git a/src/stdlib/sqlite.rs b/src/stdlib/sqlite.rs index 5322b40..da066c8 100644 --- a/src/stdlib/sqlite.rs +++ b/src/stdlib/sqlite.rs @@ -99,16 +99,16 @@ fn get_connection(conn: &Value) -> Result>> { return Ok(Arc::clone(client)); } } - Err(IntentError::RuntimeError( + Err(IntentError::runtime_error( "Invalid or closed SQLite connection".to_string(), )) } else { - Err(IntentError::TypeError( + Err(IntentError::type_error( "Expected a SQLite connection handle".to_string(), )) } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "Expected a SQLite connection handle".to_string(), )), } @@ -119,13 +119,13 @@ fn sqlite_query(conn: &Value, sql: &str, params: &[Value]) -> Result { let conn_arc = get_connection(conn)?; let conn_guard = conn_arc .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock connection: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock connection: {}", e)))?; let sqlite_params: Vec = params.iter().map(value_to_sqlite).collect(); let mut stmt = conn_guard .prepare(sql) - .map_err(|e| IntentError::RuntimeError(format!("Query preparation failed: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Query preparation failed: {}", e)))?; let column_count = stmt.column_count(); let column_names: Vec = stmt.column_names().iter().map(|s| s.to_string()).collect(); @@ -160,13 +160,13 @@ fn sqlite_query_one(conn: &Value, sql: &str, params: &[Value]) -> Result let conn_arc = get_connection(conn)?; let conn_guard = conn_arc .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock connection: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock connection: {}", e)))?; let sqlite_params: Vec = params.iter().map(value_to_sqlite).collect(); let mut stmt = conn_guard .prepare(sql) - .map_err(|e| IntentError::RuntimeError(format!("Query preparation failed: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Query preparation failed: {}", e)))?; let column_count = stmt.column_count(); let column_names: Vec = stmt.column_names().iter().map(|s| s.to_string()).collect(); @@ -191,7 +191,7 @@ fn sqlite_execute(conn: &Value, sql: &str, params: &[Value]) -> Result { let conn_arc = get_connection(conn)?; let conn_guard = conn_arc .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock connection: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock connection: {}", e)))?; let sqlite_params: Vec = params.iter().map(value_to_sqlite).collect(); @@ -213,7 +213,7 @@ fn sqlite_close(conn: &Value) -> Result { } Ok(Value::Bool(false)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "Expected a SQLite connection handle".to_string(), )), } @@ -224,7 +224,7 @@ fn sqlite_begin(conn: &Value) -> Result { let conn_arc = get_connection(conn)?; let conn_guard = conn_arc .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock connection: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock connection: {}", e)))?; match conn_guard.execute_batch("BEGIN") { Ok(_) => Ok(Value::ok(conn.clone())), @@ -237,7 +237,7 @@ fn sqlite_commit(conn: &Value) -> Result { let conn_arc = get_connection(conn)?; let conn_guard = conn_arc .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock connection: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock connection: {}", e)))?; match conn_guard.execute_batch("COMMIT") { Ok(_) => Ok(Value::Bool(true)), @@ -250,7 +250,7 @@ fn sqlite_rollback(conn: &Value) -> Result { let conn_arc = get_connection(conn)?; let conn_guard = conn_arc .lock() - .map_err(|e| IntentError::RuntimeError(format!("Failed to lock connection: {}", e)))?; + .map_err(|e| IntentError::runtime_error(format!("Failed to lock connection: {}", e)))?; match conn_guard.execute_batch("ROLLBACK") { Ok(_) => Ok(Value::Bool(true)), @@ -288,7 +288,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(path) => sqlite_connect(path), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "connect() requires a database path string".to_string(), )), }, @@ -326,7 +326,7 @@ pub fn init() -> HashMap { func: |args| match (&args[0], &args[1], &args[2]) { (conn, Value::String(sql), Value::Array(params)) => sqlite_query(conn, sql, params), (conn, Value::String(sql), Value::Unit) => sqlite_query(conn, sql, &[]), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "query() requires (connection, sql_string, params_array)".to_string(), )), }, @@ -365,7 +365,7 @@ pub fn init() -> HashMap { sqlite_query_one(conn, sql, params) } (conn, Value::String(sql), Value::Unit) => sqlite_query_one(conn, sql, &[]), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "query_one() requires (connection, sql_string, params_array)".to_string(), )), }, @@ -404,7 +404,7 @@ pub fn init() -> HashMap { sqlite_execute(conn, sql, params) } (conn, Value::String(sql), Value::Unit) => sqlite_execute(conn, sql, &[]), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "execute() requires (connection, sql_string, params_array)".to_string(), )), }, diff --git a/src/stdlib/string.rs b/src/stdlib/string.rs index 413a0e6..6243acb 100644 --- a/src/stdlib/string.rs +++ b/src/stdlib/string.rs @@ -43,7 +43,7 @@ pub fn init() -> HashMap { .collect(); Ok(Value::Array(parts)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "split() requires two strings".to_string(), )), }, @@ -79,7 +79,7 @@ pub fn init() -> HashMap { .collect(); Ok(Value::String(parts.join(delim))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "join() requires array and string".to_string(), )), }, @@ -118,11 +118,11 @@ pub fn init() -> HashMap { } Value::String(s1) => match &args[1] { Value::String(s2) => Ok(Value::String(format!("{}{}", s1, s2))), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "concat() requires strings".to_string(), )), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "concat() requires strings or array of strings".to_string(), )), }, @@ -149,13 +149,13 @@ pub fn init() -> HashMap { func: |args| match (&args[0], &args[1]) { (Value::String(s), Value::Int(n)) => { if *n < 0 { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "repeat count must be non-negative".to_string(), )); } Ok(Value::String(s.repeat(*n as usize))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "repeat() requires string and int".to_string(), )), }, @@ -178,7 +178,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(s) => Ok(Value::String(s.chars().rev().collect())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "reverse() requires a string".to_string(), )), }, @@ -204,7 +204,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(s) => Ok(Value::String(s.trim().to_string())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "trim() requires a string".to_string(), )), }, @@ -230,7 +230,7 @@ pub fn init() -> HashMap { eprintln!("[DEPRECATED] trim_left() is deprecated. Use trim_start() instead."); match &args[0] { Value::String(s) => Ok(Value::String(s.trim_start().to_string())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "trim_left() requires a string".to_string(), )), } @@ -257,7 +257,7 @@ pub fn init() -> HashMap { eprintln!("[DEPRECATED] trim_right() is deprecated. Use trim_end() instead."); match &args[0] { Value::String(s) => Ok(Value::String(s.trim_end().to_string())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "trim_right() requires a string".to_string(), )), } @@ -288,7 +288,7 @@ pub fn init() -> HashMap { s.trim_matches(|c| char_set.contains(&c)).to_string(), )) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "trim_chars() requires two strings".to_string(), )), }, @@ -316,7 +316,7 @@ pub fn init() -> HashMap { eprintln!("[DEPRECATED] to_upper() is deprecated. Use upper() instead."); match &args[0] { Value::String(s) => Ok(Value::String(s.to_uppercase())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "to_upper() requires a string".to_string(), )), } @@ -343,7 +343,7 @@ pub fn init() -> HashMap { eprintln!("[DEPRECATED] to_lower() is deprecated. Use lower() instead."); match &args[0] { Value::String(s) => Ok(Value::String(s.to_lowercase())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "to_lower() requires a string".to_string(), )), } @@ -380,7 +380,7 @@ pub fn init() -> HashMap { } } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "capitalize() requires a string".to_string(), )), }, @@ -420,7 +420,7 @@ pub fn init() -> HashMap { .join(" "); Ok(Value::String(result)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "title() requires a string".to_string(), )), }, @@ -459,7 +459,7 @@ pub fn init() -> HashMap { } Ok(Value::String(result)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "to_snake_case() requires a string".to_string(), )), }, @@ -497,7 +497,7 @@ pub fn init() -> HashMap { } Ok(Value::String(result)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "to_camel_case() requires a string".to_string(), )), }, @@ -535,7 +535,7 @@ pub fn init() -> HashMap { } Ok(Value::String(result)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "to_pascal_case() requires a string".to_string(), )), }, @@ -574,7 +574,7 @@ pub fn init() -> HashMap { } Ok(Value::String(result)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "to_kebab_case() requires a string".to_string(), )), }, @@ -634,7 +634,7 @@ pub fn init() -> HashMap { .collect(); Ok(Value::String(result.trim_matches('-').to_string())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "slugify() requires a string".to_string(), )), } @@ -664,7 +664,7 @@ pub fn init() -> HashMap { (Value::String(s), Value::String(substr)) => { Ok(Value::Bool(s.contains(substr.as_str()))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "contains() requires two strings".to_string(), )), }, @@ -691,7 +691,7 @@ pub fn init() -> HashMap { (Value::String(s), Value::String(prefix)) => { Ok(Value::Bool(s.starts_with(prefix.as_str()))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "starts_with() requires two strings".to_string(), )), }, @@ -718,7 +718,7 @@ pub fn init() -> HashMap { (Value::String(s), Value::String(suffix)) => { Ok(Value::Bool(s.ends_with(suffix.as_str()))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "ends_with() requires two strings".to_string(), )), }, @@ -746,7 +746,7 @@ pub fn init() -> HashMap { Some(idx) => Ok(Value::Int(idx as i64)), None => Ok(Value::Int(-1)), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "index_of() requires two strings".to_string(), )), }, @@ -774,7 +774,7 @@ pub fn init() -> HashMap { Some(idx) => Ok(Value::Int(idx as i64)), None => Ok(Value::Int(-1)), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "last_index_of() requires two strings".to_string(), )), }, @@ -801,7 +801,7 @@ pub fn init() -> HashMap { (Value::String(s), Value::String(substr)) => { Ok(Value::Int(s.matches(substr.as_str()).count() as i64)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "count() requires two strings".to_string(), )), }, @@ -829,7 +829,7 @@ pub fn init() -> HashMap { (Value::String(s), Value::String(from), Value::String(to)) => { Ok(Value::String(s.replace(from.as_str(), to.as_str()))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "replace() requires three strings".to_string(), )), }, @@ -857,7 +857,7 @@ pub fn init() -> HashMap { (Value::String(s), Value::String(from), Value::String(to)) => { Ok(Value::String(s.replacen(from.as_str(), to.as_str(), 1))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "replace_first() requires three strings".to_string(), )), }, @@ -896,7 +896,7 @@ pub fn init() -> HashMap { .collect(); Ok(Value::String(result)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "replace_chars() requires three strings".to_string(), )), }, @@ -925,7 +925,7 @@ pub fn init() -> HashMap { let result: String = s.chars().filter(|c| !char_set.contains(c)).collect(); Ok(Value::String(result)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "remove_chars() requires two strings".to_string(), )), }, @@ -954,7 +954,7 @@ pub fn init() -> HashMap { let result: String = s.chars().filter(|c| char_set.contains(c)).collect(); Ok(Value::String(result)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "keep_chars() requires two strings".to_string(), )), }, @@ -987,13 +987,13 @@ pub fn init() -> HashMap { Ok(re) => Ok(Value::String( re.replace_all(s, replacement.as_str()).to_string(), )), - Err(e) => Err(IntentError::RuntimeError(format!( + Err(e) => Err(IntentError::runtime_error(format!( "Invalid regex pattern: {}", e ))), } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "replace_pattern() requires three strings".to_string(), )), }, @@ -1020,12 +1020,12 @@ pub fn init() -> HashMap { func: |args| match (&args[0], &args[1]) { (Value::String(s), Value::String(pattern)) => match regex::Regex::new(pattern) { Ok(re) => Ok(Value::Bool(re.is_match(s))), - Err(e) => Err(IntentError::RuntimeError(format!( + Err(e) => Err(IntentError::runtime_error(format!( "Invalid regex pattern: {}", e ))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "matches_pattern() requires two strings".to_string(), )), }, @@ -1055,12 +1055,12 @@ pub fn init() -> HashMap { Some(m) => Ok(Value::some(Value::String(m.as_str().to_string()))), None => Ok(Value::none()), }, - Err(e) => Err(IntentError::RuntimeError(format!( + Err(e) => Err(IntentError::runtime_error(format!( "Invalid regex pattern: {}", e ))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "find_pattern() requires two strings".to_string(), )), }, @@ -1093,12 +1093,12 @@ pub fn init() -> HashMap { .collect(); Ok(Value::Array(matches)) } - Err(e) => Err(IntentError::RuntimeError(format!( + Err(e) => Err(IntentError::runtime_error(format!( "Invalid regex pattern: {}", e ))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "find_all_pattern() requires two strings".to_string(), )), }, @@ -1129,12 +1129,12 @@ pub fn init() -> HashMap { re.split(s).map(|p| Value::String(p.to_string())).collect(); Ok(Value::Array(parts)) } - Err(e) => Err(IntentError::RuntimeError(format!( + Err(e) => Err(IntentError::runtime_error(format!( "Invalid regex pattern: {}", e ))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "split_pattern() requires two strings".to_string(), )), }, @@ -1179,12 +1179,12 @@ pub fn init() -> HashMap { } None => Ok(Value::none()), }, - Err(e) => Err(IntentError::RuntimeError(format!( + Err(e) => Err(IntentError::runtime_error(format!( "Invalid regex pattern: {}", e ))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "capture_pattern() requires two strings".to_string(), )), }, @@ -1232,12 +1232,12 @@ pub fn init() -> HashMap { .collect(); Ok(Value::Array(all_matches)) } - Err(e) => Err(IntentError::RuntimeError(format!( + Err(e) => Err(IntentError::runtime_error(format!( "Invalid regex pattern: {}", e ))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "capture_all_pattern() requires two strings".to_string(), )), }, @@ -1287,12 +1287,12 @@ pub fn init() -> HashMap { } None => Ok(Value::none()), }, - Err(e) => Err(IntentError::RuntimeError(format!( + Err(e) => Err(IntentError::runtime_error(format!( "Invalid regex pattern: {}", e ))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "capture_named_pattern() requires two strings".to_string(), )), }, @@ -1325,10 +1325,10 @@ pub fn init() -> HashMap { .nth(idx) .map(|c| Value::String(c.to_string())) .ok_or_else(|| { - IntentError::RuntimeError(format!("Index {} out of bounds", idx)) + IntentError::runtime_error(format!("Index {} out of bounds", idx)) }) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "char_at() requires string and int".to_string(), )), }, @@ -1359,13 +1359,13 @@ pub fn init() -> HashMap { let end = *end as usize; let chars: Vec = s.chars().collect(); if start > chars.len() || end > chars.len() || start > end { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "Invalid substring range".to_string(), )); } Ok(Value::String(chars[start..end].iter().collect())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "substring() requires string, int, int".to_string(), )), }, @@ -1393,7 +1393,7 @@ pub fn init() -> HashMap { s.chars().map(|c| Value::String(c.to_string())).collect(); Ok(Value::Array(chars)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "chars() requires a string".to_string(), )), }, @@ -1421,7 +1421,7 @@ pub fn init() -> HashMap { s.lines().map(|l| Value::String(l.to_string())).collect(); Ok(Value::Array(lines)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "lines() requires a string".to_string(), )), }, @@ -1451,7 +1451,7 @@ pub fn init() -> HashMap { .collect(); Ok(Value::Array(words)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "words() requires a string".to_string(), )), }, @@ -1489,7 +1489,7 @@ pub fn init() -> HashMap { Ok(Value::String(format!("{}{}", truncated, suffix))) } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "truncate() requires string, int, string".to_string(), )), }, @@ -1527,7 +1527,7 @@ pub fn init() -> HashMap { Ok(Value::String(format!("{}{}", padding, s))) } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "pad_left() requires string, int, string".to_string(), )), }, @@ -1563,7 +1563,7 @@ pub fn init() -> HashMap { Ok(Value::String(format!("{}{}", s, padding))) } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "pad_right() requires string, int, string".to_string(), )), }, @@ -1602,7 +1602,7 @@ pub fn init() -> HashMap { Ok(Value::String(format!("{}{}{}", left, s, right))) } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "center() requires string, int, string".to_string(), )), }, @@ -1628,7 +1628,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(s) => Ok(Value::Bool(s.is_empty())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_empty() requires a string".to_string(), )), }, @@ -1652,7 +1652,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(s) => Ok(Value::Bool(s.trim().is_empty())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_blank() requires a string".to_string(), )), }, @@ -1678,7 +1678,7 @@ pub fn init() -> HashMap { Value::String(s) => Ok(Value::Bool( !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()), )), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_numeric() requires a string".to_string(), )), }, @@ -1704,7 +1704,7 @@ pub fn init() -> HashMap { Value::String(s) => Ok(Value::Bool( !s.is_empty() && s.chars().all(|c| c.is_alphabetic()), )), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_alpha() requires a string".to_string(), )), }, @@ -1730,7 +1730,7 @@ pub fn init() -> HashMap { Value::String(s) => Ok(Value::Bool( !s.is_empty() && s.chars().all(|c| c.is_alphanumeric()), )), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_alphanumeric() requires a string".to_string(), )), }, @@ -1756,7 +1756,7 @@ pub fn init() -> HashMap { Value::String(s) => Ok(Value::Bool( !s.is_empty() && s.chars().all(|c| !c.is_alphabetic() || c.is_lowercase()), )), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_lowercase() requires a string".to_string(), )), }, @@ -1782,7 +1782,7 @@ pub fn init() -> HashMap { Value::String(s) => Ok(Value::Bool( !s.is_empty() && s.chars().all(|c| !c.is_alphabetic() || c.is_uppercase()), )), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_uppercase() requires a string".to_string(), )), }, @@ -1808,7 +1808,7 @@ pub fn init() -> HashMap { Value::String(s) => Ok(Value::Bool( !s.is_empty() && s.chars().all(|c| c.is_whitespace()), )), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_whitespace() requires a string".to_string(), )), }, @@ -1838,7 +1838,7 @@ pub fn init() -> HashMap { (Value::String(s), Value::String(pattern)) => { Ok(Value::Bool(simple_glob_match(s, pattern))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "matches() requires two strings".to_string(), )), }, @@ -1868,7 +1868,7 @@ pub fn init() -> HashMap { (Value::String(s), Value::String(from), Value::String(to)) => { Ok(Value::String(s.replace(from.as_str(), to.as_str()))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "replace_all() requires three strings".to_string(), )), }, @@ -1892,7 +1892,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(s) => Ok(Value::String(s.to_uppercase())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "upper() requires a string".to_string(), )), }, @@ -1916,7 +1916,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(s) => Ok(Value::String(s.to_lowercase())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "lower() requires a string".to_string(), )), }, @@ -1940,7 +1940,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(s) => Ok(Value::String(s.trim_start().to_string())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "trim_start() requires a string".to_string(), )), }, @@ -1964,7 +1964,7 @@ pub fn init() -> HashMap { max_arity: 1, func: |args| match &args[0] { Value::String(s) => Ok(Value::String(s.trim_end().to_string())), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "trim_end() requires a string".to_string(), )), }, @@ -2002,7 +2002,7 @@ pub fn init() -> HashMap { .replace('\'', "'"); Ok(Value::String(escaped)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "html_escape() requires a string argument".to_string(), )), }, diff --git a/src/stdlib/template.rs b/src/stdlib/template.rs index ec47719..ea90891 100644 --- a/src/stdlib/template.rs +++ b/src/stdlib/template.rs @@ -54,7 +54,7 @@ pub fn load_template_file(path: &str, base_path: Option<&str>) -> Result }; fs::read_to_string(&full_path).map_err(|e| { - IntentError::RuntimeError(format!( + IntentError::runtime_error(format!( "Failed to load template '{}': {}", full_path.display(), e diff --git a/src/stdlib/time.rs b/src/stdlib/time.rs index 8abf7fd..d426ae5 100644 --- a/src/stdlib/time.rs +++ b/src/stdlib/time.rs @@ -37,7 +37,7 @@ where /// Parse timezone string to chrono_tz::Tz fn parse_timezone(tz: &str) -> Result { tz.parse::().map_err(|_| { - IntentError::RuntimeError(format!( + IntentError::runtime_error(format!( "Invalid timezone: '{}'. Use IANA format like 'America/New_York'", tz )) @@ -116,7 +116,7 @@ pub fn init() -> HashMap { max_arity: 0, func: |_args| match Utc::now().timestamp_nanos_opt() { Some(nanos) => Ok(Value::Int(nanos)), - None => Err(IntentError::RuntimeError( + None => Err(IntentError::runtime_error( "Timestamp out of range for nanoseconds".to_string(), )), }, @@ -152,11 +152,11 @@ pub fn init() -> HashMap { (Value::Int(ts), Value::String(tz_str)) => { let tz = parse_timezone(tz_str)?; let dt = DateTime::from_timestamp(*ts, 0) - .ok_or_else(|| IntentError::RuntimeError("Invalid timestamp".to_string()))? + .ok_or_else(|| IntentError::runtime_error("Invalid timestamp".to_string()))? .with_timezone(&tz); Ok(Value::Map(datetime_to_map(&dt, tz_str))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "to_timezone() requires (timestamp: Int, timezone: String)".to_string(), )), }, @@ -187,11 +187,11 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Int(ts) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; Ok(Value::Map(datetime_to_map(&dt, "UTC"))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "to_utc() requires a timestamp".to_string(), )), }, @@ -225,11 +225,11 @@ pub fn init() -> HashMap { func: |args| match (&args[0], &args[1]) { (Value::Int(ts), Value::String(fmt)) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; Ok(Value::String(dt.format(fmt).to_string())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "format() requires (timestamp: Int, format: String)".to_string(), )), }, @@ -264,11 +264,11 @@ pub fn init() -> HashMap { (Value::Int(ts), Value::String(tz_str), Value::String(fmt)) => { let tz = parse_timezone(tz_str)?; let dt = DateTime::from_timestamp(*ts, 0) - .ok_or_else(|| IntentError::RuntimeError("Invalid timestamp".to_string()))? + .ok_or_else(|| IntentError::runtime_error("Invalid timestamp".to_string()))? .with_timezone(&tz); Ok(Value::String(dt.format(fmt).to_string())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "format_in() requires (timestamp: Int, timezone: String, format: String)" .to_string(), )), @@ -300,11 +300,11 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Int(ts) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; Ok(Value::String(dt.to_rfc3339())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "to_iso() requires a timestamp".to_string(), )), }, @@ -345,7 +345,7 @@ pub fn init() -> HashMap { Err(e) => Ok(Value::err(Value::String(format!("Parse error: {}", e)))), } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "parse_datetime() requires (date_str: String, format: String)".to_string(), )), }, @@ -377,7 +377,7 @@ pub fn init() -> HashMap { Ok(dt) => Ok(Value::ok(Value::Int(dt.timestamp()))), Err(e) => Ok(Value::err(Value::String(format!("Parse error: {}", e)))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "parse_iso() requires a string".to_string(), )), }, @@ -436,7 +436,7 @@ pub fn init() -> HashMap { ))), } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "make_time() requires 6 integers: (year, month, day, hour, minute, second)" .to_string(), )), @@ -477,7 +477,7 @@ pub fn init() -> HashMap { ))), } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "make_date() requires 3 integers: (year, month, day)".to_string(), )), }, @@ -508,7 +508,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| match (&args[0], &args[1]) { (Value::Int(ts), Value::Int(secs)) => Ok(Value::Int(ts + secs)), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "add_seconds() requires (timestamp: Int, seconds: Int)".to_string(), )), }, @@ -537,7 +537,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| match (&args[0], &args[1]) { (Value::Int(ts), Value::Int(mins)) => Ok(Value::Int(ts + mins * 60)), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "add_minutes() requires (timestamp: Int, minutes: Int)".to_string(), )), }, @@ -566,7 +566,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| match (&args[0], &args[1]) { (Value::Int(ts), Value::Int(hours)) => Ok(Value::Int(ts + hours * 3600)), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "add_hours() requires (timestamp: Int, hours: Int)".to_string(), )), }, @@ -597,7 +597,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| match (&args[0], &args[1]) { (Value::Int(ts), Value::Int(days)) => Ok(Value::Int(ts + days * 86400)), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "add_days() requires (timestamp: Int, days: Int)".to_string(), )), }, @@ -627,7 +627,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| match (&args[0], &args[1]) { (Value::Int(ts), Value::Int(weeks)) => Ok(Value::Int(ts + weeks * 604800)), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "add_weeks() requires (timestamp: Int, weeks: Int)".to_string(), )), }, @@ -662,7 +662,7 @@ pub fn init() -> HashMap { match (&args[0], &args[1]) { (Value::Int(ts), Value::Int(months)) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; // Proper calendar-aware month addition @@ -702,12 +702,12 @@ pub fn init() -> HashMap { chrono::LocalResult::Single(new_dt) => { Ok(Value::Int(new_dt.timestamp())) } - _ => Err(IntentError::RuntimeError( + _ => Err(IntentError::runtime_error( "Invalid date after month addition".to_string(), )), } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "add_months() requires (timestamp: Int, months: Int)".to_string(), )), } @@ -742,7 +742,7 @@ pub fn init() -> HashMap { match (&args[0], &args[1]) { (Value::Int(ts), Value::Int(years)) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; let new_year = dt.year() + (*years as i32); @@ -773,12 +773,12 @@ pub fn init() -> HashMap { chrono::LocalResult::Single(new_dt) => { Ok(Value::Int(new_dt.timestamp())) } - _ => Err(IntentError::RuntimeError( + _ => Err(IntentError::runtime_error( "Invalid date after year addition".to_string(), )), } } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "add_years() requires (timestamp: Int, years: Int)".to_string(), )), } @@ -819,7 +819,7 @@ pub fn init() -> HashMap { map.insert("days".to_string(), Value::Int(diff_secs / 86400)); Ok(Value::Map(map)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "diff() requires two timestamps".to_string(), )), }, @@ -851,7 +851,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| match (&args[0], &args[1]) { (Value::Int(ts1), Value::Int(ts2)) => Ok(Value::Bool(ts1 < ts2)), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "before() requires two timestamps".to_string(), )), }, @@ -881,7 +881,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| match (&args[0], &args[1]) { (Value::Int(ts1), Value::Int(ts2)) => Ok(Value::Bool(ts1 > ts2)), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "after() requires two timestamps".to_string(), )), }, @@ -911,7 +911,7 @@ pub fn init() -> HashMap { max_arity: 2, func: |args| match (&args[0], &args[1]) { (Value::Int(ts1), Value::Int(ts2)) => Ok(Value::Bool(ts1 == ts2)), - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "equal() requires two timestamps".to_string(), )), }, @@ -942,11 +942,11 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Int(ts) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; Ok(Value::Int(dt.year() as i64)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "year() requires a timestamp".to_string(), )), }, @@ -975,11 +975,11 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Int(ts) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; Ok(Value::Int(dt.month() as i64)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "month() requires a timestamp".to_string(), )), }, @@ -1008,11 +1008,11 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Int(ts) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; Ok(Value::Int(dt.day() as i64)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "day() requires a timestamp".to_string(), )), }, @@ -1041,11 +1041,11 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Int(ts) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; Ok(Value::Int(dt.hour() as i64)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "hour() requires a timestamp".to_string(), )), }, @@ -1074,11 +1074,11 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Int(ts) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; Ok(Value::Int(dt.minute() as i64)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "minute() requires a timestamp".to_string(), )), }, @@ -1107,11 +1107,11 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Int(ts) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; Ok(Value::Int(dt.second() as i64)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "second() requires a timestamp".to_string(), )), }, @@ -1141,11 +1141,11 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Int(ts) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; Ok(Value::Int(dt.weekday().num_days_from_sunday() as i64)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "weekday() requires a timestamp".to_string(), )), }, @@ -1176,7 +1176,7 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Int(ts) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; let name = match dt.weekday() { chrono::Weekday::Sun => "Sunday", @@ -1189,7 +1189,7 @@ pub fn init() -> HashMap { }; Ok(Value::String(name.to_string())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "weekday_name() requires a timestamp".to_string(), )), }, @@ -1219,7 +1219,7 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Int(ts) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; let name = match dt.month() { 1 => "January", @@ -1238,7 +1238,7 @@ pub fn init() -> HashMap { }; Ok(Value::String(name.to_string())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "month_name() requires a timestamp".to_string(), )), }, @@ -1268,11 +1268,11 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Int(ts) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; Ok(Value::Int(dt.ordinal() as i64)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "day_of_year() requires a timestamp".to_string(), )), }, @@ -1303,13 +1303,13 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Int(ts) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; let year = dt.year(); let is_leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); Ok(Value::Bool(is_leap)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "is_leap_year() requires a timestamp".to_string(), )), }, @@ -1343,14 +1343,14 @@ pub fn init() -> HashMap { func: |args| match &args[0] { Value::Int(ms) => { if *ms < 0 { - return Err(IntentError::RuntimeError( + return Err(IntentError::runtime_error( "sleep() requires non-negative milliseconds".to_string(), )); } std::thread::sleep(Duration::from_millis(*ms as u64)); Ok(Value::Unit) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "sleep() requires an integer (milliseconds)".to_string(), )), }, @@ -1382,7 +1382,7 @@ pub fn init() -> HashMap { let now = Utc::now().timestamp_millis(); Ok(Value::Int(now - start)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "elapsed() requires a start timestamp".to_string(), )), }, @@ -1485,11 +1485,11 @@ pub fn init() -> HashMap { func: |args| match (&args[0], &args[1]) { (Value::Int(ts), Value::String(fmt)) => { let dt = DateTime::from_timestamp(*ts, 0).ok_or_else(|| { - IntentError::RuntimeError("Invalid timestamp".to_string()) + IntentError::runtime_error("Invalid timestamp".to_string()) })?; Ok(Value::String(dt.format(fmt).to_string())) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "format_timestamp() requires int and format string".to_string(), )), }, @@ -1526,7 +1526,7 @@ pub fn init() -> HashMap { map.insert("nanos".to_string(), Value::Int(*secs * 1_000_000_000)); Ok(Value::Map(map)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "duration_secs() requires an integer".to_string(), )), }, @@ -1563,7 +1563,7 @@ pub fn init() -> HashMap { map.insert("nanos".to_string(), Value::Int(*ms * 1_000_000)); Ok(Value::Map(map)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "duration_millis() requires an integer".to_string(), )), }, diff --git a/src/stdlib/url.rs b/src/stdlib/url.rs index c726b2d..83ceb79 100644 --- a/src/stdlib/url.rs +++ b/src/stdlib/url.rs @@ -228,7 +228,7 @@ pub fn init() -> HashMap { Ok(Value::ok(Value::Map(result))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "parse() requires a URL string".to_string(), )), } @@ -261,7 +261,7 @@ pub fn init() -> HashMap { let encoded = url_encode(s); Ok(Value::String(encoded)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "encode() requires a string".to_string(), )), }, @@ -293,7 +293,7 @@ pub fn init() -> HashMap { let encoded = url_encode_component(s); Ok(Value::String(encoded)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "encode_component() requires a string".to_string(), )), }, @@ -324,7 +324,7 @@ pub fn init() -> HashMap { Ok(decoded) => Ok(Value::ok(Value::String(decoded))), Err(e) => Ok(Value::err(Value::String(e))), }, - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "decode() requires a string".to_string(), )), }, @@ -362,7 +362,7 @@ pub fn init() -> HashMap { .collect(); Ok(Value::String(pairs.join("&"))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "build_query() requires a map".to_string(), )), }, @@ -413,7 +413,7 @@ pub fn init() -> HashMap { Ok(Value::Map(result)) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "parse_query() requires a string".to_string(), )), } @@ -447,7 +447,7 @@ pub fn init() -> HashMap { let path = path.trim_start_matches('/'); Ok(Value::String(format!("{}/{}", base, path))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "join_url() requires two strings".to_string(), )), }, @@ -478,7 +478,7 @@ pub fn init() -> HashMap { let path = path.trim_start_matches('/'); Ok(Value::String(format!("{}/{}", base, path))) } - _ => Err(IntentError::TypeError( + _ => Err(IntentError::type_error( "join() requires two strings".to_string(), )), } diff --git a/src/typechecker.rs b/src/typechecker.rs index 74e9404..9a2389b 100644 --- a/src/typechecker.rs +++ b/src/typechecker.rs @@ -16,10 +16,27 @@ pub enum Severity { Warning, } +/// Classification of type diagnostics for structured matching. +/// +/// Used by `check_program_with_lint_mode` to promote annotation warnings +/// to errors in strict mode without brittle substring matching. +#[derive(Debug, Clone, PartialEq)] +pub enum DiagnosticKind { + /// Missing type annotation on function parameter + MissingParamAnnotation, + /// Missing return type annotation on function + MissingReturnAnnotation, + /// Missing type annotation on lambda parameter + MissingLambdaParamAnnotation, + /// General type error (mismatch, undefined, etc.) + General, +} + /// A diagnostic produced by the type checker #[derive(Debug, Clone)] pub struct TypeDiagnostic { pub severity: Severity, + pub kind: DiagnosticKind, pub message: String, pub line: usize, pub column: usize, @@ -34,6 +51,9 @@ pub struct FunctionSig { pub variadic: bool, /// Number of required parameters (those without defaults). Defaults to params.len(). pub required_params: usize, + /// Generic type parameter names (e.g., ["T", "U"] for `fn foo`). + /// Empty for non-generic functions. + pub type_params: Vec, } /// Exported type definitions from a parsed file (functions, structs, enums, aliases) @@ -79,9 +99,22 @@ pub struct TypeContext { resolving_files: Vec, } -/// Returns true if NTNT_STRICT mode is enabled +/// Returns true if NTNT_STRICT mode is enabled. +/// +/// **Deprecated:** Use `NTNT_LINT_MODE=strict` instead. +/// This function emits a one-time deprecation warning to stderr when +/// `NTNT_STRICT` is detected. pub fn is_strict_mode() -> bool { - std::env::var("NTNT_STRICT").map_or(false, |v| v == "1" || v == "true") + use std::sync::Once; + static DEPRECATION_WARNED: Once = Once::new(); + + let is_set = std::env::var("NTNT_STRICT").map_or(false, |v| v == "1" || v == "true"); + if is_set { + DEPRECATION_WARNED.call_once(|| { + eprintln!("[DEPRECATED] NTNT_STRICT is deprecated. Use NTNT_LINT_MODE=strict instead."); + }); + } + is_set } /// Run the type checker in strict mode. Returns `Some(errors)` if strict mode is @@ -90,13 +123,19 @@ pub fn strict_check(ast: &Program, source: &str) -> Option> strict_check_with_file(ast, source, None) } -/// Strict check with file path for cross-file import resolution +/// Strict check with file path for cross-file import resolution. +/// +/// Runs when either `NTNT_STRICT=1` (deprecated) or `NTNT_LINT_MODE=strict` is set. pub fn strict_check_with_file( ast: &Program, source: &str, file_path: Option<&str>, ) -> Option> { - if !is_strict_mode() { + let lint_strict = matches!( + crate::config::get_lint_mode(), + crate::config::LintMode::Strict + ); + if !is_strict_mode() && !lint_strict { return None; } let errors: Vec<_> = check_program_with_options(ast, source, false, file_path) @@ -138,6 +177,38 @@ pub fn check_program_strict_with_file( check_program_with_options(ast, source, true, Some(file_path)) } +/// Entry point using `LintMode` enum (from `NTNT_LINT_MODE` env or CLI flags). +pub fn check_program_with_lint_mode( + ast: &Program, + source: &str, + lint_mode: crate::config::LintMode, + file_path: Option<&str>, +) -> Vec { + let strict = matches!( + lint_mode, + crate::config::LintMode::Warn | crate::config::LintMode::Strict + ); + let mut diagnostics = check_program_with_options(ast, source, strict, file_path); + + // In strict mode, promote annotation warnings to errors using structured kind + if matches!(lint_mode, crate::config::LintMode::Strict) { + for d in &mut diagnostics { + if d.severity == Severity::Warning + && matches!( + d.kind, + DiagnosticKind::MissingParamAnnotation + | DiagnosticKind::MissingReturnAnnotation + | DiagnosticKind::MissingLambdaParamAnnotation + ) + { + d.severity = Severity::Error; + } + } + } + + diagnostics +} + fn check_program_with_options( ast: &Program, source: &str, @@ -339,9 +410,17 @@ impl TypeContext { // ── Diagnostics ─────────────────────────────────────────────────── - fn emit(&mut self, severity: Severity, message: String, line: usize, hint: Option) { + fn emit_with_kind( + &mut self, + severity: Severity, + kind: DiagnosticKind, + message: String, + line: usize, + hint: Option, + ) { self.diagnostics.push(TypeDiagnostic { severity, + kind, message, line, column: 0, @@ -349,6 +428,10 @@ impl TypeContext { }); } + fn emit(&mut self, severity: Severity, message: String, line: usize, hint: Option) { + self.emit_with_kind(severity, DiagnosticKind::General, message, line, hint); + } + fn error(&mut self, message: String, line: usize, hint: Option) { self.emit(Severity::Error, message, line, hint); } @@ -413,7 +496,15 @@ impl TypeContext { if self.structs.contains_key(name) || self.enums.contains_key(name) { return Type::Named(name.clone()); } - // Treat unresolved type names as Any (likely type parameters like T, U) + // Single uppercase letter or common type param names: keep as Named + // so generic unification can resolve them (e.g., T, U, V, K, V2, etc.) + // Multi-char all-uppercase names also treated as type params. + let looks_like_type_param = name.len() == 1 + || name.chars().all(|c| c.is_uppercase() || c.is_ascii_digit()); + if looks_like_type_param { + return Type::Named(name.clone()); + } + // Treat other unresolved names as Any Type::Any } }, @@ -535,9 +626,11 @@ impl TypeContext { name, params, return_type, - type_params: _, + type_params, .. } => { + let tp_names: Vec = type_params.iter().map(|t| t.name.clone()).collect(); + let param_types: Vec<(String, Type)> = params .iter() .map(|p| { @@ -567,6 +660,7 @@ impl TypeContext { return_type: ret, variadic: false, required_params, + type_params: tp_names, }, ); } @@ -732,7 +826,9 @@ impl TypeContext { let fn_line = self.find_line_near(&format!("fn {}", name)); for param in params { if param.type_annotation.is_none() { - self.warning( + self.emit_with_kind( + Severity::Warning, + DiagnosticKind::MissingParamAnnotation, format!( "Parameter '{}' in function '{}' has no type annotation", param.name, name @@ -743,7 +839,9 @@ impl TypeContext { } } if return_type.is_none() { - self.warning( + self.emit_with_kind( + Severity::Warning, + DiagnosticKind::MissingReturnAnnotation, format!("Function '{}' has no return type annotation", name), fn_line, Some(format!("Add a return type: fn {}(...) -> Type", name)), @@ -1759,6 +1857,23 @@ impl TypeContext { Expression::Lambda { params, body } => { self.push_scope(); + + // Strict lint: warn about untyped lambda parameters + if self.strict_lint { + for param in params { + if param.type_annotation.is_none() { + let line = self.find_line_near(¶m.name); + self.emit_with_kind( + Severity::Warning, + DiagnosticKind::MissingLambdaParamAnnotation, + format!("Lambda parameter '{}' has no type annotation", param.name), + line, + Some(format!("Add a type: {}: Type", param.name)), + ); + } + } + } + // Save and reset collected_returns for lambda scope let prev_return = self.current_return_type.take(); let prev_collected = std::mem::take(&mut self.collected_returns); @@ -2343,6 +2458,12 @@ impl TypeContext { .zip(sig.params.iter()) .enumerate() { + // Skip type-checking for generic type params — they accept any type. + let is_type_param = + matches!(param_type, Type::Named(n) if sig.type_params.contains(n)); + if is_type_param { + continue; + } if !self.compatible(arg_type, param_type) && !matches!(arg_type, Type::Any) && !matches!(param_type, Type::Any) @@ -2364,6 +2485,35 @@ impl TypeContext { } } + // Generic type unification: if the function has type params, + // infer T from the concrete argument types and substitute into return type. + if !sig.type_params.is_empty() { + let (bindings, conflicts) = + Self::unify_type_params(&sig.type_params, &sig.params, &arg_types); + // Emit errors for conflicting type param bindings + // e.g., fn f(a: T, b: T) called with (Int, String) + for (param_name, first_type, second_type) in &conflicts { + let line = self.find_line_near(&format!("{}(", name)); + self.error( + format!( + "Type parameter '{}' in '{}': conflicting types {} and {}", + param_name, + name, + first_type.name(), + second_type.name() + ), + line, + Some(format!( + "All arguments for '{}' must have the same type", + param_name + )), + ); + } + if !bindings.is_empty() { + return Self::substitute_type_params(&sig.return_type, &bindings); + } + } + return sig.return_type; } } @@ -2372,6 +2522,137 @@ impl TypeContext { Type::Any } + /// Unify generic type parameters with concrete argument types. + /// Returns a map from type param name → concrete type, and a list of + /// conflicts (type param bound to incompatible types across arguments). + /// + /// Example: `fn identity(x: T) -> T` called with `Int` arg → `{"T": Int}` + /// Example: `fn f(a: T, b: T)` called with `(Int, String)` → conflict on T + fn unify_type_params( + type_params: &[String], + param_sigs: &[(String, Type)], + arg_types: &[Type], + ) -> (HashMap, Vec<(String, Type, Type)>) { + let mut bindings: HashMap = HashMap::new(); + let mut conflicts: Vec<(String, Type, Type)> = Vec::new(); + for ((_param_name, param_type), arg_type) in param_sigs.iter().zip(arg_types.iter()) { + Self::unify_one( + param_type, + arg_type, + type_params, + &mut bindings, + &mut conflicts, + ); + } + (bindings, conflicts) + } + + /// Recursively unify a single param type pattern with a concrete type. + fn unify_one( + pattern: &Type, + concrete: &Type, + type_params: &[String], + bindings: &mut HashMap, + conflicts: &mut Vec<(String, Type, Type)>, + ) { + match pattern { + // If the pattern is a named type that's a type parameter → bind it + Type::Any => {} // Any is already maximally general + Type::Named(name) if type_params.contains(name) => { + if let Some(existing) = bindings.get(name) { + // Check for conflict: same type param bound to different types + if existing != concrete + && !matches!(existing, Type::Any) + && !matches!(concrete, Type::Any) + { + conflicts.push((name.clone(), existing.clone(), concrete.clone())); + } + } else { + bindings.insert(name.clone(), concrete.clone()); + } + } + // Recurse into compound types + Type::Array(inner_pattern) => { + if let Type::Array(inner_concrete) = concrete { + Self::unify_one( + inner_pattern, + inner_concrete, + type_params, + bindings, + conflicts, + ); + } + } + Type::Optional(inner_pattern) => { + let inner_concrete = match concrete { + Type::Optional(c) => c.as_ref(), + other => other, // T? unified with T also binds T + }; + Self::unify_one( + inner_pattern, + inner_concrete, + type_params, + bindings, + conflicts, + ); + } + Type::Function { + params: fn_params, + return_type: fn_ret, + } => { + if let Type::Function { + params: concrete_params, + return_type: concrete_ret, + } = concrete + { + for (fp, cp) in fn_params.iter().zip(concrete_params.iter()) { + Self::unify_one(fp, cp, type_params, bindings, conflicts); + } + Self::unify_one(fn_ret, concrete_ret, type_params, bindings, conflicts); + } + } + _ => {} // Concrete types don't produce bindings + } + } + + /// Substitute resolved type parameter bindings into a type. + /// + /// Example: return type `T` with bindings `{"T": Int}` → `Int` + fn substitute_type_params(ty: &Type, bindings: &HashMap) -> Type { + match ty { + Type::Named(name) => { + if let Some(resolved) = bindings.get(name) { + resolved.clone() + } else { + ty.clone() + } + } + Type::Array(inner) => { + Type::Array(Box::new(Self::substitute_type_params(inner, bindings))) + } + Type::Optional(inner) => { + Type::Optional(Box::new(Self::substitute_type_params(inner, bindings))) + } + Type::Function { + params, + return_type, + } => Type::Function { + params: params + .iter() + .map(|p| Self::substitute_type_params(p, bindings)) + .collect(), + return_type: Box::new(Self::substitute_type_params(return_type, bindings)), + }, + Type::Tuple(types) => Type::Tuple( + types + .iter() + .map(|t| Self::substitute_type_params(t, bindings)) + .collect(), + ), + _ => ty.clone(), + } + } + /// Bind pattern variables with their inferred types fn bind_pattern(&mut self, pattern: &Pattern, scrutinee_type: &Type) { match pattern { @@ -2665,6 +2946,7 @@ impl TypeContext { return_type: $ret, variadic: false, required_params, + type_params: vec![], }); } }; @@ -2677,6 +2959,7 @@ impl TypeContext { return_type: $ret, variadic: true, required_params, + type_params: vec![], }); } }; @@ -2803,6 +3086,7 @@ fn get_module_signatures(module: &str) -> HashMap { return_type: $ret, variadic: false, required_params, + type_params: vec![], }); } }; @@ -2815,6 +3099,7 @@ fn get_module_signatures(module: &str) -> HashMap { return_type: $ret, variadic: true, required_params, + type_params: vec![], }); } }; @@ -5694,6 +5979,7 @@ let n: Int = double(5)"#; return_type: Type::String, variadic: false, required_params: 2, + type_params: vec![], }, ); @@ -5936,6 +6222,7 @@ let n: Int = double(5)"#; return_type: Type::String, variadic: false, required_params: 1, + type_params: vec![], }, ); diff --git a/tests/language_features_tests.rs b/tests/language_features_tests.rs index 898e728..ffaae71 100644 --- a/tests/language_features_tests.rs +++ b/tests/language_features_tests.rs @@ -4495,3 +4495,83 @@ print(out) assert_eq!(exit_code, 0, "Nested if inside elif should work"); assert_eq!(stdout.trim(), "second-deep"); } + +// ============================================================================ +// Type System Feature Tests (DD-009, Phase 7.2) +// ============================================================================ + +#[test] +fn test_type_alias_basic() { + let code = r#" +type UserId = Int + +fn get_user(id: UserId) -> String { + return "user_" + str(id) +} +print(get_user(42)) +"#; + let (stdout, _, exit_code) = run_ntnt_code(code); + assert_eq!(exit_code, 0, "Type alias should work"); + assert_eq!(stdout.trim(), "user_42"); +} + +#[test] +fn test_type_alias_function_type() { + let code = r#" +type Mapper = (Int) -> Int + +fn apply(f: Mapper, x: Int) -> Int { + return f(x) +} + +let double = fn(x: Int) -> Int { return x * 2 } +print(apply(double, 21)) +"#; + let (stdout, _, exit_code) = run_ntnt_code(code); + assert_eq!(exit_code, 0, "Function type alias should work"); + assert_eq!(stdout.trim(), "42"); +} + +#[test] +fn test_optional_type_annotation() { + let code = r#" +fn greet(name: String?) -> String { + return name ?? "world" +} +print(greet("test")) +"#; + let (stdout, _, exit_code) = run_ntnt_code(code); + assert_eq!(exit_code, 0, "Optional type annotation should work"); + assert_eq!(stdout.trim(), "test"); +} + +#[test] +fn test_array_type_literal_syntax() { + let code = r#" +fn sum(nums: [Int]) -> Int { + let mut total = 0 + for n in nums { + total = total + n + } + return total +} +print(sum([1, 2, 3, 4, 5])) +"#; + let (stdout, _, exit_code) = run_ntnt_code(code); + assert_eq!(exit_code, 0, "[Int] array type syntax should work"); + assert_eq!(stdout.trim(), "15"); +} + +#[test] +fn test_rich_error_binary_op_mismatch() { + let code = r#" +let result = "hello" - 42 +"#; + let (_, stderr, exit_code) = run_ntnt_code(code); + assert_ne!(exit_code, 0, "String - Int should error"); + assert!( + stderr.contains("Cannot apply") || stderr.contains("String") || stderr.contains("Int"), + "Error should mention types involved, got: {}", + stderr + ); +} diff --git a/tests/type_checker_tests.rs b/tests/type_checker_tests.rs index ffef5d8..f5f6d16 100644 --- a/tests/type_checker_tests.rs +++ b/tests/type_checker_tests.rs @@ -424,72 +424,80 @@ print(result) #[test] fn test_strict_lint_warns_untyped_params() { - let code = r#"fn greet(name) { + let source = r#"fn greet(name) { return "hello " + name } print(greet("world")) "#; - let (stdout, _stderr, _code) = lint_strict_code(code); + let (stdout, _stderr, exit_code) = lint_strict_code(source); let json: serde_json::Value = serde_json::from_str(&stdout).expect("lint --strict should output valid JSON"); - let warnings = json["summary"]["warnings"].as_i64().unwrap_or(0); + let errors = json["summary"]["errors"].as_i64().unwrap_or(0); assert!( - warnings > 0, - "Strict lint should produce warnings for untyped params, got 0 warnings" + errors > 0, + "Strict lint should produce errors for untyped params, got 0 errors" ); - // Verify the warnings mention the untyped parameter + // In strict mode, missing annotations are promoted to errors (not warnings) let files = json["files"].as_array().unwrap(); let issues = files[0]["issues"].as_array().unwrap(); - let type_warnings: Vec<&serde_json::Value> = issues + let type_errors: Vec<&serde_json::Value> = issues .iter() .filter(|issue| { issue["rule"].as_str() == Some("type_check") - && issue["severity"].as_str() == Some("warning") + && issue["severity"].as_str() == Some("error") }) .collect(); assert!( - !type_warnings.is_empty(), - "Should have type_check warnings for untyped parameters" + !type_errors.is_empty(), + "Should have type_check errors for untyped parameters in strict mode" ); - let has_param_warning = type_warnings.iter().any(|w| { + let has_param_error = type_errors.iter().any(|w| { let msg = w["message"].as_str().unwrap_or(""); msg.contains("no type annotation") && msg.contains("name") }); assert!( - has_param_warning, - "Should warn about untyped parameter 'name'. Warnings: {:?}", - type_warnings + has_param_error, + "Should error about untyped parameter 'name' in strict mode. Errors: {:?}", + type_errors + ); + + // Strict lint with errors should exit non-zero + assert!( + exit_code != 0, + "ntnt lint --strict should exit non-zero when annotation errors exist, got exit code {}", + exit_code ); } #[test] fn test_strict_lint_warns_missing_return_type() { - let code = r#"fn add(a: Int, b: Int) { + let source = r#"fn add(a: Int, b: Int) { return a + b } print(add(1, 2)) "#; - let (stdout, _stderr, _code) = lint_strict_code(code); + let (stdout, _stderr, exit_code) = lint_strict_code(source); let json: serde_json::Value = serde_json::from_str(&stdout).expect("lint --strict should output valid JSON"); let files = json["files"].as_array().unwrap(); let issues = files[0]["issues"].as_array().unwrap(); - let return_warnings: Vec<&serde_json::Value> = issues + // In strict mode, missing return type annotations are promoted to errors + let return_errors: Vec<&serde_json::Value> = issues .iter() .filter(|issue| { issue["rule"].as_str() == Some("type_check") - && issue["severity"].as_str() == Some("warning") + && issue["severity"].as_str() == Some("error") && issue["message"] .as_str() .unwrap_or("") @@ -498,8 +506,15 @@ print(add(1, 2)) .collect(); assert!( - !return_warnings.is_empty(), - "Strict lint should warn about missing return type on 'add'" + !return_errors.is_empty(), + "Strict lint should error about missing return type on 'add'" + ); + + // Strict lint with errors should exit non-zero + assert!( + exit_code != 0, + "ntnt lint --strict should exit non-zero when annotation errors exist, got exit code {}", + exit_code ); } @@ -575,6 +590,90 @@ print(greet("world")) } } +// ============================================================================ +// --warn-untyped integration tests (DD-009) +// ============================================================================ + +/// Helper to run `ntnt lint --warn-untyped` on code +fn lint_warn_untyped_code(code: &str) -> (String, String, i32) { + let test_file = unique_test_file("lint_warn_untyped"); + let mut file = fs::File::create(&test_file).expect("Failed to create test file"); + write!(file, "{}", code).expect("Failed to write test file"); + drop(file); + let result = run_ntnt(&["lint", "--warn-untyped", &test_file]); + fs::remove_file(&test_file).ok(); + result +} + +#[test] +fn test_warn_untyped_produces_warnings_not_errors() { + let source = r#"fn greet(name) { + return "hello " + name +} + +print(greet("world")) +"#; + + let (stdout, _stderr, exit_code) = lint_warn_untyped_code(source); + + let json: serde_json::Value = + serde_json::from_str(&stdout).expect("lint --warn-untyped should output valid JSON"); + + let warnings = json["summary"]["warnings"].as_i64().unwrap_or(0); + let errors = json["summary"]["errors"].as_i64().unwrap_or(0); + + assert!( + warnings > 0, + "--warn-untyped should produce warnings for untyped params, got 0" + ); + assert_eq!( + errors, 0, + "--warn-untyped should NOT produce errors (warnings are non-fatal), got {} errors", + errors + ); + + // Exit code should be 0 (warnings are non-fatal) + assert_eq!( + exit_code, 0, + "--warn-untyped should exit 0 (warnings are non-fatal), got exit code {}", + exit_code + ); +} + +#[test] +fn test_warn_untyped_no_warnings_on_fully_typed() { + let code = r#"fn add(a: Int, b: Int) -> Int { + return a + b +} + +print(add(1, 2)) +"#; + + let (stdout, _stderr, _code) = lint_warn_untyped_code(code); + + let json: serde_json::Value = + serde_json::from_str(&stdout).expect("lint --warn-untyped should output valid JSON"); + + let files = json["files"].as_array().unwrap(); + if !files.is_empty() { + let issues = files[0]["issues"].as_array().unwrap(); + let type_warnings: Vec<&serde_json::Value> = issues + .iter() + .filter(|issue| { + issue["rule"].as_str() == Some("type_check") + && (issue["severity"].as_str() == Some("warning") + || issue["severity"].as_str() == Some("error")) + }) + .collect(); + + assert!( + type_warnings.is_empty(), + "Fully typed code with --warn-untyped should have zero type warnings, found: {:?}", + type_warnings + ); + } +} + // ============================================================================ // Contract type-checking integration tests // ============================================================================ @@ -847,3 +946,81 @@ fn check(x: Int, y: String) -> Bool { comparison_warnings ); } + +// ============================================================================ +// Generic type parameter unification tests (DD-009 Phase 7.4) +// ============================================================================ + +#[test] +fn test_generic_identity_infers_return_type() { + // identity(42) should infer T=Int, return type Int + // Assigning to String should produce a type error + let source = r#" +fn identity(x: T) -> T { + return x +} +let result: String = identity(42) +"#; + let (stdout, _stderr, exit_code) = lint_code(source); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let errors = json["summary"]["errors"].as_i64().unwrap_or(0); + assert!( + errors > 0, + "identity(42) assigned to String should produce a type error" + ); + assert_ne!(exit_code, 0); +} + +#[test] +fn test_generic_identity_correct_usage() { + // identity(42) assigned to Int should be fine + let source = r#" +fn identity(x: T) -> T { + return x +} +let result: Int = identity(42) +"#; + let (stdout, _stderr, _exit_code) = lint_code(source); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let errors = json["summary"]["errors"].as_i64().unwrap_or(0); + assert_eq!( + errors, 0, + "identity(42) assigned to Int should not error" + ); +} + +#[test] +fn test_generic_conflicting_type_params() { + // fn f(a: T, b: T) called with (Int, String) should error + let source = r#" +fn merge(a: T, b: T) -> T { + return a +} +let result = merge(42, "hello") +"#; + let (stdout, _stderr, _exit_code) = lint_code(source); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let errors = json["summary"]["errors"].as_i64().unwrap_or(0); + assert!( + errors > 0, + "merge(Int, String) should produce a type error for conflicting T" + ); +} + +#[test] +fn test_generic_array_unification() { + // fn first(arr: [T]) -> T should unify T from array element type + let source = r#" +fn first(arr: [T]) -> T { + return arr[0] +} +let x: Int = first([1, 2, 3]) +"#; + let (stdout, _stderr, _exit_code) = lint_code(source); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let errors = json["summary"]["errors"].as_i64().unwrap_or(0); + assert_eq!( + errors, 0, + "first([Int]) assigned to Int should not error" + ); +}