@@ -4,6 +4,7 @@
|
||||
!/.env.example
|
||||
/logs/
|
||||
/runtime/
|
||||
/notes/
|
||||
/dictionary/actions.jsonl
|
||||
/dictionary/actions.index.json
|
||||
/dictionary/model.codebook.json
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
- Start llama.cpp: `cargo run -- serve`
|
||||
- Query local model: `cargo run -- query "log hello from codex"`
|
||||
- Direct proof-of-concept dispatch: `cargo run -- run logger --payload '{"message":"hello from codex"}'`
|
||||
- Direct math dispatch: `cargo run -- run calculate --payload '{"expression":"2 + 2 * 3"}'`
|
||||
- Direct text dispatch: `cargo run -- run to_slug --payload '{"text":"Hello, World!"}'`
|
||||
- Direct file dispatch: `cargo run -- run write_note --payload '{"filename":"hello.txt","content":"hello"}'`
|
||||
- Codex setup: `scripts/codex-setup.sh`
|
||||
- Codex logger smoke test: `scripts/codex-run-logger.sh`
|
||||
- Precommit/CI check: `scripts/precommit-check.sh`
|
||||
@@ -38,6 +41,7 @@ Use `.env` for local values and keep it out of git. Update `.env.example`, `READ
|
||||
- `dictionary/actions.index.json` is the generated verbose runtime lookup file.
|
||||
- `dictionary/model.codebook.json` is the generated model-facing compact codebook.
|
||||
- `dictionary/model.examples.jsonl` is generated template/example data for future synthetic-data tooling.
|
||||
- Registered action suites are Math & Conversion, Text Utilities, File Operations, and Logger.
|
||||
- Regenerate dictionary files with `cargo run -- dictionary generate` after changing registered actions.
|
||||
- Do not manually edit generated dictionary files.
|
||||
- Generated metadata includes `dictionary_version`, `registry_checksum`, `static_base_checksum`, and `codebook_checksum`.
|
||||
@@ -50,12 +54,14 @@ Use `.env` for local values and keep it out of git. Update `.env.example`, `READ
|
||||
- Unknown, malformed, ambiguous, missing, or empty action ids must be rejected without running scripts.
|
||||
- Script processes are detached. The controller spawns them and does not monitor completion.
|
||||
- Keep script payload validation strict and local to the script module.
|
||||
- File operation scripts must remain confined to project-local `notes/` paths and reject absolute paths or path traversal.
|
||||
|
||||
## Generated Paths
|
||||
|
||||
- `target/`
|
||||
- `logs/`
|
||||
- `runtime/`
|
||||
- `notes/`
|
||||
- `dictionary/actions.jsonl`
|
||||
- `dictionary/actions.index.json`
|
||||
- `dictionary/model.codebook.json`
|
||||
|
||||
Generated
+108
-5
@@ -151,7 +151,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -283,6 +283,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "evalexpr"
|
||||
version = "12.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae893d2d5e908b78f151ed89de3bfc272cdf6d368c7ed866942f98e24dea208a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -386,7 +392,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -695,6 +701,16 @@ dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -720,14 +736,19 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"dotenvy",
|
||||
"evalexpr",
|
||||
"md-5",
|
||||
"rand 0.8.7",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"shell-words",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -781,6 +802,15 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
@@ -819,7 +849,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand 0.10.2",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
@@ -861,6 +891,17 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
@@ -869,7 +910,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -884,7 +944,7 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1079,6 +1139,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -1438,6 +1509,18 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@@ -1719,6 +1802,26 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
|
||||
@@ -9,13 +9,18 @@ anyhow = "1.0"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
dotenvy = "0.15"
|
||||
evalexpr = "12.0"
|
||||
md-5 = "0.10"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
shell-words = "1.1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { version = "1.11", features = ["v4", "serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
|
||||
@@ -21,6 +21,9 @@ cargo run -- dictionary generate
|
||||
cargo run -- serve
|
||||
cargo run -- query "log hello from codex"
|
||||
cargo run -- run logger --payload '{"message":"hello from direct dispatch"}'
|
||||
cargo run -- run calculate --payload '{"expression":"2 + 2 * 3"}'
|
||||
cargo run -- run to_slug --payload '{"text":"Hello, World!"}'
|
||||
cargo run -- run write_note --payload '{"filename":"hello.txt","content":"hello"}'
|
||||
scripts/precommit-check.sh
|
||||
```
|
||||
|
||||
@@ -67,7 +70,32 @@ Malformed JSON, missing action codes, stale versions/checksums, aliases, and unk
|
||||
|
||||
## Initial Script
|
||||
|
||||
The first registered script is `logger`. It writes JSONL records to `logs/controller-actions.jsonl`. Dispatch is detached: after the controller spawns the script process, it does not monitor completion.
|
||||
The first registered script was `logger`. It writes JSONL records to `logs/controller-actions.jsonl`. Dispatch is detached: after the controller spawns the script process, it does not monitor completion.
|
||||
|
||||
## Action Suites
|
||||
|
||||
Math & Conversion:
|
||||
|
||||
- `calculate`: `{ "expression": "2 + 2 * 3" }`
|
||||
- `convert_units`: `{ "value": 100, "from": "km", "to": "miles" }`
|
||||
- `generate_uuid`: `{}`
|
||||
- `random_number`: `{ "min": 1, "max": 100 }`
|
||||
|
||||
Text Utilities:
|
||||
|
||||
- `count_words`: `{ "text": "..." }`
|
||||
- `to_slug`: `{ "text": "..." }`
|
||||
- `hash_string`: `{ "text": "...", "algorithm": "sha256|md5|sha1" }`
|
||||
- `truncate_text`: `{ "text": "...", "max_chars": 100 }`
|
||||
|
||||
File Operations:
|
||||
|
||||
- `write_note`: `{ "filename": "...", "content": "..." }`
|
||||
- `append_to_file`: `{ "filename": "...", "content": "..." }`
|
||||
- `read_file`: `{ "filename": "..." }`
|
||||
- `delete_file`: `{ "filename": "..." }`
|
||||
|
||||
File operations are confined to project-local `notes/` and reject absolute paths or path traversal.
|
||||
|
||||
## CI And Hooks
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ Commands:
|
||||
- `cargo run -- serve`
|
||||
- `cargo run -- query "log hello from codex"`
|
||||
- `cargo run -- run logger --payload '{"message":"hello from codex"}'`
|
||||
- `cargo run -- run calculate --payload '{"expression":"2 + 2 * 3"}'`
|
||||
- `cargo run -- run to_slug --payload '{"text":"Hello, World!"}'`
|
||||
- `cargo run -- run write_note --payload '{"filename":"hello.txt","content":"hello"}'`
|
||||
- `scripts/codex-setup.sh`
|
||||
- `scripts/codex-run-logger.sh`
|
||||
- `scripts/precommit-check.sh`
|
||||
@@ -42,6 +45,15 @@ Environment:
|
||||
|
||||
The LLM may return verbose JSON like `{"action_id":"logger","payload":{"message":"hello"}}` or compact codebook JSON like `{"v":"1.0.0","c":"<codebook_checksum>","a":"A001","p":{"F001":"hello"}}`. Compact outputs must match `dictionary/model.codebook.json` version and checksum before expansion. The runtime only executes exact `action_id` matches from `dictionary/actions.index.json`; aliases are not executable. Invalid or unknown outputs are rejected without running anything.
|
||||
|
||||
Registered suites:
|
||||
|
||||
- Math & Conversion: `calculate`, `convert_units`, `generate_uuid`, `random_number`.
|
||||
- Text Utilities: `count_words`, `to_slug`, `hash_string`, `truncate_text`.
|
||||
- File Operations: `write_note`, `append_to_file`, `read_file`, `delete_file`.
|
||||
- Logger: `logger`.
|
||||
|
||||
File operations are sandboxed to project-local `notes/`.
|
||||
|
||||
Codex environment: `.codex/environments/environment.toml` defines setup plus Build, Test, Generate Dictionary, Precommit Check, Run Logger, Query Logger, and Serve Llama actions.
|
||||
|
||||
CI and hooks: `.gitea/workflows/ci.yml`, `.woodpecker.yml`, and `.githooks/pre-commit` all use `scripts/precommit-check.sh` to verify formatting, tests, and dictionary generation.
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
"serve": "cargo run -- serve",
|
||||
"query": "cargo run -- query \"log hello from codex\"",
|
||||
"run_logger": "cargo run -- run logger --payload '{\"message\":\"hello from codex\"}'",
|
||||
"run_calculate": "cargo run -- run calculate --payload '{\"expression\":\"2 + 2 * 3\"}'",
|
||||
"run_to_slug": "cargo run -- run to_slug --payload '{\"text\":\"Hello, World!\"}'",
|
||||
"run_write_note": "cargo run -- run write_note --payload '{\"filename\":\"hello.txt\",\"content\":\"hello\"}'",
|
||||
"codex_setup": "scripts/codex-setup.sh",
|
||||
"codex_logger_smoke": "scripts/codex-run-logger.sh",
|
||||
"precommit_check": "scripts/precommit-check.sh",
|
||||
@@ -108,8 +111,102 @@
|
||||
"runtime_policy": "Verbose outputs require exact action_id matches; compact outputs require matching version/checksum and exact codebook expansion before dispatch."
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"action_id": "calculate",
|
||||
"suite": "Math & Conversion",
|
||||
"payload_example": {
|
||||
"expression": "2 + 2 * 3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action_id": "convert_units",
|
||||
"suite": "Math & Conversion",
|
||||
"payload_example": {
|
||||
"value": 100,
|
||||
"from": "km",
|
||||
"to": "miles"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action_id": "generate_uuid",
|
||||
"suite": "Math & Conversion",
|
||||
"payload_example": {}
|
||||
},
|
||||
{
|
||||
"action_id": "random_number",
|
||||
"suite": "Math & Conversion",
|
||||
"payload_example": {
|
||||
"min": 1,
|
||||
"max": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"action_id": "count_words",
|
||||
"suite": "Text Utilities",
|
||||
"payload_example": {
|
||||
"text": "hello from codex"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action_id": "to_slug",
|
||||
"suite": "Text Utilities",
|
||||
"payload_example": {
|
||||
"text": "Hello, World!"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action_id": "hash_string",
|
||||
"suite": "Text Utilities",
|
||||
"payload_example": {
|
||||
"text": "hello",
|
||||
"algorithm": "sha256"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action_id": "truncate_text",
|
||||
"suite": "Text Utilities",
|
||||
"payload_example": {
|
||||
"text": "hello from codex",
|
||||
"max_chars": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"action_id": "write_note",
|
||||
"suite": "File Operations",
|
||||
"payload_example": {
|
||||
"filename": "hello.txt",
|
||||
"content": "hello"
|
||||
},
|
||||
"sandbox": "notes/"
|
||||
},
|
||||
{
|
||||
"action_id": "append_to_file",
|
||||
"suite": "File Operations",
|
||||
"payload_example": {
|
||||
"filename": "hello.txt",
|
||||
"content": " world"
|
||||
},
|
||||
"sandbox": "notes/"
|
||||
},
|
||||
{
|
||||
"action_id": "read_file",
|
||||
"suite": "File Operations",
|
||||
"payload_example": {
|
||||
"filename": "hello.txt"
|
||||
},
|
||||
"sandbox": "notes/"
|
||||
},
|
||||
{
|
||||
"action_id": "delete_file",
|
||||
"suite": "File Operations",
|
||||
"payload_example": {
|
||||
"filename": "hello.txt"
|
||||
},
|
||||
"sandbox": "notes/"
|
||||
},
|
||||
{
|
||||
"action_id": "logger",
|
||||
"suite": "Logger",
|
||||
"script": "src/scripts/logger.rs",
|
||||
"description": "Proof-of-concept script that writes structured JSONL log records.",
|
||||
"payload_example": {
|
||||
@@ -122,6 +219,7 @@
|
||||
"target/",
|
||||
"logs/",
|
||||
"runtime/",
|
||||
"notes/",
|
||||
"dictionary/actions.jsonl",
|
||||
"dictionary/actions.index.json",
|
||||
"dictionary/model.codebook.json",
|
||||
|
||||
+15
-8
@@ -426,9 +426,10 @@ mod tests {
|
||||
fn adaptive_layer_maps_logger_and_message_deterministically() {
|
||||
let records = sorted_records(®istered_actions());
|
||||
let adaptive = build_adaptive_codebook(&records);
|
||||
assert_eq!(adaptive.actions["A001"], "logger");
|
||||
assert_eq!(adaptive.fields["F001"], "message");
|
||||
assert_eq!(adaptive.modules["M001"], "logger");
|
||||
assert_eq!(adaptive.actions["A001"], "append_to_file");
|
||||
assert!(adaptive.actions.values().any(|value| value == "logger"));
|
||||
assert!(adaptive.fields.values().any(|value| value == "message"));
|
||||
assert!(adaptive.modules.values().any(|value| value == "logger"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -459,23 +460,29 @@ mod tests {
|
||||
let temp = temp_dictionary_dir();
|
||||
let summary = generate_dictionary_files(®istered_actions(), temp.path()).unwrap();
|
||||
|
||||
assert_eq!(summary.action_count, 1);
|
||||
assert_eq!(summary.action_count, 13);
|
||||
assert!(summary.jsonl_path.exists());
|
||||
assert!(summary.index_path.exists());
|
||||
assert!(summary.codebook_path.exists());
|
||||
assert!(summary.examples_path.exists());
|
||||
|
||||
let records = read_jsonl_records(&summary.jsonl_path).unwrap();
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].action_id, "logger");
|
||||
assert_eq!(records.len(), 13);
|
||||
assert!(records.iter().any(|record| record.action_id == "logger"));
|
||||
|
||||
let index = load_action_index(temp.path()).unwrap();
|
||||
assert!(index.actions.contains_key("logger"));
|
||||
assert!(!index.actions.contains_key("log"));
|
||||
|
||||
let codebook = load_model_codebook(temp.path()).unwrap();
|
||||
assert_eq!(codebook.adaptive.actions["A001"], "logger");
|
||||
assert_eq!(codebook.adaptive.fields["F001"], "message");
|
||||
assert_eq!(codebook.adaptive.actions["A001"], "append_to_file");
|
||||
assert!(
|
||||
codebook
|
||||
.adaptive
|
||||
.fields
|
||||
.values()
|
||||
.any(|value| value == "message")
|
||||
);
|
||||
assert!(!codebook.codebook_checksum.is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,19 @@ use std::fs::{self, OpenOptions};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::dictionary::ActionIndex;
|
||||
use crate::scripts::file_ops::{
|
||||
FilePayload, WriteFilePayload, run_append_to_file, run_delete_file, run_read_file,
|
||||
run_write_note,
|
||||
};
|
||||
use crate::scripts::logger::{LoggerPayload, run_logger};
|
||||
use crate::scripts::math::{
|
||||
CalculatePayload, ConvertUnitsPayload, GenerateUuidPayload, RandomNumberPayload, run_calculate,
|
||||
run_convert_units, run_generate_uuid, run_random_number,
|
||||
};
|
||||
use crate::scripts::text::{
|
||||
CountWordsPayload, HashStringPayload, ToSlugPayload, TruncateTextPayload, run_count_words,
|
||||
run_hash_string, run_to_slug, run_truncate_text,
|
||||
};
|
||||
|
||||
pub fn dispatch_action(index: &ActionIndex, action_id: &str, payload: Value) -> Result<()> {
|
||||
let action = index
|
||||
@@ -46,6 +58,54 @@ pub fn run_internal_script(action_id: &str, payload: Value) -> Result<()> {
|
||||
let payload = serde_json::from_value::<LoggerPayload>(payload)?;
|
||||
run_logger(payload)
|
||||
}
|
||||
"calculate" => {
|
||||
let payload = serde_json::from_value::<CalculatePayload>(payload)?;
|
||||
run_calculate(payload)
|
||||
}
|
||||
"convert_units" => {
|
||||
let payload = serde_json::from_value::<ConvertUnitsPayload>(payload)?;
|
||||
run_convert_units(payload)
|
||||
}
|
||||
"generate_uuid" => {
|
||||
let payload = serde_json::from_value::<GenerateUuidPayload>(payload)?;
|
||||
run_generate_uuid(payload)
|
||||
}
|
||||
"random_number" => {
|
||||
let payload = serde_json::from_value::<RandomNumberPayload>(payload)?;
|
||||
run_random_number(payload)
|
||||
}
|
||||
"count_words" => {
|
||||
let payload = serde_json::from_value::<CountWordsPayload>(payload)?;
|
||||
run_count_words(payload)
|
||||
}
|
||||
"to_slug" => {
|
||||
let payload = serde_json::from_value::<ToSlugPayload>(payload)?;
|
||||
run_to_slug(payload)
|
||||
}
|
||||
"hash_string" => {
|
||||
let payload = serde_json::from_value::<HashStringPayload>(payload)?;
|
||||
run_hash_string(payload)
|
||||
}
|
||||
"truncate_text" => {
|
||||
let payload = serde_json::from_value::<TruncateTextPayload>(payload)?;
|
||||
run_truncate_text(payload)
|
||||
}
|
||||
"write_note" => {
|
||||
let payload = serde_json::from_value::<WriteFilePayload>(payload)?;
|
||||
run_write_note(payload)
|
||||
}
|
||||
"append_to_file" => {
|
||||
let payload = serde_json::from_value::<WriteFilePayload>(payload)?;
|
||||
run_append_to_file(payload)
|
||||
}
|
||||
"read_file" => {
|
||||
let payload = serde_json::from_value::<FilePayload>(payload)?;
|
||||
run_read_file(payload)
|
||||
}
|
||||
"delete_file" => {
|
||||
let payload = serde_json::from_value::<FilePayload>(payload)?;
|
||||
run_delete_file(payload)
|
||||
}
|
||||
other => bail!("unknown internal script `{other}`"),
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -227,9 +227,21 @@ mod tests {
|
||||
#[test]
|
||||
fn expands_valid_compact_action_response() {
|
||||
let codebook = test_codebook();
|
||||
let logger_code = codebook
|
||||
.adaptive
|
||||
.actions
|
||||
.iter()
|
||||
.find_map(|(code, action)| (action == "logger").then_some(code.as_str()))
|
||||
.unwrap();
|
||||
let message_code = codebook
|
||||
.adaptive
|
||||
.fields
|
||||
.iter()
|
||||
.find_map(|(code, field)| (field == "message").then_some(code.as_str()))
|
||||
.unwrap();
|
||||
let raw = format!(
|
||||
r#"{{"v":"{}","c":"{}","a":"A001","p":{{"F001":"hello"}}}}"#,
|
||||
codebook.dictionary_version, codebook.codebook_checksum
|
||||
r#"{{"v":"{}","c":"{}","a":"{}","p":{{"{}":"hello"}}}}"#,
|
||||
codebook.dictionary_version, codebook.codebook_checksum, logger_code, message_code
|
||||
);
|
||||
let parsed = parse_action_response(&raw, &codebook).unwrap();
|
||||
assert_eq!(parsed.action_id, "logger");
|
||||
|
||||
+266
-26
@@ -18,33 +18,273 @@ pub struct ExecutableDefinition {
|
||||
}
|
||||
|
||||
pub fn registered_actions() -> Vec<ActionDefinition> {
|
||||
vec![ActionDefinition {
|
||||
action_id: "logger".to_string(),
|
||||
aliases: vec![
|
||||
"log".to_string(),
|
||||
"write_log".to_string(),
|
||||
"record_message".to_string(),
|
||||
],
|
||||
description: "Write a structured proof-of-concept log message.".to_string(),
|
||||
intent_examples: vec![
|
||||
"log hello from codex".to_string(),
|
||||
"record this message".to_string(),
|
||||
"write a logger proof of concept entry".to_string(),
|
||||
],
|
||||
payload_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"required": ["message"],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Message text to write into logs/controller-actions.jsonl."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
vec![
|
||||
action(
|
||||
"append_to_file",
|
||||
&["append", "append_file", "add_to_file"],
|
||||
"Append content to a project-local notes file.",
|
||||
&[
|
||||
"append this line to notes.txt",
|
||||
"add content to a file",
|
||||
"append to a project note",
|
||||
],
|
||||
object_schema(
|
||||
&[
|
||||
("filename", string_schema("Relative notes/ filename.")),
|
||||
("content", string_schema("Content to append.")),
|
||||
],
|
||||
&["filename", "content"],
|
||||
),
|
||||
),
|
||||
action(
|
||||
"calculate",
|
||||
&["calc", "evaluate_expression", "math"],
|
||||
"Evaluate a simple math expression.",
|
||||
&[
|
||||
"calculate 2 + 2 * 3",
|
||||
"evaluate this expression",
|
||||
"what is 10 / 2 plus 8",
|
||||
],
|
||||
object_schema(
|
||||
&[("expression", string_schema("Math expression to evaluate."))],
|
||||
&["expression"],
|
||||
),
|
||||
),
|
||||
action(
|
||||
"convert_units",
|
||||
&["convert", "unit_conversion", "convert_measurement"],
|
||||
"Convert a numeric distance value between supported units.",
|
||||
&[
|
||||
"convert 100 km to miles",
|
||||
"convert meters to feet",
|
||||
"unit conversion for 12 inches to cm",
|
||||
],
|
||||
object_schema(
|
||||
&[
|
||||
("value", number_schema("Numeric value to convert.")),
|
||||
("from", string_schema("Source unit.")),
|
||||
("to", string_schema("Target unit.")),
|
||||
],
|
||||
&["value", "from", "to"],
|
||||
),
|
||||
),
|
||||
action(
|
||||
"count_words",
|
||||
&["word_count", "count_text_words"],
|
||||
"Count whitespace-delimited words in text.",
|
||||
&[
|
||||
"count words in this text",
|
||||
"how many words are here",
|
||||
"word count this sentence",
|
||||
],
|
||||
object_schema(
|
||||
&[("text", string_schema("Text to count words in."))],
|
||||
&["text"],
|
||||
),
|
||||
),
|
||||
action(
|
||||
"delete_file",
|
||||
&["delete_note", "remove_file", "remove_note"],
|
||||
"Delete a project-local notes file.",
|
||||
&[
|
||||
"delete note.txt",
|
||||
"remove this note file",
|
||||
"delete a project note",
|
||||
],
|
||||
object_schema(
|
||||
&[("filename", string_schema("Relative notes/ filename."))],
|
||||
&["filename"],
|
||||
),
|
||||
),
|
||||
action(
|
||||
"generate_uuid",
|
||||
&["uuid", "new_uuid", "create_uuid"],
|
||||
"Generate a random UUID v4.",
|
||||
&[
|
||||
"generate a uuid",
|
||||
"create a new uuid",
|
||||
"give me a random uuid",
|
||||
],
|
||||
object_schema(&[], &[]),
|
||||
),
|
||||
action(
|
||||
"hash_string",
|
||||
&["hash", "digest_string", "string_hash"],
|
||||
"Hash text with sha256, md5, or sha1.",
|
||||
&[
|
||||
"hash hello with sha256",
|
||||
"md5 this string",
|
||||
"sha1 digest for this text",
|
||||
],
|
||||
object_schema(
|
||||
&[
|
||||
("text", string_schema("Text to hash.")),
|
||||
(
|
||||
"algorithm",
|
||||
enum_schema("Hash algorithm.", &["sha256", "md5", "sha1"]),
|
||||
),
|
||||
],
|
||||
&["text", "algorithm"],
|
||||
),
|
||||
),
|
||||
action(
|
||||
"logger",
|
||||
&["log", "write_log", "record_message"],
|
||||
"Write a structured proof-of-concept log message.",
|
||||
&[
|
||||
"log hello from codex",
|
||||
"record this message",
|
||||
"write a logger proof of concept entry",
|
||||
],
|
||||
object_schema(
|
||||
&[(
|
||||
"message",
|
||||
string_schema("Message text to write into logs/controller-actions.jsonl."),
|
||||
)],
|
||||
&["message"],
|
||||
),
|
||||
),
|
||||
action(
|
||||
"random_number",
|
||||
&["random_int", "roll_number", "number_in_range"],
|
||||
"Generate a random integer in an inclusive range.",
|
||||
&[
|
||||
"random number between 1 and 100",
|
||||
"roll a number in this range",
|
||||
"pick an integer from 5 to 10",
|
||||
],
|
||||
object_schema(
|
||||
&[
|
||||
("min", integer_schema("Inclusive minimum.")),
|
||||
("max", integer_schema("Inclusive maximum.")),
|
||||
],
|
||||
&["min", "max"],
|
||||
),
|
||||
),
|
||||
action(
|
||||
"read_file",
|
||||
&["read_note", "open_file", "show_file"],
|
||||
"Read a project-local notes file.",
|
||||
&[
|
||||
"read note.txt",
|
||||
"show the contents of this note",
|
||||
"open a project note file",
|
||||
],
|
||||
object_schema(
|
||||
&[("filename", string_schema("Relative notes/ filename."))],
|
||||
&["filename"],
|
||||
),
|
||||
),
|
||||
action(
|
||||
"to_slug",
|
||||
&["slugify", "make_slug", "url_slug"],
|
||||
"Convert text to a URL-safe ASCII slug.",
|
||||
&[
|
||||
"slugify this title",
|
||||
"make a url slug",
|
||||
"convert text to a slug",
|
||||
],
|
||||
object_schema(&[("text", string_schema("Text to slugify."))], &["text"]),
|
||||
),
|
||||
action(
|
||||
"truncate_text",
|
||||
&["truncate", "shorten_text", "clip_text"],
|
||||
"Truncate text to a maximum character count.",
|
||||
&[
|
||||
"truncate this to 100 characters",
|
||||
"shorten this text",
|
||||
"clip text to a max length",
|
||||
],
|
||||
object_schema(
|
||||
&[
|
||||
("text", string_schema("Text to truncate.")),
|
||||
("max_chars", integer_schema("Maximum number of characters.")),
|
||||
],
|
||||
&["text", "max_chars"],
|
||||
),
|
||||
),
|
||||
action(
|
||||
"write_note",
|
||||
&["write_file", "save_note", "create_note"],
|
||||
"Create or overwrite a project-local notes file.",
|
||||
&[
|
||||
"write a note file",
|
||||
"save this note",
|
||||
"create a text file with this content",
|
||||
],
|
||||
object_schema(
|
||||
&[
|
||||
("filename", string_schema("Relative notes/ filename.")),
|
||||
("content", string_schema("Content to write.")),
|
||||
],
|
||||
&["filename", "content"],
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn action(
|
||||
action_id: &str,
|
||||
aliases: &[&str],
|
||||
description: &str,
|
||||
intent_examples: &[&str],
|
||||
payload_schema: Value,
|
||||
) -> ActionDefinition {
|
||||
ActionDefinition {
|
||||
action_id: action_id.to_string(),
|
||||
aliases: aliases.iter().map(|alias| alias.to_string()).collect(),
|
||||
description: description.to_string(),
|
||||
intent_examples: intent_examples
|
||||
.iter()
|
||||
.map(|example| example.to_string())
|
||||
.collect(),
|
||||
payload_schema,
|
||||
executable: ExecutableDefinition {
|
||||
kind: "internal-rust-script".to_string(),
|
||||
script: "logger".to_string(),
|
||||
script: action_id.to_string(),
|
||||
},
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
fn object_schema(properties: &[(&str, Value)], required: &[&str]) -> Value {
|
||||
let properties = properties
|
||||
.iter()
|
||||
.map(|(name, schema)| ((*name).to_string(), schema.clone()))
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"required": required,
|
||||
"properties": properties,
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn string_schema(description: &str) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "string",
|
||||
"description": description
|
||||
})
|
||||
}
|
||||
|
||||
fn number_schema(description: &str) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "number",
|
||||
"description": description
|
||||
})
|
||||
}
|
||||
|
||||
fn integer_schema(description: &str) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "integer",
|
||||
"description": description
|
||||
})
|
||||
}
|
||||
|
||||
fn enum_schema(description: &str, values: &[&str]) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "string",
|
||||
"description": description,
|
||||
"enum": values
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FilePayload {
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WriteFilePayload {
|
||||
pub filename: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub fn run_write_note(payload: WriteFilePayload) -> Result<()> {
|
||||
let path = write_note(payload)?;
|
||||
print_json(
|
||||
"write_note",
|
||||
serde_json::json!({ "path": path.display().to_string() }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run_append_to_file(payload: WriteFilePayload) -> Result<()> {
|
||||
let path = append_to_file(payload)?;
|
||||
print_json(
|
||||
"append_to_file",
|
||||
serde_json::json!({ "path": path.display().to_string() }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run_read_file(payload: FilePayload) -> Result<()> {
|
||||
let contents = read_file(payload)?;
|
||||
print_json("read_file", serde_json::json!({ "content": contents }))
|
||||
}
|
||||
|
||||
pub fn run_delete_file(payload: FilePayload) -> Result<()> {
|
||||
let path = delete_file(payload)?;
|
||||
print_json(
|
||||
"delete_file",
|
||||
serde_json::json!({ "path": path.display().to_string() }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn write_note(payload: WriteFilePayload) -> Result<PathBuf> {
|
||||
let path = safe_notes_path(&payload.filename)?;
|
||||
ensure_parent(&path)?;
|
||||
fs::write(&path, payload.content).with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn append_to_file(payload: WriteFilePayload) -> Result<PathBuf> {
|
||||
let path = safe_notes_path(&payload.filename)?;
|
||||
ensure_parent(&path)?;
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.with_context(|| format!("opening {}", path.display()))?;
|
||||
file.write_all(payload.content.as_bytes())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn read_file(payload: FilePayload) -> Result<String> {
|
||||
let path = safe_notes_path(&payload.filename)?;
|
||||
fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))
|
||||
}
|
||||
|
||||
pub fn delete_file(payload: FilePayload) -> Result<PathBuf> {
|
||||
let path = safe_notes_path(&payload.filename)?;
|
||||
fs::remove_file(&path).with_context(|| format!("deleting {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn safe_notes_path(filename: &str) -> Result<PathBuf> {
|
||||
let trimmed = filename.trim();
|
||||
if trimmed.is_empty() {
|
||||
bail!("filename must not be empty");
|
||||
}
|
||||
|
||||
let relative = Path::new(trimmed);
|
||||
if relative.is_absolute() {
|
||||
bail!("filename must be relative to notes/");
|
||||
}
|
||||
|
||||
for component in relative.components() {
|
||||
match component {
|
||||
Component::Normal(_) => {}
|
||||
_ => bail!("filename must not contain path traversal or special components"),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PathBuf::from("notes").join(relative))
|
||||
}
|
||||
|
||||
fn ensure_parent(path: &Path) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_json(action_id: &str, payload: serde_json::Value) -> Result<()> {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"action_id": action_id,
|
||||
"output": payload
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_paths() {
|
||||
assert!(safe_notes_path("../secret.txt").is_err());
|
||||
assert!(safe_notes_path("/tmp/secret.txt").is_err());
|
||||
assert!(safe_notes_path("").is_err());
|
||||
assert_eq!(
|
||||
safe_notes_path("daily/note.txt").unwrap(),
|
||||
PathBuf::from("notes/daily/note.txt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_appends_reads_and_deletes_note() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let previous = std::env::current_dir().unwrap();
|
||||
std::env::set_current_dir(temp.path()).unwrap();
|
||||
|
||||
write_note(WriteFilePayload {
|
||||
filename: "test.txt".to_string(),
|
||||
content: "hello".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
append_to_file(WriteFilePayload {
|
||||
filename: "test.txt".to_string(),
|
||||
content: " world".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
read_file(FilePayload {
|
||||
filename: "test.txt".to_string()
|
||||
})
|
||||
.unwrap(),
|
||||
"hello world"
|
||||
);
|
||||
delete_file(FilePayload {
|
||||
filename: "test.txt".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!Path::new("notes/test.txt").exists());
|
||||
|
||||
std::env::set_current_dir(previous).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
use anyhow::{Result, bail};
|
||||
use evalexpr::eval;
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CalculatePayload {
|
||||
pub expression: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ConvertUnitsPayload {
|
||||
pub value: f64,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GenerateUuidPayload {}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RandomNumberPayload {
|
||||
pub min: i64,
|
||||
pub max: i64,
|
||||
}
|
||||
|
||||
pub fn run_calculate(payload: CalculatePayload) -> Result<()> {
|
||||
let result = calculate(payload)?;
|
||||
print_json("calculate", serde_json::json!({ "result": result }))
|
||||
}
|
||||
|
||||
pub fn run_convert_units(payload: ConvertUnitsPayload) -> Result<()> {
|
||||
let result = convert_units(payload)?;
|
||||
print_json("convert_units", result)
|
||||
}
|
||||
|
||||
pub fn run_generate_uuid(_payload: GenerateUuidPayload) -> Result<()> {
|
||||
print_json(
|
||||
"generate_uuid",
|
||||
serde_json::json!({ "uuid": Uuid::new_v4().to_string() }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run_random_number(payload: RandomNumberPayload) -> Result<()> {
|
||||
let number = random_number(payload)?;
|
||||
print_json("random_number", serde_json::json!({ "number": number }))
|
||||
}
|
||||
|
||||
pub fn calculate(payload: CalculatePayload) -> Result<Value> {
|
||||
let expression = payload.expression.trim();
|
||||
if expression.is_empty() {
|
||||
bail!("expression must not be empty");
|
||||
}
|
||||
|
||||
let result = eval(expression)?;
|
||||
Ok(match result {
|
||||
evalexpr::Value::Int(value) => serde_json::json!(value),
|
||||
evalexpr::Value::Float(value) => serde_json::json!(value),
|
||||
evalexpr::Value::Boolean(value) => serde_json::json!(value),
|
||||
evalexpr::Value::String(value) => serde_json::json!(value),
|
||||
other => serde_json::json!(other.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn convert_units(payload: ConvertUnitsPayload) -> Result<Value> {
|
||||
let from = normalize_unit(&payload.from);
|
||||
let to = normalize_unit(&payload.to);
|
||||
let meters = to_meters(payload.value, &from)?;
|
||||
let converted = from_meters(meters, &to)?;
|
||||
Ok(serde_json::json!({
|
||||
"value": converted,
|
||||
"from": payload.from,
|
||||
"to": payload.to
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn random_number(payload: RandomNumberPayload) -> Result<i64> {
|
||||
if payload.min > payload.max {
|
||||
bail!("min must be less than or equal to max");
|
||||
}
|
||||
Ok(rand::thread_rng().gen_range(payload.min..=payload.max))
|
||||
}
|
||||
|
||||
fn normalize_unit(unit: &str) -> String {
|
||||
unit.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn to_meters(value: f64, unit: &str) -> Result<f64> {
|
||||
match unit {
|
||||
"m" | "meter" | "meters" => Ok(value),
|
||||
"km" | "kilometer" | "kilometers" => Ok(value * 1000.0),
|
||||
"mi" | "mile" | "miles" => Ok(value * 1609.344),
|
||||
"ft" | "foot" | "feet" => Ok(value * 0.3048),
|
||||
"in" | "inch" | "inches" => Ok(value * 0.0254),
|
||||
"cm" | "centimeter" | "centimeters" => Ok(value * 0.01),
|
||||
"mm" | "millimeter" | "millimeters" => Ok(value * 0.001),
|
||||
other => bail!("unsupported source unit `{other}`"),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_meters(value: f64, unit: &str) -> Result<f64> {
|
||||
match unit {
|
||||
"m" | "meter" | "meters" => Ok(value),
|
||||
"km" | "kilometer" | "kilometers" => Ok(value / 1000.0),
|
||||
"mi" | "mile" | "miles" => Ok(value / 1609.344),
|
||||
"ft" | "foot" | "feet" => Ok(value / 0.3048),
|
||||
"in" | "inch" | "inches" => Ok(value / 0.0254),
|
||||
"cm" | "centimeter" | "centimeters" => Ok(value / 0.01),
|
||||
"mm" | "millimeter" | "millimeters" => Ok(value / 0.001),
|
||||
other => bail!("unsupported target unit `{other}`"),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_json(action_id: &str, payload: Value) -> Result<()> {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"action_id": action_id,
|
||||
"output": payload
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn evaluates_math_expression() {
|
||||
let result = calculate(CalculatePayload {
|
||||
expression: "2 + 2 * 3".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result, serde_json::json!(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_km_to_miles() {
|
||||
let result = convert_units(ConvertUnitsPayload {
|
||||
value: 100.0,
|
||||
from: "km".to_string(),
|
||||
to: "miles".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
let converted = result["value"].as_f64().unwrap();
|
||||
assert!((converted - 62.137119).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_random_range() {
|
||||
assert!(random_number(RandomNumberPayload { min: 1, max: 3 }).unwrap() >= 1);
|
||||
assert!(random_number(RandomNumberPayload { min: 5, max: 1 }).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1 +1,4 @@
|
||||
pub mod file_ops;
|
||||
pub mod logger;
|
||||
pub mod math;
|
||||
pub mod text;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
use anyhow::{Result, bail};
|
||||
use md5::Md5;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct CountWordsPayload {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ToSlugPayload {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct HashStringPayload {
|
||||
pub text: String,
|
||||
pub algorithm: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TruncateTextPayload {
|
||||
pub text: String,
|
||||
pub max_chars: usize,
|
||||
}
|
||||
|
||||
pub fn run_count_words(payload: CountWordsPayload) -> Result<()> {
|
||||
print_json(
|
||||
"count_words",
|
||||
serde_json::json!({ "word_count": count_words(&payload.text) }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run_to_slug(payload: ToSlugPayload) -> Result<()> {
|
||||
print_json(
|
||||
"to_slug",
|
||||
serde_json::json!({ "slug": to_slug(&payload.text) }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn run_hash_string(payload: HashStringPayload) -> Result<()> {
|
||||
let hash = hash_string(payload)?;
|
||||
print_json("hash_string", serde_json::json!({ "hash": hash }))
|
||||
}
|
||||
|
||||
pub fn run_truncate_text(payload: TruncateTextPayload) -> Result<()> {
|
||||
let truncated = truncate_text(payload)?;
|
||||
print_json(
|
||||
"truncate_text",
|
||||
serde_json::json!({ "text": truncated.text, "truncated": truncated.truncated }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn count_words(text: &str) -> usize {
|
||||
text.split_whitespace()
|
||||
.filter(|word| !word.is_empty())
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn to_slug(text: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut last_was_dash = false;
|
||||
|
||||
for ch in text.chars().flat_map(char::to_lowercase) {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch);
|
||||
last_was_dash = false;
|
||||
} else if !last_was_dash && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
|
||||
slug.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
pub fn hash_string(payload: HashStringPayload) -> Result<String> {
|
||||
match payload.algorithm.trim().to_ascii_lowercase().as_str() {
|
||||
"sha256" => Ok(hex_digest(
|
||||
Sha256::digest(payload.text.as_bytes()).as_slice(),
|
||||
)),
|
||||
"sha1" => Ok(hex_digest(Sha1::digest(payload.text.as_bytes()).as_slice())),
|
||||
"md5" => Ok(hex_digest(Md5::digest(payload.text.as_bytes()).as_slice())),
|
||||
other => bail!("unsupported hash algorithm `{other}`"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TruncatedText {
|
||||
pub text: String,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
pub fn truncate_text(payload: TruncateTextPayload) -> Result<TruncatedText> {
|
||||
if payload.max_chars == 0 {
|
||||
bail!("max_chars must be greater than zero");
|
||||
}
|
||||
|
||||
let mut chars = payload.text.chars();
|
||||
let text = chars.by_ref().take(payload.max_chars).collect::<String>();
|
||||
let truncated = chars.next().is_some();
|
||||
Ok(TruncatedText { text, truncated })
|
||||
}
|
||||
|
||||
fn hex_digest(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn print_json(action_id: &str, payload: serde_json::Value) -> Result<()> {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"action_id": action_id,
|
||||
"output": payload
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn counts_words() {
|
||||
assert_eq!(count_words("hello world\nfrom codex"), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_slug() {
|
||||
assert_eq!(to_slug("Hello, World! Rust 2026"), "hello-world-rust-2026");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_supported_algorithms() {
|
||||
assert_eq!(
|
||||
hash_string(HashStringPayload {
|
||||
text: "hello".to_string(),
|
||||
algorithm: "sha256".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
);
|
||||
assert_eq!(
|
||||
hash_string(HashStringPayload {
|
||||
text: "hello".to_string(),
|
||||
algorithm: "md5".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"5d41402abc4b2a76b9719d911017c592"
|
||||
);
|
||||
assert_eq!(
|
||||
hash_string(HashStringPayload {
|
||||
text: "hello".to_string(),
|
||||
algorithm: "sha1".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
"aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_by_chars() {
|
||||
let truncated = truncate_text(TruncateTextPayload {
|
||||
text: "abcdef".to_string(),
|
||||
max_chars: 3,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(truncated.text, "abc");
|
||||
assert!(truncated.truncated);
|
||||
assert!(
|
||||
truncate_text(TruncateTextPayload {
|
||||
text: "abc".to_string(),
|
||||
max_chars: 0,
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user