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