Skip to main content

axon/
tool_executor.rs

1//! Native tool executors — Calculator and DateTimeTool.
2//!
3//! Tools are pure functions that execute locally (no LLM call).
4//! When a `use_tool` step references a known tool, the runner
5//! intercepts it and calls the executor directly.
6//!
7//! Supported tools:
8//!   - Calculator: safe arithmetic expression evaluator
9//!   - DateTimeTool: current date/time/timestamp queries
10
11use std::time::{SystemTime, UNIX_EPOCH};
12
13/// Result of a tool execution.
14#[derive(Debug, Clone)]
15pub struct ToolResult {
16    pub success: bool,
17    pub output: String,
18    pub tool_name: String,
19}
20
21/// v2.53.0 — the closed set of stdlib tool names that have a REAL native
22/// executor here. Distinct from `stdlib::TOOLS`, which also lists LLM-backed
23/// tools (e.g. `WebSearch`) that legitimately fall through to the model. A name
24/// present in `stdlib::TOOLS` but NOT in this set AND not implemented below is
25/// the pre-existing defect (v2.53.0 section 8): it silently degrades into the model
26/// *hallucinating* the tool's output. `DocumentRenderer` (whose whole premise
27/// is attestable, non-invented artifacts) MUST NOT ship into that behaviour.
28const NATIVE_EXECUTOR_TOOLS: &[&str] = &[
29    "Calculator",
30    "DateTimeTool",
31    "DocumentRenderer",
32    // v2.54.0 — Document Ingestion & Surgical Edit.
33    "DocumentReader",
34    "DocumentEditor",
35    // v2.54.0 — Inferred Ingestion: the extraction tools become real dispatch
36    // arms. With no engine registered they typed-refuse (`no_engine_configured`),
37    // NEVER fall through to the model inventing the document's contents.
38    "PDFExtractor",
39    "ImageTextExtractor",
40    "ImageAnalyzer",
41];
42
43/// Dispatch a tool call by name. Returns `None` if the tool is not a native
44/// executor AND is not a declared-but-unimplemented stdlib tool (those legacy
45/// names still fall through to the LLM — see `dispatch_or_reject` for the
46/// stricter contract the runtime uses at real call sites).
47pub fn dispatch(tool_name: &str, argument: &str) -> Option<ToolResult> {
48    match tool_name {
49        "Calculator" => Some(calculator_execute(argument)),
50        "DateTimeTool" => Some(datetime_execute(argument)),
51        // v2.53.0 — Native Document Synthesis: render a `document` IR + bound
52        // values into deterministic OOXML bytes (the design decision: returns the artifact
53        // value, never a filesystem write).
54        "DocumentRenderer" => Some(document_render_execute(argument)),
55        // v2.54.0 — read an ingested OOXML document into a bounded, born-
56        // Untrusted, Parsed text tree.
57        "DocumentReader" => Some(document_read_execute(argument)),
58        // v2.54.0 — surgical edit: touch only the targeted parts, emit the
59        // per-part hash manifest, inherit taint.
60        "DocumentEditor" => Some(document_edit_execute(argument)),
61        // v2.54.0 — Inferred Ingestion. Each routes to the registered
62        // extraction engine (the sidecar client v2.54.0 / the enterprise engine
63        // v2.54.0); with none registered, each returns a TYPED refusal, never a
64        // hallucinated read. OCR (`image:text`) and vision
65        // (`image:description`) are distinct transforms.
66        "PDFExtractor" => Some(extraction_execute("pdf:text", argument)),
67        "ImageTextExtractor" => Some(extraction_execute("image:text", argument)),
68        "ImageAnalyzer" => Some(extraction_execute("image:description", argument)),
69        _ => None, // Not a native tool — fall through to LLM
70    }
71}
72
73/// v2.53.0 section 8 — the honest dispatch: a tool name DECLARED in `stdlib::TOOLS`
74/// but with no native executor and no LLM provider is a **typed refusal**, not
75/// a silent hand-off to the model. This closes the pre-existing defect where
76/// calling e.g. `PDFExtractor` (declared, unimplemented) returned the model
77/// *inventing* the PDF's contents, shaped exactly like a real extraction.
78/// Returns `Err` for such names; `Ok(Some)` for a real native result;
79/// `Ok(None)` for a name with a legitimate non-native (LLM/provider) path.
80pub fn dispatch_or_reject(tool_name: &str, argument: &str) -> Result<Option<ToolResult>, String> {
81    if let Some(r) = dispatch(tool_name, argument) {
82        return Ok(Some(r));
83    }
84    // Declared in the stdlib catalog but not natively executable here.
85    let declared = crate::stdlib::TOOLS.iter().any(|t| t.name == tool_name);
86    if declared && !NATIVE_EXECUTOR_TOOLS.contains(&tool_name) {
87        // Only tools with a real provider (`requires_api_key` / a `provider`)
88        // may legitimately reach a non-native backend; a declared tool with
89        // neither a native executor nor a provider would silently become model
90        // invention, so refuse.
91        let has_provider = crate::stdlib::TOOLS
92            .iter()
93            .find(|t| t.name == tool_name)
94            .map(|t| !t.provider.is_empty() || t.requires_api_key)
95            .unwrap_or(false);
96        if !has_provider {
97            return Err(format!(
98                "tool '{tool_name}' is declared in the stdlib catalog but has no native \
99                 implementation and no provider — refusing to silently hand the call to the \
100                 model, which would fabricate its output. Implement the tool, give \
101                 it a provider, or remove it from the catalog."
102            ));
103        }
104    }
105    Ok(None)
106}
107
108/// v2.53.0 — render a `document` into OOXML bytes. The argument is JSON:
109/// `{ "document": <IRDocument>, "values": { "<ref>": "<resolved text>" } }`.
110/// Returns a typed artifact descriptor (sha256 + content type + base64 bytes)
111/// as JSON — the HOST decides where the bytes land.
112// v2.81.0 — the OOXML surface lives behind the `documents` feature. The
113// tool STAYS REGISTERED and refuses in writing: a capability that silently
114// vanishes from a build is the v2.67.0 defect (an advertised primitive that is not
115// there), and a runtime that answers "unknown tool" teaches the adopter nothing.
116#[cfg(not(feature = "documents"))]
117fn document_render_execute(_argument: &str) -> ToolResult {
118    ToolResult {
119        success: false,
120        output: "DocumentRenderer: this build was compiled without the `documents` feature, so the OOXML engine is absent. Reinstall with: cargo install axon-lang --features documents"
121            .to_string(),
122        tool_name: "DocumentRenderer".to_string(),
123    }
124}
125
126#[cfg(feature = "documents")]
127fn document_render_execute(argument: &str) -> ToolResult {
128    use base64::Engine;
129    let name = "DocumentRenderer";
130    let parsed: serde_json::Value = match serde_json::from_str(argument) {
131        Ok(v) => v,
132        Err(e) => return err(name, format!("invalid render request JSON: {e}")),
133    };
134    let doc_val = match parsed.get("document") {
135        Some(d) => d.clone(),
136        None => return err(name, "render request missing `document`".into()),
137    };
138    let spec: crate::ooxml::DocumentSpec = match serde_json::from_value(doc_val) {
139        Ok(s) => s,
140        Err(e) => return err(name, format!("malformed document IR: {e}")),
141    };
142    let values: std::collections::BTreeMap<String, String> = parsed
143        .get("values")
144        .and_then(|v| serde_json::from_value(v.clone()).ok())
145        .unwrap_or_default();
146    match crate::ooxml::render(&spec, &values) {
147        Ok(out) => {
148            let b64 = base64::engine::general_purpose::STANDARD.encode(&out.bytes);
149            let descriptor = serde_json::json!({
150                "sha256": out.sha256_hex,
151                "content_type": out.content_type,
152                "extension": out.extension,
153                "size_bytes": out.bytes.len(),
154                "bytes_base64": b64,
155            });
156            ToolResult {
157                success: true,
158                output: descriptor.to_string(),
159                tool_name: name.to_string(),
160            }
161        }
162        Err(e) => err(name, e.to_string()),
163    }
164}
165
166/// v2.54.0 — the shared extraction dispatch arm. `transform` is the OTS
167/// transform the calling tool asked for (`pdf:text` / `image:text` /
168/// `image:description`). The argument is JSON: `{ "bytes_base64": "…",
169/// "format"?: "pdf", "target_field"?: "due_date", "language"?: "en" }`.
170///
171/// It routes to the registered engine (`extraction::run_active`) and formats the
172/// born-`Inferred` result as JSON — spans + measured confidence + provenance
173/// (`inferred`) + taint (`untrusted`) + ceiling (`believe`) + the audit fields.
174/// **On any failure it returns a typed refusal** (`success: false`, the error
175/// slug), never an empty or invented string.
176fn extraction_execute(transform: &str, argument: &str) -> ToolResult {
177    use base64::Engine;
178    let name = match transform {
179        "pdf:text" => "PDFExtractor",
180        "image:text" => "ImageTextExtractor",
181        _ => "ImageAnalyzer",
182    };
183    let parsed: serde_json::Value = match serde_json::from_str(argument) {
184        Ok(v) => v,
185        Err(e) => return err(name, format!("invalid extraction request JSON: {e}")),
186    };
187    let bytes = match parsed.get("bytes_base64").and_then(|v| v.as_str()) {
188        Some(b64) => match base64::engine::general_purpose::STANDARD.decode(b64) {
189            Ok(b) => b,
190            Err(e) => return err(name, format!("bytes_base64 is not valid base64: {e}")),
191        },
192        None => return err(name, "extraction request missing `bytes_base64`".into()),
193    };
194    let str_field = |k: &str| parsed.get(k).and_then(|v| v.as_str()).map(|s| s.to_string());
195    let hint = crate::extraction::ExtractionHint {
196        format: str_field("format"),
197        transform: Some(transform.to_string()),
198        target_field: str_field("target_field"),
199        language: str_field("language"),
200    };
201    match crate::extraction::run_active(&bytes, &hint, &crate::extraction::ExtractionBounds::default())
202    {
203        Ok(result) => {
204            let spans: Vec<serde_json::Value> = result
205                .spans
206                .iter()
207                .map(|s| {
208                    serde_json::json!({
209                        "text": s.text,
210                        "confidence": s.confidence,
211                        "page": s.page,
212                        "bbox": { "x": s.bbox.x, "y": s.bbox.y, "w": s.bbox.w, "h": s.bbox.h },
213                    })
214                })
215                .collect();
216            let out = serde_json::json!({
217                "engine": result.engine,
218                "engine_version": result.engine_version,
219                "transform": transform,
220                "provenance": "inferred", // the design decision — never `parsed`
221                "taint": "untrusted", // the design decision
222                "epistemic_ceiling": "believe", // the design decision — never `know`
223                "mean_confidence": result.mean_confidence(),
224                "page_count": result.page_count(),
225                "spans": spans,
226            });
227            ToolResult { success: true, output: out.to_string(), tool_name: name.to_string() }
228        }
229        // Typed refusal — the whole point of v2.54.0 + the design decision: never fiction.
230        Err(e) => err(name, format!("{}: {}", e.slug(), e)),
231    }
232}
233
234fn err(tool: &str, msg: String) -> ToolResult {
235    ToolResult {
236        success: false,
237        output: format!("DocumentRenderer: {msg}"),
238        tool_name: tool.to_string(),
239    }
240}
241
242/// v2.54.0 — read an ingested OOXML document. The argument is JSON:
243/// `{ "bytes_base64": "…" }` (the host has already read + sandboxed the file),
244/// OR `{ "path": "…", "roots": ["…"] }` (read through the v2.54.0 path sandbox).
245/// Returns the born-Untrusted, Parsed text tree + per-part hashes.
246// v2.81.0 — the OOXML surface lives behind the `documents` feature. The
247// tool STAYS REGISTERED and refuses in writing: a capability that silently
248// vanishes from a build is the v2.67.0 defect (an advertised primitive that is not
249// there), and a runtime that answers "unknown tool" teaches the adopter nothing.
250#[cfg(not(feature = "documents"))]
251fn document_read_execute(_argument: &str) -> ToolResult {
252    ToolResult {
253        success: false,
254        output: "DocumentReader: this build was compiled without the `documents` feature, so the OOXML engine is absent. Reinstall with: cargo install axon-lang --features documents"
255            .to_string(),
256        tool_name: "DocumentReader".to_string(),
257    }
258}
259
260#[cfg(feature = "documents")]
261fn document_read_execute(argument: &str) -> ToolResult {
262    use base64::Engine;
263    let name = "DocumentReader";
264    let parsed: serde_json::Value = match serde_json::from_str(argument) {
265        Ok(v) => v,
266        Err(e) => return err_named(name, format!("invalid request JSON: {e}")),
267    };
268    // Resolve the bytes — either supplied directly, or read via the sandbox.
269    let bytes: Vec<u8> = if let Some(b64) = parsed.get("bytes_base64").and_then(|v| v.as_str()) {
270        match base64::engine::general_purpose::STANDARD.decode(b64) {
271            Ok(b) => b,
272            Err(e) => return err_named(name, format!("invalid base64: {e}")),
273        }
274    } else if let Some(path) = parsed.get("path").and_then(|v| v.as_str()) {
275        let roots: Vec<std::path::PathBuf> = parsed
276            .get("roots")
277            .and_then(|v| v.as_array())
278            .map(|a| a.iter().filter_map(|x| x.as_str().map(std::path::PathBuf::from)).collect())
279            .unwrap_or_default();
280        let sandbox = crate::fs_sandbox::PathSandbox::new(roots);
281        match sandbox.resolve(path) {
282            Ok(resolved) => match std::fs::read(&resolved) {
283                Ok(b) => b,
284                Err(e) => return err_named(name, format!("read failed: {e}")),
285            },
286            Err(e) => return err_named(name, e.to_string()),
287        }
288    } else {
289        return err_named(name, "request needs `bytes_base64` or `path`".into());
290    };
291
292    match crate::ooxml_read::read_ooxml(&bytes, &crate::ooxml_read::IngestBounds::default()) {
293        Ok(doc) => {
294            let text: Vec<serde_json::Value> = doc
295                .text
296                .iter()
297                .map(|r| serde_json::json!({ "part": r.part, "text": r.text }))
298                .collect();
299            let out = serde_json::json!({
300                "format": doc.format,
301                // Born Untrusted, Parsed — the type system carries this.
302                "taint": doc.taint.as_str(),
303                "provenance": doc.provenance.as_str(),
304                "epistemic_ceiling": doc.provenance.epistemic_ceiling(),
305                "text": text,
306                "full_text": doc.full_text(),
307                "part_hashes": doc.part_hashes,
308            });
309            ToolResult { success: true, output: out.to_string(), tool_name: name.to_string() }
310        }
311        Err(e) => err_named(name, e.to_string()),
312    }
313}
314
315/// v2.54.0 — surgical edit. Argument JSON:
316/// `{ "bytes_base64": "…", "edits": [{ "kind": "replace_text", "part": "…",
317/// "find": "…", "replace": "…" }] }`. Returns the new bytes + the per-part hash
318/// manifest (the proven blast radius) + the inherited taint.
319// v2.81.0 — the OOXML surface lives behind the `documents` feature. The
320// tool STAYS REGISTERED and refuses in writing: a capability that silently
321// vanishes from a build is the v2.67.0 defect (an advertised primitive that is not
322// there), and a runtime that answers "unknown tool" teaches the adopter nothing.
323#[cfg(not(feature = "documents"))]
324fn document_edit_execute(_argument: &str) -> ToolResult {
325    ToolResult {
326        success: false,
327        output: "DocumentEditor: this build was compiled without the `documents` feature, so the OOXML engine is absent. Reinstall with: cargo install axon-lang --features documents"
328            .to_string(),
329        tool_name: "DocumentEditor".to_string(),
330    }
331}
332
333#[cfg(feature = "documents")]
334fn document_edit_execute(argument: &str) -> ToolResult {
335    use base64::Engine;
336    let name = "DocumentEditor";
337    let parsed: serde_json::Value = match serde_json::from_str(argument) {
338        Ok(v) => v,
339        Err(e) => return err_named(name, format!("invalid request JSON: {e}")),
340    };
341    let b64 = match parsed.get("bytes_base64").and_then(|v| v.as_str()) {
342        Some(b) => b,
343        None => return err_named(name, "request needs `bytes_base64`".into()),
344    };
345    let bytes = match base64::engine::general_purpose::STANDARD.decode(b64) {
346        Ok(b) => b,
347        Err(e) => return err_named(name, format!("invalid base64: {e}")),
348    };
349    let doc = match crate::ooxml_read::read_ooxml(&bytes, &crate::ooxml_read::IngestBounds::default()) {
350        Ok(d) => d,
351        Err(e) => return err_named(name, format!("read failed: {e}")),
352    };
353    // Parse the edits.
354    let mut edits = Vec::new();
355    for e in parsed.get("edits").and_then(|v| v.as_array()).cloned().unwrap_or_default() {
356        let part = e.get("part").and_then(|v| v.as_str()).unwrap_or("").to_string();
357        match e.get("kind").and_then(|v| v.as_str()) {
358            Some("replace_text") => edits.push(crate::ooxml_edit::PartEdit::ReplaceText {
359                part,
360                find: e.get("find").and_then(|v| v.as_str()).unwrap_or("").to_string(),
361                replace: e.get("replace").and_then(|v| v.as_str()).unwrap_or("").to_string(),
362            }),
363            Some("replace") => {
364                let nb = e
365                    .get("new_bytes_base64")
366                    .and_then(|v| v.as_str())
367                    .and_then(|s| base64::engine::general_purpose::STANDARD.decode(s).ok())
368                    .unwrap_or_default();
369                edits.push(crate::ooxml_edit::PartEdit::Replace { part, new_bytes: nb });
370            }
371            other => return err_named(name, format!("unknown edit kind '{}'", other.unwrap_or("<none>"))),
372        }
373    }
374    match crate::ooxml_edit::edit_document(&doc, &edits) {
375        Ok(out) => {
376            let manifest: Vec<serde_json::Value> = out
377                .manifest
378                .iter()
379                .map(|m| serde_json::json!({
380                    "part": m.part, "before": m.before_sha256, "after": m.after_sha256, "touched": m.touched,
381                }))
382                .collect();
383            let result = serde_json::json!({
384                "sha256": out.sha256_hex,
385                // The edit inherits the input's taint — no laundering.
386                "taint": out.taint.as_str(),
387                "touched_parts": out.touched_parts(),
388                "manifest": manifest,
389                "bytes_base64": base64::engine::general_purpose::STANDARD.encode(&out.bytes),
390            });
391            ToolResult { success: true, output: result.to_string(), tool_name: name.to_string() }
392        }
393        Err(e) => err_named(name, e.to_string()),
394    }
395}
396
397#[cfg_attr(not(feature = "postgres"), allow(dead_code))]
398fn err_named(tool: &str, msg: String) -> ToolResult {
399    ToolResult { success: false, output: format!("{tool}: {msg}"), tool_name: tool.to_string() }
400}
401
402// ── Calculator ──────────────────────────────────────────────────────────────
403
404/// Safe arithmetic expression evaluator.
405///
406/// Supports: +, -, *, /, % (mod), ** (power), parentheses,
407/// constants (pi, e), and functions (sqrt, abs, round, sin, cos, tan,
408/// log, ln, ceil, floor, pow, min, max).
409pub fn calculator_execute(expression: &str) -> ToolResult {
410    let expr = expression.trim();
411    if expr.is_empty() {
412        return ToolResult {
413            success: false,
414            output: "Empty expression".to_string(),
415            tool_name: "Calculator".to_string(),
416        };
417    }
418
419    match eval_expr(expr) {
420        Ok(val) => {
421            // Format: remove trailing zeros for clean output
422            let formatted = if val.fract() == 0.0 && val.abs() < 1e15 {
423                format!("{}", val as i64)
424            } else {
425                format!("{}", val)
426            };
427            ToolResult {
428                success: true,
429                output: formatted,
430                tool_name: "Calculator".to_string(),
431            }
432        }
433        Err(e) => ToolResult {
434            success: false,
435            output: format!("Calculator error: {e}"),
436            tool_name: "Calculator".to_string(),
437        },
438    }
439}
440
441// ── Calculator parser (recursive descent) ───────────────────────────────────
442
443struct CalcParser<'a> {
444    input: &'a [u8],
445    pos: usize,
446}
447
448impl<'a> CalcParser<'a> {
449    fn new(input: &'a str) -> Self {
450        Self {
451            input: input.as_bytes(),
452            pos: 0,
453        }
454    }
455
456    fn skip_ws(&mut self) {
457        while self.pos < self.input.len() && self.input[self.pos].is_ascii_whitespace() {
458            self.pos += 1;
459        }
460    }
461
462    fn peek(&mut self) -> Option<u8> {
463        self.skip_ws();
464        self.input.get(self.pos).copied()
465    }
466
467    fn consume(&mut self, expected: u8) -> bool {
468        self.skip_ws();
469        if self.pos < self.input.len() && self.input[self.pos] == expected {
470            self.pos += 1;
471            true
472        } else {
473            false
474        }
475    }
476
477    /// expr = term (('+' | '-') term)*
478    fn parse_expr(&mut self) -> Result<f64, String> {
479        let mut result = self.parse_term()?;
480        loop {
481            self.skip_ws();
482            if self.consume(b'+') {
483                result += self.parse_term()?;
484            } else if self.consume(b'-') {
485                result -= self.parse_term()?;
486            } else {
487                break;
488            }
489        }
490        Ok(result)
491    }
492
493    /// term = power (('*' | '/' | '%') power)*
494    fn parse_term(&mut self) -> Result<f64, String> {
495        let mut result = self.parse_power()?;
496        loop {
497            self.skip_ws();
498            if self.consume(b'*') {
499                if self.consume(b'*') {
500                    // ** is power — put it back and let power handle it
501                    self.pos -= 2;
502                    break;
503                }
504                result *= self.parse_power()?;
505            } else if self.consume(b'/') {
506                let divisor = self.parse_power()?;
507                if divisor == 0.0 {
508                    return Err("Division by zero".to_string());
509                }
510                result /= divisor;
511            } else if self.consume(b'%') {
512                let modulus = self.parse_power()?;
513                if modulus == 0.0 {
514                    return Err("Modulo by zero".to_string());
515                }
516                result %= modulus;
517            } else {
518                break;
519            }
520        }
521        Ok(result)
522    }
523
524    /// power = unary ('**' unary)*
525    fn parse_power(&mut self) -> Result<f64, String> {
526        let base = self.parse_unary()?;
527        self.skip_ws();
528        if self.pos + 1 < self.input.len()
529            && self.input[self.pos] == b'*'
530            && self.input[self.pos + 1] == b'*'
531        {
532            self.pos += 2;
533            let exp = self.parse_power()?; // right-associative
534            Ok(base.powf(exp))
535        } else {
536            Ok(base)
537        }
538    }
539
540    /// unary = '-' unary | '+' unary | atom
541    fn parse_unary(&mut self) -> Result<f64, String> {
542        self.skip_ws();
543        if self.consume(b'-') {
544            Ok(-self.parse_unary()?)
545        } else if self.consume(b'+') {
546            self.parse_unary()
547        } else {
548            self.parse_atom()
549        }
550    }
551
552    /// atom = number | '(' expr ')' | function '(' args ')' | constant
553    fn parse_atom(&mut self) -> Result<f64, String> {
554        self.skip_ws();
555
556        // Parenthesized expression
557        if self.consume(b'(') {
558            let val = self.parse_expr()?;
559            if !self.consume(b')') {
560                return Err("Missing closing parenthesis".to_string());
561            }
562            return Ok(val);
563        }
564
565        // Number
566        if self.pos < self.input.len()
567            && (self.input[self.pos].is_ascii_digit() || self.input[self.pos] == b'.')
568        {
569            return self.parse_number();
570        }
571
572        // Identifier (function or constant)
573        if self.pos < self.input.len() && self.input[self.pos].is_ascii_alphabetic() {
574            let name = self.parse_ident();
575            return self.resolve_ident(&name);
576        }
577
578        Err(format!(
579            "Unexpected character at position {}",
580            self.pos
581        ))
582    }
583
584    fn parse_number(&mut self) -> Result<f64, String> {
585        let start = self.pos;
586        while self.pos < self.input.len()
587            && (self.input[self.pos].is_ascii_digit() || self.input[self.pos] == b'.')
588        {
589            self.pos += 1;
590        }
591        // Handle scientific notation: 1e10, 2.5e-3
592        if self.pos < self.input.len()
593            && (self.input[self.pos] == b'e' || self.input[self.pos] == b'E')
594        {
595            self.pos += 1;
596            if self.pos < self.input.len()
597                && (self.input[self.pos] == b'+' || self.input[self.pos] == b'-')
598            {
599                self.pos += 1;
600            }
601            while self.pos < self.input.len() && self.input[self.pos].is_ascii_digit() {
602                self.pos += 1;
603            }
604        }
605        let s = std::str::from_utf8(&self.input[start..self.pos])
606            .map_err(|_| "Invalid UTF-8 in number")?;
607        s.parse::<f64>()
608            .map_err(|_| format!("Invalid number: '{s}'"))
609    }
610
611    fn parse_ident(&mut self) -> String {
612        let start = self.pos;
613        while self.pos < self.input.len()
614            && (self.input[self.pos].is_ascii_alphanumeric() || self.input[self.pos] == b'_')
615        {
616            self.pos += 1;
617        }
618        String::from_utf8_lossy(&self.input[start..self.pos]).to_string()
619    }
620
621    fn resolve_ident(&mut self, name: &str) -> Result<f64, String> {
622        // Constants
623        match name {
624            "pi" | "PI" => return Ok(std::f64::consts::PI),
625            "e" | "E" => return Ok(std::f64::consts::E),
626            "tau" | "TAU" => return Ok(std::f64::consts::TAU),
627            "inf" => return Ok(f64::INFINITY),
628            _ => {}
629        }
630
631        // Functions
632        self.skip_ws();
633        if !self.consume(b'(') {
634            return Err(format!("Unknown identifier: '{name}'"));
635        }
636
637        let args = self.parse_args()?;
638
639        if !self.consume(b')') {
640            return Err(format!("Missing ')' after {name}(...)"));
641        }
642
643        match (name, args.len()) {
644            ("sqrt", 1) => Ok(args[0].sqrt()),
645            ("abs", 1) => Ok(args[0].abs()),
646            ("round", 1) => Ok(args[0].round()),
647            ("ceil", 1) => Ok(args[0].ceil()),
648            ("floor", 1) => Ok(args[0].floor()),
649            ("sin", 1) => Ok(args[0].sin()),
650            ("cos", 1) => Ok(args[0].cos()),
651            ("tan", 1) => Ok(args[0].tan()),
652            ("asin", 1) => Ok(args[0].asin()),
653            ("acos", 1) => Ok(args[0].acos()),
654            ("atan", 1) => Ok(args[0].atan()),
655            ("log", 1) | ("log10", 1) => Ok(args[0].log10()),
656            ("ln", 1) => Ok(args[0].ln()),
657            ("log2", 1) => Ok(args[0].log2()),
658            ("exp", 1) => Ok(args[0].exp()),
659            ("pow", 2) => Ok(args[0].powf(args[1])),
660            ("min", 2) => Ok(args[0].min(args[1])),
661            ("max", 2) => Ok(args[0].max(args[1])),
662            ("atan2", 2) => Ok(args[0].atan2(args[1])),
663            _ => Err(format!("Unknown function: '{name}' with {} args", args.len())),
664        }
665    }
666
667    fn parse_args(&mut self) -> Result<Vec<f64>, String> {
668        let mut args = Vec::new();
669        self.skip_ws();
670        if self.peek() == Some(b')') {
671            return Ok(args);
672        }
673        args.push(self.parse_expr()?);
674        while self.consume(b',') {
675            args.push(self.parse_expr()?);
676        }
677        Ok(args)
678    }
679}
680
681fn eval_expr(expr: &str) -> Result<f64, String> {
682    let mut parser = CalcParser::new(expr);
683    let result = parser.parse_expr()?;
684    parser.skip_ws();
685    if parser.pos < parser.input.len() {
686        return Err(format!(
687            "Unexpected trailing characters at position {}",
688            parser.pos
689        ));
690    }
691    if result.is_nan() {
692        return Err("Result is NaN".to_string());
693    }
694    Ok(result)
695}
696
697// ── DateTimeTool ────────────────────────────────────────────────────────────
698
699/// Current date/time queries using system time (UTC).
700///
701/// Supported queries: now, today, timestamp, year, month, day, weekday, iso,
702/// hour, minute, second, date, time.
703pub fn datetime_execute(query: &str) -> ToolResult {
704    let query = query.trim().to_lowercase();
705
706    let now = SystemTime::now()
707        .duration_since(UNIX_EPOCH)
708        .unwrap_or_default();
709
710    let secs = now.as_secs();
711    let (year, month, day, hour, min, sec, weekday) = unix_to_utc(secs);
712
713    let output = match query.as_str() {
714        "now" | "iso" | "datetime" => format!(
715            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
716            year, month, day, hour, min, sec
717        ),
718        "today" | "date" => format!("{:04}-{:02}-{:02}", year, month, day),
719        "time" => format!("{:02}:{:02}:{:02}Z", hour, min, sec),
720        "timestamp" | "unix" | "epoch" => format!("{}", secs),
721        "year" => format!("{}", year),
722        "month" => format!("{}", month),
723        "day" => format!("{}", day),
724        "hour" => format!("{}", hour),
725        "minute" => format!("{}", min),
726        "second" => format!("{}", sec),
727        "weekday" => weekday_name(weekday).to_string(),
728        _ => format!(
729            "Unknown query '{}'. Supported: now, today, timestamp, year, month, day, weekday, iso, time, hour, minute, second",
730            query
731        ),
732    };
733
734    ToolResult {
735        success: true,
736        output,
737        tool_name: "DateTimeTool".to_string(),
738    }
739}
740
741/// Convert UNIX timestamp to (year, month, day, hour, min, sec, weekday).
742/// weekday: 0=Sunday, 1=Monday, ..., 6=Saturday.
743fn unix_to_utc(secs: u64) -> (i32, u32, u32, u32, u32, u32, u32) {
744    let days = (secs / 86400) as i64;
745    let time_of_day = secs % 86400;
746
747    let hour = (time_of_day / 3600) as u32;
748    let min = ((time_of_day % 3600) / 60) as u32;
749    let sec = (time_of_day % 60) as u32;
750
751    // Weekday: Jan 1, 1970 was Thursday (4)
752    let weekday = ((days + 4) % 7) as u32;
753
754    // Civil date from days since epoch (algorithm from Howard Hinnant)
755    let z = days + 719468;
756    let era = if z >= 0 { z } else { z - 146096 } / 146097;
757    let doe = (z - era * 146097) as u32;
758    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
759    let y = (yoe as i64 + era * 400) as i32;
760    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
761    let mp = (5 * doy + 2) / 153;
762    let d = doy - (153 * mp + 2) / 5 + 1;
763    let m = if mp < 10 { mp + 3 } else { mp - 9 };
764    let year = if m <= 2 { y + 1 } else { y };
765
766    (year, m, d, hour, min, sec, weekday)
767}
768
769fn weekday_name(weekday: u32) -> &'static str {
770    match weekday {
771        0 => "Sunday",
772        1 => "Monday",
773        2 => "Tuesday",
774        3 => "Wednesday",
775        4 => "Thursday",
776        5 => "Friday",
777        6 => "Saturday",
778        _ => "Unknown",
779    }
780}
781
782// ── Tests ───────────────────────────────────────────────────────────────────
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787
788    // Calculator tests
789
790    #[test]
791    fn calc_basic_arithmetic() {
792        assert_eq!(eval_expr("2 + 3").unwrap(), 5.0);
793        assert_eq!(eval_expr("10 - 4").unwrap(), 6.0);
794        assert_eq!(eval_expr("3 * 7").unwrap(), 21.0);
795        assert_eq!(eval_expr("20 / 4").unwrap(), 5.0);
796    }
797
798    #[test]
799    fn calc_operator_precedence() {
800        assert_eq!(eval_expr("2 + 3 * 4").unwrap(), 14.0);
801        assert_eq!(eval_expr("(2 + 3) * 4").unwrap(), 20.0);
802    }
803
804    #[test]
805    fn calc_power() {
806        assert_eq!(eval_expr("2 ** 10").unwrap(), 1024.0);
807        assert_eq!(eval_expr("3 ** 2").unwrap(), 9.0);
808    }
809
810    #[test]
811    fn calc_modulo() {
812        assert_eq!(eval_expr("17 % 5").unwrap(), 2.0);
813    }
814
815    #[test]
816    fn calc_unary_minus() {
817        assert_eq!(eval_expr("-5").unwrap(), -5.0);
818        assert_eq!(eval_expr("-3 + 7").unwrap(), 4.0);
819        assert_eq!(eval_expr("-(2 + 3)").unwrap(), -5.0);
820    }
821
822    #[test]
823    fn calc_constants() {
824        assert!((eval_expr("pi").unwrap() - std::f64::consts::PI).abs() < 1e-10);
825        assert!((eval_expr("e").unwrap() - std::f64::consts::E).abs() < 1e-10);
826    }
827
828    #[test]
829    fn calc_functions() {
830        assert_eq!(eval_expr("sqrt(16)").unwrap(), 4.0);
831        assert_eq!(eval_expr("abs(-5)").unwrap(), 5.0);
832        assert_eq!(eval_expr("round(3.7)").unwrap(), 4.0);
833        assert_eq!(eval_expr("ceil(3.2)").unwrap(), 4.0);
834        assert_eq!(eval_expr("floor(3.8)").unwrap(), 3.0);
835        assert_eq!(eval_expr("pow(2, 8)").unwrap(), 256.0);
836        assert_eq!(eval_expr("min(3, 7)").unwrap(), 3.0);
837        assert_eq!(eval_expr("max(3, 7)").unwrap(), 7.0);
838    }
839
840    #[test]
841    fn calc_trig() {
842        assert!((eval_expr("sin(0)").unwrap()).abs() < 1e-10);
843        assert!((eval_expr("cos(0)").unwrap() - 1.0).abs() < 1e-10);
844    }
845
846    #[test]
847    fn calc_logarithm() {
848        assert!((eval_expr("log(100)").unwrap() - 2.0).abs() < 1e-10);
849        assert!((eval_expr("ln(e)").unwrap() - 1.0).abs() < 1e-10);
850    }
851
852    #[test]
853    fn calc_nested() {
854        assert_eq!(eval_expr("sqrt(pow(3, 2) + pow(4, 2))").unwrap(), 5.0);
855    }
856
857    #[test]
858    fn calc_scientific_notation() {
859        assert_eq!(eval_expr("1e3").unwrap(), 1000.0);
860        assert_eq!(eval_expr("2.5e2").unwrap(), 250.0);
861    }
862
863    #[test]
864    fn calc_division_by_zero() {
865        assert!(eval_expr("1 / 0").is_err());
866    }
867
868    #[test]
869    fn calc_empty_expression() {
870        let r = calculator_execute("");
871        assert!(!r.success);
872    }
873
874    #[test]
875    fn calc_invalid_expression() {
876        assert!(eval_expr("2 +").is_err());
877    }
878
879    #[test]
880    fn calc_integer_output() {
881        let r = calculator_execute("2 + 3");
882        assert!(r.success);
883        assert_eq!(r.output, "5");
884    }
885
886    #[test]
887    fn calc_float_output() {
888        let r = calculator_execute("1 / 3");
889        assert!(r.success);
890        assert!(r.output.starts_with("0.333"));
891    }
892
893    // DateTimeTool tests
894
895    #[test]
896    fn datetime_now_iso_format() {
897        let r = datetime_execute("now");
898        assert!(r.success);
899        assert!(r.output.contains('T'));
900        assert!(r.output.ends_with('Z'));
901    }
902
903    #[test]
904    fn datetime_today() {
905        let r = datetime_execute("today");
906        assert!(r.success);
907        assert_eq!(r.output.len(), 10); // YYYY-MM-DD
908        assert!(r.output.contains('-'));
909    }
910
911    #[test]
912    fn datetime_timestamp() {
913        let r = datetime_execute("timestamp");
914        assert!(r.success);
915        let ts: u64 = r.output.parse().expect("should be a number");
916        assert!(ts > 1700000000); // After ~2023
917    }
918
919    #[test]
920    fn datetime_year() {
921        let r = datetime_execute("year");
922        assert!(r.success);
923        let y: i32 = r.output.parse().expect("should be a number");
924        assert!(y >= 2024);
925    }
926
927    #[test]
928    fn datetime_weekday() {
929        let r = datetime_execute("weekday");
930        assert!(r.success);
931        let valid = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
932        assert!(valid.contains(&r.output.as_str()));
933    }
934
935    #[test]
936    fn datetime_unknown_query() {
937        let r = datetime_execute("foobar");
938        assert!(r.success);
939        assert!(r.output.contains("Unknown query"));
940    }
941
942    // Dispatch tests
943
944    #[test]
945    fn dispatch_calculator() {
946        let r = dispatch("Calculator", "2 + 2");
947        assert!(r.is_some());
948        let r = r.unwrap();
949        assert!(r.success);
950        assert_eq!(r.output, "4");
951    }
952
953    #[test]
954    fn dispatch_datetime() {
955        let r = dispatch("DateTimeTool", "now");
956        assert!(r.is_some());
957        assert!(r.unwrap().success);
958    }
959
960    #[test]
961    fn dispatch_unknown_tool() {
962        assert!(dispatch("WebSearch", "query").is_none());
963    }
964}