Files
boxmaker/docs/plans/M3b/04-toolkit-files.md
T
kyleandClaude Opus 5.5 5e55fe4c66 M3b plan: every task's git add includes Cargo.lock
Task 04 added dependencies to toolkit and its git add line left out the lock
file, so the driver stopped on an unclean tree. The lock change is folded into
task 04's commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 00:00:12 -07:00

152 lines
6.5 KiB
Markdown

# M3b task 04: the `toolkit` program, `read_file` and `write_file`
**Branch:** `m3b` (run `git switch m3b`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `toolkit: the tool program, read_file and write_file`
## Goal
`toolkit` is the one binary inside a tool container. `brokerd` will run `toolkit <tool>` with the
tool's arguments as JSON on standard input (the `proto::tools` structs from task 01). `toolkit`
prints what the model should see on standard output and exits 0 (done), 1 (the tool could not do
it, for a reason the model should read) or 2 (it was run wrongly). It decides nothing: whether the
call may run was decided before the container existed. Spec section 4.
This task makes the program and its first two tools. `shell` and `http_fetch` come in tasks 05 and
06; until then they are unknown tools (exit 2).
## Files
- Copy: `crates/toolkit/tests/support/mod.rs`, `crates/toolkit/tests/files.rs`
- Create: `crates/toolkit/src/input.rs`, `crates/toolkit/src/files.rs`
- Replace: `crates/toolkit/src/lib.rs`, `crates/toolkit/src/main.rs` (both are stubs today)
- Modify: `crates/toolkit/Cargo.toml`, `docs/dependencies.md`, `docs/implementer-log.md`
`Cargo.toml`: under `[dependencies]`, after `proto.workspace = true`, add
`serde.workspace = true` and `serde_json.workspace = true`, each on its own line.
`docs/dependencies.md`: in the `serde` and `serde_json` rows, the "Used by" column becomes
`` `proto`, `brokerd`, `toolkit` ``.
## Interfaces
`lib.rs` (module doc: the programs that run inside tool containers):
```rust
pub mod files;
pub mod input;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Exit { Done, ToolError, Misuse }
impl Exit {
/// 0, 1, 2. No `as` cast: a `match`.
pub fn code(self) -> u8;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome { pub exit: Exit, pub stdout: String, pub stderr: String }
impl Outcome {
pub fn done(stdout: String) -> Outcome; // Done, stderr empty
pub fn tool_error(stdout: String) -> Outcome; // ToolError, stderr empty
pub fn misuse(stderr: String) -> Outcome; // Misuse, stdout EMPTY: the model sees nothing
}
/// Run tool `name` with the arguments on `stdin`.
pub fn run(name: &str, stdin: &mut dyn std::io::Read) -> Outcome;
```
`run`:
1. `input::read_input(stdin)`; an error `e` → `Outcome::misuse(format!("toolkit: {e}"))`.
2. By `name`: `"read_file"` → `input::parse::<ReadFileArgs>(name, &text)` then
`files::read_file(&args)`; `"write_file"` → the same with `WriteFileArgs` and
`files::write_file`. A parse error `e` (already a whole line) → `Outcome::misuse(e)`.
3. Any other name → `Outcome::misuse(format!("toolkit: unknown tool {name:?}"))`.
The input is read **before** the name is looked at, for every name.
`input.rs`:
```rust
pub const MAX_INPUT: usize = 2 * 1024 * 1024;
#[derive(Debug)]
pub enum InputError { TooLarge, NotUtf8, Io(std::io::Error) }
// Display, by hand:
// TooLarge → "the arguments are larger than 2097152 bytes" (write it with {MAX_INPUT})
// NotUtf8 → "the arguments are not UTF-8"
// Io(e) → "cannot read the arguments: {e}"
/// All of `stdin`, at most MAX_INPUT bytes, as UTF-8. Read at most MAX_INPUT + 1 bytes
/// (`Read::take`); more than MAX_INPUT is TooLarge.
pub fn read_input(stdin: &mut dyn std::io::Read) -> Result<String, InputError>;
/// Parse `text` as the arguments of tool `name`; the error is the whole line for stderr:
/// "toolkit: {name}: the arguments do not parse: {serde's error}".
pub fn parse<T: serde::de::DeserializeOwned>(name: &str, text: &str) -> Result<T, String>;
```
`files.rs`:
```rust
pub const MAX_READ: usize = 1024 * 1024;
pub fn read_file(args: &ReadFileArgs) -> Outcome;
pub fn write_file(args: &WriteFileArgs) -> Outcome;
```
## `read_file`: every exit
Every failure is `Outcome::tool_error(format!("read_file: {path}: {why}"))`, one line, no newline
at the end.
1. `std::fs::metadata(path)` fails with `ErrorKind::NotFound` → why = `no such file`.
2. It fails any other way → why = the error's `Display` text.
3. It is a directory → why = `is a directory`.
4. Opening fails → why = the error's text.
5. Read at most `MAX_READ + 1` bytes (`take`). Reading fails → the error's text. More than
`MAX_READ` bytes → why = `larger than 1048576 bytes` (write it with `{MAX_READ}`). Exactly
`MAX_READ` is fine.
6. Not UTF-8 → why = `not UTF-8 text`.
7. Otherwise `Outcome::done(text)`: the content exactly, nothing added.
## `write_file`: every exit
Every failure is `Outcome::tool_error(format!("write_file: {path}: {why}"))`.
1. The path's parent is not an existing directory (`Path::parent` is `None`, or not `is_dir()`) →
why = `the directory does not exist`. **No directory is ever created.**
2. The path is a directory → why = `is a directory`.
3. `std::fs::write(path, content)` fails → why = the error's text.
4. Otherwise `Outcome::done(format!("wrote {} bytes to {path}", content.len()))` (bytes, not
characters).
## `main.rs`
Read the arguments with `std::env::args_os()` (never `args()`, which panics on non-UTF-8). Skip the
program name. If there is exactly one argument, it is the tool name (`to_str()`, or `""` if it is
not UTF-8); otherwise the name is `""`, which `run` answers as an unknown tool. Call
`toolkit::run(name, &mut std::io::stdin().lock())`, write `stdout` to standard output and `stderr`
to standard error with `write_all` (ignore their errors), and return
`ExitCode::from(outcome.exit.code())`.
## Steps
- [ ] **1. Copy.** `git switch m3b`, then `mkdir -p crates/toolkit/tests/support` and
`cp docs/plans/M3b/files/crates/toolkit/tests/support/mod.rs crates/toolkit/tests/support/` and
`cp docs/plans/M3b/files/crates/toolkit/tests/files.rs crates/toolkit/tests/`
- [ ] **2. See it fail.** `cargo test -p toolkit --test files`. Expected: it does not compile.
- [ ] **3. Write the code** and the two manifest and doc changes. Run `cargo fmt --all`.
- [ ] **4. See it pass.** `cargo test -p toolkit --test files`. Expected: 7 passed. One of them
returns early and passes if you are root; you should not be.
- [ ] **5. Walk the exits.** Point at the line of your code for each numbered exit above.
- [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`.
- [ ] **7. Log and commit.** `git add crates/toolkit docs/dependencies.md docs/implementer-log.md Cargo.lock && git commit`
## Done when
- `cargo test -p toolkit --test files` reports 7 passed; `make gate` prints `gate: ok`.
## Stop and report if
- A test wants a misuse to print anything on standard output.
- A test wants `write_file` to create a directory.