# digit v0.3.0 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add response size capping, raw output mode, shell completions, and comprehensive docs.rs documentation.
**Architecture:** Four independent features modifying existing files. `finger()` is refactored to call a new `finger_raw()` that returns `Vec<u8>` and accepts `max_response_size`. `finger()` wraps it with lossy UTF-8 decode. CLI gains `--max-size`, `--raw` flags, and a `completions` subcommand. Documentation added incrementally.
**Tech Stack:** Rust 2021, clap (derive), clap_complete, thiserror, std::io::Read::take.
---
### Task 1: Refactor finger() to finger_raw() -- Tests (Red)
**Files:**
- Modify: `src/protocol.rs`
- Modify: `tests/integration.rs`
This task adds `finger_raw()`, changes `finger()` to call it, and updates all callers to pass `max_response_size`. The refactor must happen atomically since the `finger()` signature changes.
- [ ] **Step 1: Add `finger_raw` function signature with `todo!()` and update `finger` signature**
In `src/protocol.rs`, add `finger_raw` before the existing `finger` function:
```rust
/// Execute a finger query over TCP, returning raw bytes.
///
/// Like [`finger`], but returns the raw response bytes without UTF-8 decoding.
/// The response is capped at `max_response_size` bytes.
pub fn finger_raw(
query: &Query,
timeout: Duration,
max_response_size: u64,
) -> Result<Vec<u8>, FingerError> {
todo!()
}
```
Change the existing `finger` function signature from:
```rust
pub fn finger(query: &Query, timeout: Duration) -> Result<String, FingerError> {
```
to:
```rust
/// Execute a finger query over TCP, returning the response as a string.
///
/// Calls [`finger_raw`] and decodes the response with [`String::from_utf8_lossy`].
/// Invalid UTF-8 bytes are replaced with U+FFFD.
pub fn finger(query: &Query, timeout: Duration, max_response_size: u64) -> Result<String, FingerError> {
```
Do NOT change the body of `finger` yet -- just the signature.
- [ ] **Step 2: Update all integration test calls to pass max_response_size**
In `tests/integration.rs`, update the import line:
```rust
use digit::protocol::{finger, finger_raw};
```
Update every `finger(&q, ...)` call to pass a large max size. There are 5 calls plus the timeout test. Change each:
```rust
let result = finger(&q, Duration::from_secs(5));
```
to:
```rust
let result = finger(&q, Duration::from_secs(5), 1_048_576);
```
The calls to update are in: `read_timeout_reports_timed_out` (line 28, uses `Duration::from_secs(1)`), `end_to_end_user_query` (line 92), `end_to_end_list_users` (line 107), `end_to_end_verbose_query` (line 122), `connection_refused_returns_error` (line 137, uses `Duration::from_secs(2)`), `utf8_lossy_handles_invalid_bytes` (line 158).
- [ ] **Step 3: Add integration tests for size cap and raw mode**
Add these tests to `tests/integration.rs`:
```rust
#[test]
fn response_capped_at_max_size() {
// Server sends 1000 bytes, but we cap at 100.
let big_response = "X".repeat(1000);
let (port, handle) = mock_finger_server(&big_response, |_| {});
let q = Query::parse(Some(&format!("user@127.0.0.1")), false, port).expect("valid query");
let result = finger(&q, Duration::from_secs(5), 100).expect("finger should succeed");
assert_eq!(result.len(), 100);
assert!(result.chars().all(|c| c == 'X'));
handle.join().expect("server thread");
}
#[test]
fn finger_raw_returns_bytes() {
let (port, handle) = mock_finger_server("Hello\r\n", |_| {});
let q = Query::parse(Some(&format!("user@127.0.0.1")), false, port).expect("valid query");
let result = finger_raw(&q, Duration::from_secs(5), 1_048_576).expect("finger_raw should succeed");
assert_eq!(result, b"Hello\r\n");
handle.join().expect("server thread");
}
#[test]
fn finger_raw_preserves_invalid_utf8() {
let response_bytes: Vec<u8> = vec![72, 101, 108, 108, 111, 0xFF, 0xFE, 10];
let response_str = unsafe { String::from_utf8_unchecked(response_bytes.clone()) };
let (port, handle) = mock_finger_server(&response_str, |_| {});
let q = Query::parse(Some(&format!("user@127.0.0.1")), false, port).expect("valid query");
let result = finger_raw(&q, Duration::from_secs(5), 1_048_576).expect("finger_raw should succeed");
// Raw mode preserves the original bytes without replacement.
assert_eq!(result, response_bytes);
handle.join().expect("server thread");
}
```
- [ ] **Step 4: Update main.rs to pass max_response_size**
In `src/main.rs`, update the `finger` call to pass `1_048_576` as a temporary hardcoded value:
```rust
match finger(&query, timeout, 1_048_576) {
```
- [ ] **Step 5: Verify new tests fail, old tests compile**
Run: `cargo test`
Expected: Existing tests pass (finger() compiles with new parameter). The 3 new tests fail: `response_capped_at_max_size` fails because finger() doesn't cap yet, `finger_raw_returns_bytes` and `finger_raw_preserves_invalid_utf8` fail because `finger_raw` has `todo!()`.
- [ ] **Step 6: Commit**
```bash
git add src/protocol.rs src/main.rs tests/integration.rs
git commit -m "test: add finger_raw and response size cap tests (red)"
```
---
### Task 2: Implement finger_raw() and Response Size Cap (Green)
**Files:**
- Modify: `src/protocol.rs`
- [ ] **Step 1: Move the finger() body into finger_raw()**
Replace the `todo!()` in `finger_raw` with the full body of the current `finger()` function, but with two changes:
1. Replace the read section. Change:
```rust
let mut buf = Vec::new();
stream.read_to_end(&mut buf).map_err(|e| {
```
to:
```rust
let mut buf = Vec::new();
stream.take(max_response_size).read_to_end(&mut buf).map_err(|e| {
```
2. Return `Vec<u8>` instead of `String`. Change the final line from:
```rust
Ok(String::from_utf8_lossy(&buf).into_owned())
```
to:
```rust
Ok(buf)
```
- [ ] **Step 2: Replace finger() body with delegation to finger_raw()**
Replace the entire body of `finger()` with:
```rust
pub fn finger(query: &Query, timeout: Duration, max_response_size: u64) -> Result<String, FingerError> {
let bytes = finger_raw(query, timeout, max_response_size)?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
```
- [ ] **Step 3: Run all tests**
Run: `cargo test`
Expected: All tests pass, including the 3 new ones (size cap, raw bytes, raw preserves invalid UTF-8).
- [ ] **Step 4: Commit**
```bash
git add src/protocol.rs
git commit -m "feat: add finger_raw() and response size capping (green)"
```
---
### Task 3: Add --max-size and --raw CLI Flags
**Files:**
- Modify: `src/main.rs`
- [ ] **Step 1: Add the CLI flags and update main logic**
In `src/main.rs`, add to the imports:
```rust
use std::io::Write as _;
use digit::protocol::{finger, finger_raw};
```
Remove `use digit::protocol::finger;` (replaced by the new import).
Add two fields to the `Cli` struct:
```rust
/// Maximum response size in bytes
#[arg(long, default_value_t = 1_048_576)]
max_size: u64,
/// Write raw response bytes to stdout without UTF-8 decoding
#[arg(long)]
raw: bool,
```
Replace the finger call and response handling section (the `match finger(...)` block) with:
```rust
if cli.raw {
match finger_raw(&query, timeout, cli.max_size) {
Ok(bytes) => {
if bytes.len() as u64 >= cli.max_size {
eprintln!(
"digit: warning: response truncated at {} bytes (use --max-size to increase)",
cli.max_size
);
}
std::io::stdout()
.write_all(&bytes)
.expect("failed to write to stdout");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("digit: {}", e);
ExitCode::FAILURE
}
}
} else {
match finger(&query, timeout, cli.max_size) {
Ok(response) => {
if response.len() as u64 >= cli.max_size {
eprintln!(
"digit: warning: response truncated at {} bytes (use --max-size to increase)",
cli.max_size
);
}
print!("{}", response);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("digit: {}", e);
ExitCode::FAILURE
}
}
}
```
Note: The truncation check uses `>=` because `take()` reads at most `max_response_size` bytes. If the response is exactly that length, it was likely truncated. For the `finger()` path (lossy decode), the string length in bytes might differ from the raw length due to multi-byte replacement characters, but the check is still a reasonable heuristic since the raw bytes are capped first.
- [ ] **Step 2: Verify it compiles and help text is correct**
Run: `cargo build`
Expected: Compiles successfully.
Run: `cargo run -- --help`
Expected: Shows `--max-size` with `[default: 1048576]` and `--raw` flags.
- [ ] **Step 3: Run all tests**
Run: `cargo test`
Expected: All tests pass.
- [ ] **Step 4: Commit**
```bash
git add src/main.rs
git commit -m "feat: add --max-size and --raw CLI flags"
```
---
### Task 4: Shell Completions Subcommand
**Files:**
- Modify: `Cargo.toml`
- Modify: `src/main.rs`
- [ ] **Step 1: Add clap_complete dependency**
In `Cargo.toml`, add to `[dependencies]`:
```toml
clap_complete = "4"
```
- [ ] **Step 2: Add the Command enum and subcommand support**
In `src/main.rs`, add to the imports:
```rust
use clap::Subcommand;
use clap_complete::aot::generate;
```
Add the `Command` enum before the `Cli` struct:
```rust
#[derive(Subcommand, Debug)]
enum Command {
/// Generate shell completions for the given shell
Completions {
/// Shell to generate completions for (bash, zsh, fish, powershell, elvish)
shell: clap_complete::Shell,
},
}
```
Add a subcommand field to the `Cli` struct:
```rust
#[command(subcommand)]
command: Option<Command>,
```
- [ ] **Step 3: Handle the completions subcommand in main**
In the `main` function, add subcommand handling after `let cli = Cli::parse();` and before the `Query::parse` call:
```rust
if let Some(Command::Completions { shell }) = cli.command {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "digit", &mut std::io::stdout());
return ExitCode::SUCCESS;
}
```
Also add `use clap::CommandFactory;` to the imports at the top.
- [ ] **Step 4: Verify it compiles and generates completions**
Run: `cargo build`
Expected: Compiles successfully.
Run: `cargo run -- completions bash | head -5`
Expected: Outputs bash completion script starting with shell function definitions.
Run: `cargo run -- completions zsh | head -5`
Expected: Outputs zsh completion script.
- [ ] **Step 5: Verify normal finger queries still work**
Run: `cargo run -- --help`
Expected: Shows normal usage plus the `completions` subcommand.
Run: `cargo test`
Expected: All tests pass.
- [ ] **Step 6: Commit**
```bash
git add Cargo.toml Cargo.lock src/main.rs
git commit -m "feat: add shell completions subcommand via clap_complete"
```
---
### Task 5: Documentation -- Crate and Module Level
**Files:**
- Modify: `src/lib.rs`
- Modify: `src/query.rs`
- Modify: `src/protocol.rs`
- [ ] **Step 1: Add crate-level documentation to lib.rs**
Replace the contents of `src/lib.rs` with:
```rust
//! A finger protocol client library implementing
//! [RFC 1288](https://datatracker.ietf.org/doc/html/rfc1288) and
//! [RFC 742](https://datatracker.ietf.org/doc/html/rfc742).
//!
//! This crate provides:
//! - [`query::Query`] -- parsing finger query strings into structured queries
//! - [`protocol::build_query_string`] -- constructing RFC 1288 wire-format query strings
//! - [`protocol::finger`] -- executing a finger query over TCP (returns a UTF-8 string)
//! - [`protocol::finger_raw`] -- executing a finger query over TCP (returns raw bytes)
//!
//! # Example
//!
//! ```no_run
//! use std::time::Duration;
//! use digit::query::Query;
//! use digit::protocol::finger;
//!
//! let query = Query::parse(Some("user@example.com"), false, 79).unwrap();
//! let response = finger(&query, Duration::from_secs(10), 1_048_576).unwrap();
//! println!("{}", response);
//! ```
pub mod protocol;
pub mod query;
```
- [ ] **Step 2: Add module-level documentation to query.rs**
Add at the very top of `src/query.rs`, before the `Query` struct:
```rust
//! Finger query string parsing.
//!
//! Parses user input (e.g. `"user@host"`, `"@host"`, `"user@host1@host2"`)
//! into a structured [`Query`] for use with [`crate::protocol::finger`].
```
- [ ] **Step 3: Add a rustdoc example to Query::parse**
Update the doc comment on `Query::parse` in `src/query.rs`. Replace the existing doc comment (lines 30-38) with:
```rust
/// Parse a query string into a [`Query`].
///
/// Returns an error if any hostname segment is empty (e.g. `"user@"`, `"user@@host"`).
///
/// # Examples
///
/// ```
/// use digit::query::Query;
///
/// // Standard user query
/// let q = Query::parse(Some("user@example.com"), false, 79).unwrap();
/// assert_eq!(q.user, Some("user".to_string()));
/// assert_eq!(q.target_host(), "example.com");
///
/// // Empty hostname is rejected
/// assert!(Query::parse(Some("user@"), false, 79).is_err());
/// ```
```
- [ ] **Step 4: Add module-level documentation to protocol.rs**
Add at the very top of `src/protocol.rs`, before the imports:
```rust
//! Finger protocol wire format and TCP communication.
//!
//! Provides functions to build RFC 1288 query strings and execute finger
//! queries over TCP with configurable timeouts and response size limits.
```
- [ ] **Step 5: Add rustdoc example to build_query_string**
Update the doc comment on `build_query_string` in `src/protocol.rs`. Replace the existing doc comment with:
```rust
/// Build the wire-format query string to send over the TCP connection.
///
/// Per RFC 1288:
/// - Verbose queries prepend `/W ` (with trailing space).
/// - Forwarding appends `@host1@host2...` for all hosts except the last
/// (the last host is the connection target, not part of the query string).
/// - The query is terminated with `\r\n`.
///
/// # Example
///
/// ```
/// use digit::query::Query;
/// use digit::protocol::build_query_string;
///
/// let q = Query::parse(Some("user@host"), true, 79).unwrap();
/// assert_eq!(build_query_string(&q), "/W user\r\n");
/// ```
```
- [ ] **Step 6: Add rustdoc example to finger**
Update the doc comment on `finger` in `src/protocol.rs`:
```rust
/// Execute a finger query over TCP, returning the response as a string.
///
/// Calls [`finger_raw`] and decodes the response with [`String::from_utf8_lossy`].
/// Invalid UTF-8 bytes are replaced with U+FFFD. The response is capped at
/// `max_response_size` bytes.
///
/// # Example
///
/// ```no_run
/// use std::time::Duration;
/// use digit::query::Query;
/// use digit::protocol::finger;
///
/// let q = Query::parse(Some("user@example.com"), false, 79).unwrap();
/// let response = finger(&q, Duration::from_secs(10), 1_048_576).unwrap();
/// println!("{}", response);
/// ```
```
- [ ] **Step 7: Add rustdoc example to finger_raw**
Update the doc comment on `finger_raw` in `src/protocol.rs`:
```rust
/// Execute a finger query over TCP, returning raw bytes.
///
/// Like [`finger`], but returns the raw response bytes without UTF-8 decoding.
/// The response is capped at `max_response_size` bytes. Useful for piping
/// output to other tools or when the response contains non-UTF-8 data.
///
/// # Example
///
/// ```no_run
/// use std::time::Duration;
/// use digit::query::Query;
/// use digit::protocol::finger_raw;
///
/// let q = Query::parse(Some("user@example.com"), false, 79).unwrap();
/// let bytes = finger_raw(&q, Duration::from_secs(10), 1_048_576).unwrap();
/// std::io::Write::write_all(&mut std::io::stdout(), &bytes).unwrap();
/// ```
```
- [ ] **Step 8: Run doc tests**
Run: `cargo test --doc`
Expected: The compilable doc examples (`Query::parse`, `build_query_string`) pass. The `no_run` examples compile but don't execute.
- [ ] **Step 9: Run full test suite**
Run: `cargo test`
Expected: All tests pass.
- [ ] **Step 10: Commit**
```bash
git add src/lib.rs src/query.rs src/protocol.rs
git commit -m "docs: add crate, module, and function-level rustdoc documentation"
```
---
### Task 6: Update README
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Update the README**
Replace the full contents of `README.md` with:
````markdown
# digit
[](https://crates.io/crates/digit-cli)
[](https://docs.rs/digit-cli)
A finger protocol client implementing [RFC 1288](https://datatracker.ietf.org/doc/html/rfc1288) and [RFC 742](https://datatracker.ietf.org/doc/html/rfc742), written in Rust.
To try this on active finger servers (as of 2026), try the domains `graph.no` and `tilde.town`
## Installation
```
cargo install digit-cli
```
Or from source:
```
cargo install --path .
```
## Usage
```
digit [OPTIONS] [QUERY]
digit completions <SHELL>
```
### Examples
```bash
# Query a user at a host
digit user@example.com
# List all users at a host
digit @example.com
# Query a user with verbose/long output
digit -l user@example.com
# Use a non-standard port
digit -p 7979 user@example.com
# Set a connection timeout (in seconds)
digit -t 5 user@example.com
# Write raw bytes to stdout (useful for piping)
# Limit response size to 64 KiB
digit --max-size 65536 user@example.com
# Query a user at localhost
digit user
# List users at localhost
digit
```
### Forwarding queries
The finger protocol supports forwarding queries through a chain of hosts using the `@host1@host2` syntax (RFC 1288, section 2.5.1). For example:
```bash
digit user@host1@host2
```
This connects to `host2` and asks it to forward the query to `host1`. Note that many modern finger servers disable forwarding for security reasons, so this feature depends on server support.
### Options
| `-l, --long` | Request verbose/long output (sends `/W` prefix) |
| `-p, --port <PORT>` | Port to connect on (default: 79) |
| `-t, --timeout <SECS>` | Connection timeout in seconds (default: 10) |
| `--max-size <BYTES>` | Maximum response size in bytes (default: 1048576) |
| `--raw` | Write raw response bytes to stdout without UTF-8 decoding |
| `-h, --help` | Print help |
| `-V, --version` | Print version |
### Shell completions
Generate shell completions with the `completions` subcommand:
```bash
# Bash
digit completions bash > ~/.bash_completion.d/digit
# Zsh
digit completions zsh > ~/.zfunc/_digit
# Fish
digit completions fish > ~/.config/fish/completions/digit.fish
# PowerShell
digit completions powershell > digit.ps1
# Elvish
digit completions elvish > digit.elv
```
## Library
`digit` can also be used as a Rust library. See the [API documentation](https://docs.rs/digit-cli) for details.
## License
MIT
````
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs: update README with new flags, completions, and docs.rs link"
```
---
### Task 7: Version Bump and Final Checks
**Files:**
- Modify: `Cargo.toml`
- [ ] **Step 1: Bump version to 0.3.0**
In `Cargo.toml`, change:
```toml
version = "0.2.0"
```
to:
```toml
version = "0.3.0"
```
- [ ] **Step 2: Run full test suite**
Run: `cargo test`
Expected: All tests pass.
- [ ] **Step 3: Run clippy**
Run: `cargo clippy -- -D warnings`
Expected: No warnings.
- [ ] **Step 4: Check formatting**
Run: `cargo fmt -- --check`
Expected: No formatting issues. If any, run `cargo fmt` to fix.
- [ ] **Step 5: Verify doc tests**
Run: `cargo test --doc`
Expected: All doc tests pass.
- [ ] **Step 6: Commit**
```bash
git add Cargo.toml Cargo.lock
git commit -m "chore: bump version to 0.3.0"
```