digit-cli 0.3.0

A finger protocol client (RFC 1288 / RFC 742)
Documentation
# digit v0.2.0 Polish Spec

**Date:** 2026-04-11
**Status:** Draft
**Baseline:** v0.1.0 (commit `f9575db`)

## Overview

Three targeted improvements to digit's error handling and connection logic. No new features, no new dependencies, no CLI changes.

## 1. Query Validation

`Query::parse` currently returns `Query` infallibly. Inputs like `user@`, `@`, `user@@host`, and `user@host@` produce empty hostname strings that fail later at the DNS layer with confusing errors.

**Change:** `Query::parse` returns `Result<Query, QueryError>`.

**New error type** in `query.rs`:

```rust
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum QueryError {
    #[error("invalid query: empty hostname in '{input}'")]
    EmptyHostname { input: String },
}
```

**Validation rule:** After splitting on `@`, if any host segment is an empty string, return `Err(QueryError::EmptyHostname { input })`.

**Inputs that become errors:**

| Input | Reason |
|---|---|
| `user@` | Single empty host |
| `@` | Single empty host |
| `user@host@` | Last host empty |
| `user@@host` | First host empty |
| `@host@` | Last host empty |

**Inputs that remain valid (unchanged behavior):**

| Input | Result |
|---|---|
| `user@host` | user=Some("user"), hosts=["host"] |
| `@host` | user=None, hosts=["host"] |
| `user` | user=Some("user"), hosts=["localhost"] |
| `""` / None | user=None, hosts=["localhost"] |
| `user@host1@host2` | Forwarding chain |

**API ripple:**
- `main.rs`: Handle the `Result` from `parse`, print error to stderr, exit with code 1.
- `tests/integration.rs`: Add `.expect("valid query")` to all `Query::parse` calls (known-good inputs).
- Unit tests: Existing tests add `.unwrap()`. New tests cover error cases.

## 2. Timeout Consistency

Currently, post-connection timeouts during `write_all` or `read_to_end` produce `SendFailed` / `ReadFailed` errors. The user sees "failed to read response: ..." when the real problem is a timeout. The `TimedOut` error kind check already exists for `connect_timeout` but not for the data transfer phase.

**Change:** Add `io::ErrorKind::TimedOut` checks in the `write_all` and `read_to_end` error mappers, producing `FingerError::Timeout` instead of `SendFailed` / `ReadFailed` when the underlying error is a timeout.

Pattern (same for both send and read):
```rust
.map_err(|e| {
    if e.kind() == io::ErrorKind::TimedOut {
        FingerError::Timeout { host: host.to_string(), port: query.port }
    } else {
        FingerError::SendFailed { source: e }  // or ReadFailed
    }
})
```

**Testing:** Add an integration test with a mock server that accepts the connection but never responds, using a short timeout (1-2 seconds). Verify the error message contains "timed out".

## 3. Parallel DNS Connection

Currently only the first address from `ToSocketAddrs` is attempted. If a hostname resolves to multiple addresses (IPv4 + IPv6, multiple A records) and the first is unreachable, the connection fails even though another would work.

**Change:** Try all resolved addresses simultaneously, use the first successful connection.

**Logic:**
1. Resolve hostname via `ToSocketAddrs`, collect into `Vec<SocketAddr>`
2. If empty, return `FingerError::DnsResolution` (same as now)
3. If one address, connect directly with `connect_timeout` (no threading overhead)
4. If multiple addresses, spawn a thread per address, each calling `TcpStream::connect_timeout`
5. Use `std::sync::mpsc::channel` to collect results -- each thread sends `Result<TcpStream, io::Error>`
6. Receive from channel: first `Ok(stream)` wins, otherwise collect errors
7. If all fail, return `ConnectionFailed` with the last error (or `Timeout` if `TimedOut`)

**No new dependencies.** Uses `std::sync::mpsc`, `std::thread`, `std::net`.

**Thread cleanup:** Spawned threads are not joined after a winner is found. They complete in the background and their `TcpStream` values are dropped (closing the connection). Fine for a short-lived CLI process.

**Testing:** Existing integration tests use `127.0.0.1` (single address), validating the single-address fast path. Multi-address testing would require DNS mocking which adds complexity without proportional value -- the parallel logic is straightforward channel usage.

## Files Changed

| File | Change |
|---|---|
| `src/query.rs` | Add `QueryError`, change `parse` return type to `Result`, add validation |
| `src/protocol.rs` | Timeout checks in send/read mappers, parallel DNS connection |
| `src/main.rs` | Handle `Result` from `Query::parse` |
| `tests/integration.rs` | Add `.expect()` to parse calls, add timeout test |

## Version Bump

Bump to `0.2.0` in `Cargo.toml` since the `Query::parse` return type change is a breaking API change for anyone using digit as a library.