Introduction
towl is a fast command-line tool built in Rust that scans codebases for TODO comments. It provides an interactive TUI for browsing and managing TODOs, can create GitHub issues from them, and supports multiple output formats for CI/scripting. It detects TODO, FIXME, HACK, NOTE, and BUG comments across many languages, with configurable patterns, context-aware output, and robust resource limits.
Key Features
- Interactive TUI -- Browse, filter, sort, and peek at TODOs in a full-screen terminal interface powered by ratatui
- GitHub integration -- Create GitHub issues from selected TODOs and automatically replace comments with issue links
- Multi-language support -- Scans Rust, Python, JavaScript, Go, Shell, and more via configurable comment prefixes and function patterns
- Multiple output formats -- JSON, CSV, Markdown, TOML, and terminal table (non-interactive mode)
- Type filtering & sorting -- Filter results by TODO type; sort by file, line, type, or priority
- Context-aware -- Captures surrounding code lines and enclosing function names
- Configurable -- Customise file extensions, exclude patterns, comment prefixes, and TODO patterns via
.towl.toml(override with--configorTOWL_CONFIGenv var) - AI validation -- LLM-powered TODO analysis using Claude, OpenAI, or local CLI agents (Claude Code, Codex)
- Safe by design -- Path traversal protection, resource limits, symlink resolution, and secret handling for tokens
- Fast -- Concurrent file scanning, async I/O with tokio, compiled regex patterns, and static enum dispatch
How It Works
┌──────────┐
│ Config │ --config / TOWL_CONFIG / .towl.toml + env vars
└────┬─────┘
│
┌────▼─────┐
│ Scanner │ Walks directory tree, scans files concurrently
└────┬─────┘
│
┌────▼─────┐
│ Parser │ Matches comment prefixes + TODO patterns
└────┬─────┘
│
┌────▼─────┐
│ LLM │ --ai: validates TODOs with AI (optional)
└────┬─────┘
│
┌──────┴──────┐
│ │
┌─────▼────┐ ┌────▼─────┐
│ TUI │ │ Output │ Non-interactive: formats + writes
│ (default) │ │ (-N) │
└─────┬────┘ └──────────┘
│
┌─────▼────┐
│ Processor │ Replaces TODOs with GitHub issue links
└──────────┘
- Config loads settings from
.towl.toml(or a custom path via--config/TOWL_CONFIG), merges environment variables for GitHub and LLM integration - Scanner walks the directory tree using the
ignorecrate, scanning matching files concurrently with bounded parallelism - Parser reads each file, matches comment prefixes and TODO patterns via compiled regex, extracts context lines and function names
- LLM (optional,
--ai) validates each TODO with an AI model, classifying them as Valid, Invalid, or Uncertain - TUI (default) presents an interactive interface for browsing, filtering, and selecting TODOs to create as GitHub issues
- Output (non-interactive) formats the collected
TodoCommentitems into the requested format and writes to a file or stdout - Processor replaces TODO comments in source files with GitHub issue links after issues are created
Quick Example
# Scan current directory (opens interactive TUI)
towl scan
# Non-interactive: output as terminal table
towl scan -N
# Non-interactive: output to JSON file
towl scan -N -f json -o todos.json
# Filter to only FIXME comments
towl scan -N -t fixme
# AI analysis: validate TODOs and filter out invalid ones
towl scan -N --ai
# Create GitHub issues
towl scan -N -g
# Show current configuration
towl config
Installation
From crates.io
cargo install towl
Requires Rust 1.75 or later. Install Rust via rustup if needed.
From Source
git clone https://github.com/glottologist/towl.git
cd towl
cargo build --release
The binary will be at target/release/towl.
Verify Installation
towl --version
towl --help
Requirements
- Rust: 1.75+
- git: Required on
PATHfortowl init(extracts GitHub owner/repo from the git remote) - OS: Linux, macOS, Windows
Quick Start
1. Initialise Configuration
Inside a git repository with a GitHub remote:
towl init
This creates .towl.toml with sensible defaults. GitHub owner/repo are auto-detected from git remote get-url origin at runtime (not stored in the config file).
If .towl.toml already exists, use --force to overwrite:
towl init --force
2. Scan for TODOs (Interactive)
# Scan the current directory (opens TUI)
towl scan
# Scan a specific path
towl scan src/
# Use a config file from a custom location
towl scan -c .config/.towl.toml
The interactive TUI lets you browse, filter, sort, peek at source code, and create GitHub issues from selected TODOs.
3. Scan for TODOs (Non-Interactive)
Use --non-interactive / -N for CI pipelines and scripting:
# Terminal table output
towl scan -N
# Enable verbose output (file counts, timing)
towl scan -N -v
4. Choose an Output Format
Non-interactive mode supports multiple output formats:
# Terminal table (default)
towl scan -N
# JSON file
towl scan -N -f json -o todos.json
# CSV file
towl scan -N -f csv -o todos.csv
# Markdown file
towl scan -N -f markdown -o todos.md
# TOML file
towl scan -N -f toml -o todos.toml
Note: File-based formats (
json,csv,toml,markdown) require the-oflag with a matching file extension. Terminal/table formats always output to stdout.
5. Filter by Type
# Only TODO comments
towl scan -N -t todo
# Only FIXME comments
towl scan -N -t fixme
# Only BUG comments
towl scan -N -t bug
Available types: todo, fixme, hack, note, bug
6. Create GitHub Issues
Set your GitHub token:
export TOWL_GITHUB_TOKEN=ghp_your_token_here
Then create issues from TODOs:
# Create GitHub issues (non-interactive)
towl scan -N -g
# Preview issues without creating them
towl scan -N -g -n
In interactive mode, select TODOs with Space and press Enter to create issues.
7. View Configuration
towl config
# From a custom config path
towl config -c .config/.towl.toml
Displays a tree view of all active settings including file extensions, exclude patterns, comment prefixes, TODO patterns, and GitHub configuration.
You can also set the TOWL_CONFIG environment variable to avoid passing --config every time:
export TOWL_CONFIG=.config/.towl.toml
towl scan
Configuration
towl uses a .towl.toml file in the project root for configuration. All fields have sensible defaults -- you only need to override what you want to change.
You can point to a config file in a different location using:
--config/-cflag onscanandconfigcommandsTOWL_CONFIGenvironment variable
The --config flag takes precedence over TOWL_CONFIG, which takes precedence over the default .towl.toml.
Config File
Create .towl.toml manually or run towl init:
[parsing]
file_extensions = ["rs", "toml", "json", "yaml", "yml", "sh", "bash"]
exclude_patterns = ["target/*", ".git/*"]
include_context_lines = 10
Parsing Section
| Field | Type | Default | Description |
|---|---|---|---|
file_extensions | string[] | ["rs", "toml", "json", "yaml", "yml", "sh", "bash"] | File extensions to scan |
exclude_patterns | string[] | ["target/*", ".git/*"] | Glob patterns to exclude |
include_context_lines | integer | 10 | Number of surrounding lines to capture (1-50) |
comment_prefixes | string[] | ["//", "^\\s*#", "/\\*", "^\\s*\\*"] | Regex patterns for comment line detection |
todo_patterns | string[] | See below | Regex patterns for TODO extraction |
function_patterns | string[] | See below | Regex patterns for function context detection |
Default TODO Patterns
todo_patterns = [
"(?i)\\bTODO:\\s*(.*)",
"(?i)\\bFIXME:\\s*(.*)",
"(?i)\\bHACK:\\s*(.*)",
"(?i)\\bNOTE:\\s*(.*)",
"(?i)\\bBUG:\\s*(.*)",
]
All patterns are case-insensitive by default. Each pattern must contain a capture group (.*) for extracting the description text.
Default Function Patterns
function_patterns = [
"^\\s*(pub\\s+)?fn\\s+(\\w+)", # Rust
"^\\s*def\\s+(\\w+)", # Python
"^\\s*(async\\s+)?function\\s+(\\w+)", # JavaScript
"^\\s*(public|private|protected)?\\s*(static\\s+)?\\w+\\s+(\\w+)\\s*\\(", # Java/C#
"^\\s*func\\s+(\\w+)", # Go/Swift
]
Pattern Limits
Each pattern field is limited to 100 entries. Individual regex patterns are limited to 256 characters. Config string values (e.g., owner, repo) are limited to 512 characters. These limits prevent denial-of-service via malicious configuration files.
GitHub Section
| Field | Type | Default | Description |
|---|---|---|---|
rate_limit_delay_ms | integer | 1000 | Delay in ms between GitHub API calls |
Owner and repo are always auto-detected from git remote get-url origin at runtime -- they are not stored in the config file. Use TOWL_GITHUB_OWNER and TOWL_GITHUB_REPO environment variables to override if needed.
Note: The GitHub token is never stored in the config file. Use the
TOWL_GITHUB_TOKENenvironment variable.
LLM Section
| Field | Type | Default | Description |
|---|---|---|---|
provider | string | claude | LLM provider: "claude", "openai", "claude-code", or "codex" |
model | string | claude-opus-4-6 | Model identifier |
base_url | string | Provider default | Custom endpoint URL (for Ollama, vLLM, etc.) |
max_concurrent_analyses | integer | 5 | Max concurrent LLM requests (1-20) |
max_analyse_count | integer | 50 | Max TODOs to analyse per scan (1-500) |
max_tokens | integer | 4096 | LLM response token limit |
command | string | Auto (provider-dependent) | Override CLI binary path |
args | string[] | Auto (provider-dependent) | Override CLI arguments |
Note: The LLM API key is never stored in the config file. Use the
TOWL_LLM_API_KEYenvironment variable. See AI Analysis for usage details.
Environment Variables
Eight environment variables override defaults:
| Variable | Overrides | Description |
|---|---|---|
TOWL_CONFIG | DEFAULT_CONFIG_PATH | Path to a .towl.toml file (overridden by --config flag) |
TOWL_GITHUB_TOKEN | -- | GitHub personal access token (stored as SecretString, masked in logs) |
TOWL_GITHUB_OWNER | git remote detection | GitHub repository owner |
TOWL_GITHUB_REPO | git remote detection | GitHub repository name |
TOWL_LLM_API_KEY | llm.api_key | LLM API key (stored as SecretString, env-only) |
TOWL_LLM_PROVIDER | llm.provider | LLM provider ("claude" or "openai") |
TOWL_LLM_MODEL | llm.model | LLM model identifier |
TOWL_LLM_BASE_URL | llm.base_url | Custom LLM endpoint URL |
Config Loading Order
- Built-in defaults
- Config file resolved as:
--configflag >TOWL_CONFIGenv var >.towl.toml - Git remote auto-detection for owner/repo
- Environment variable overrides (
TOWL_GITHUB_*,TOWL_LLM_*)
If no config file exists at the resolved path, defaults are used without error.
Viewing Active Configuration
# Show config from default .towl.toml
towl config
# Show config from a custom path
towl config -c .config/.towl.toml
Example output:
📋 Towl Configuration
┌─ Parsing
│ ├─ File Extensions: bash, json, rs, sh, toml, yaml, yml
│ ├─ Exclude Patterns: target/*, .git/*
│ ├─ Context Lines: 10
│ ├─ Comment Prefixes:
│ │ ├─ //
│ │ ├─ ^\s*#
│ │ ├─ /\*
│ │ └─ ^\s*\*
│ ├─ TODO Patterns:
│ │ ├─ (?i)\bTODO:\s*(.*)
│ │ ...
│ └─ Function Patterns:
│ ├─ ^\s*(pub\s+)?fn\s+(\w+)
│ ...
└─ GitHub
├─ Owner: glottologist
├─ Repo: towl
└─ Token: not set
Scanning for TODOs
The towl scan command walks a directory tree, reads each matching file, and extracts TODO-style comments using compiled regex patterns.
Config Override
By default, towl reads .towl.toml from the project root. Use --config / -c to load from a different path:
towl scan -c .config/.towl.toml
towl scan -c .config/.towl.toml src/
You can also set the TOWL_CONFIG environment variable. The --config flag takes precedence over the env var.
Interactive Mode (Default)
By default, towl scan opens an interactive TUI:
towl scan
towl scan src/
See Interactive TUI for details on the TUI interface.
Non-Interactive Mode
Use --non-interactive / -N to disable the TUI (for CI/scripting):
towl scan -N
towl scan -N src/
towl scan -N -v
How Scanning Works
- Directory walk -- Uses the
ignorecrate to traverse the file tree, respecting.gitignorerules automatically - Extension filter -- Only files matching
file_extensionsin config are read (default:rs,toml,json,yaml,yml,sh,bash) - Exclude patterns -- Files matching
exclude_patternsare skipped (default:target/*,.git/*) - Concurrent scanning -- Matching files are scanned concurrently with bounded parallelism (up to 64 files at once)
- Content parsing -- Each file is read and scanned for lines matching
comment_prefixes, then checked againsttodo_patterns - Context extraction -- Surrounding lines and enclosing function names are captured
Verbose Mode
The -v / --verbose flag prints scan metrics to stderr (non-interactive mode only):
towl scan -N -v
Files scanned: 42
Files skipped: 3
Files errored: 0
Scan duration: 12ms
Filtering by Type
Restrict results to a single TODO type:
towl scan -N -t todo # Only TODO comments
towl scan -N -t fixme # Only FIXME comments
towl scan -N -t hack # Only HACK comments
towl scan -N -t note # Only NOTE comments
towl scan -N -t bug # Only BUG comments
The filter value is case-insensitive on the command line but stored lowercase internally.
GitHub Issue Creation
Create GitHub issues from found TODOs:
# Create issues
towl scan -N -g
# Preview without creating
towl scan -N -g -n
When issues are created, towl automatically replaces the TODO comment in source files with a link to the created issue. Duplicate detection prevents creating issues for TODOs that already have a matching open issue.
In interactive mode, select TODOs with Space and press Enter to create issues.
AI Analysis
Use the --ai flag to validate TODOs with an LLM:
# Analyse and filter out invalid TODOs
towl scan -N --ai
# Interactive mode with AI analysis
towl scan --ai
# Create GitHub issues for valid TODOs only (enriched with AI reasoning)
towl scan -N --ai -g
In non-interactive mode, TODOs classified as Invalid are automatically excluded from the output. See AI Analysis for full details.
Combining Options
Options compose freely:
# Scan src/, output FIXME comments as JSON to a file, verbose
towl scan -N src/ -t fixme -f json -o fixmes.json -v
# Scan and create GitHub issues for TODO comments only
towl scan -N -t todo -g
# AI-validated FIXMEs as JSON
towl scan -N --ai -t fixme -f json -o fixmes.json
# Use a custom config for everything
towl scan -c .config/.towl.toml -N src/ -t fixme -f json -o fixmes.json
Resource Limits
towl enforces hard limits to prevent runaway scans:
| Limit | Value | Purpose |
|---|---|---|
| Max file size | 10 MB | Skips binary/generated files |
| Max TODOs per file | 10,000 | Prevents single-file explosion |
| Max total TODOs | 100,000 | Caps overall result set |
| Max files scanned | 100,000 | Bounds directory walk |
When a limit is hit, scanning stops gracefully and returns the results collected so far.
Scan Result
The scan produces a ScanResult containing:
- todos -- The list of extracted
TodoCommentitems - files_scanned -- Number of files successfully read
- files_skipped -- Number of files skipped (wrong extension, excluded, too large)
- files_errored -- Number of files that failed to read (permissions, encoding)
- duration -- Wall-clock time for the scan
Two convenience checks:
all_files_failed()-- Returnstruewhen no files were scanned but errors occurred (likely a permissions or path issue)is_clean()-- Returnstruewhen zero TODOs were found and zero files errored
Path Safety
- Path traversal -- Paths containing
..components are rejected - Symlink resolution -- Symlinks are resolved before processing to prevent escape from the scan root
- .gitignore -- Respected automatically via the
ignorecrate
Interactive TUI
By default, towl scan opens an interactive terminal interface powered by ratatui. The TUI lets you browse, filter, sort, and peek at TODOs, then create GitHub issues from selected items.
Launching
# Opens TUI with TODOs from current directory
towl scan
# Opens TUI with TODOs from a specific path
towl scan src/
To bypass the TUI (for CI/scripting), use --non-interactive / -N.
Modes
The TUI has six modes:
Browse
The main view. Displays all TODOs in a scrollable list with type, description, file path, and line number.
When launched with --ai, each row shows a validity indicator (V/I/?) and is colour-coded: green for valid, red for invalid, yellow for uncertain.
| Key | Action |
|---|---|
j / Down | Move cursor down |
k / Up | Move cursor up |
Space | Toggle selection on current item |
a | Select all visible TODOs |
n | Deselect all |
f | Cycle type filter (All, TODO, FIXME, HACK, NOTE, BUG) |
s | Cycle sort field (File, Line, Type, Priority) |
r | Reverse sort order |
p | Open peek view for current TODO |
d | Delete selected invalid TODOs (requires --ai) |
Enter | Confirm selection and proceed to create GitHub issues |
q / Esc | Quit |
Ctrl+C | Force quit (works in any mode) |
Peek
Shows the source code surrounding the selected TODO with syntax context. The TODO line is highlighted. When --ai is active, the AI Analysis section is displayed below the source code with the validity, confidence score, and reasoning. The reasoning text word-wraps to fit the popup width.
| Key | Action |
|---|---|
j / Down | Scroll down |
k / Up | Scroll up |
p / q / Esc | Close peek and return to browse |
Confirm
Appears after pressing Enter in browse mode with selected TODOs. Shows a summary of the TODOs that will be created as GitHub issues.
| Key | Action |
|---|---|
y / Enter | Confirm and start creating issues |
n / q / Esc | Cancel and return to browse |
Creating
Displays a progress view while GitHub issues are being created. Shows the current phase (initialising client, loading existing issues, creating issues, replacing TODOs in files) and a progress counter.
No keyboard input is accepted during creation (except Ctrl+C to force quit).
Done
Shows the results after issue creation completes -- number of issues created, any errors encountered. Press q, Esc, or Enter to exit.
Delete Confirm (requires --ai)
Appears after pressing d in Browse mode with selected invalid TODOs. Lists the TODOs that will be removed from source files.
| Key | Action |
|---|---|
y / Enter | Confirm and delete the TODO comment lines |
n / q / Esc | Cancel and return to browse |
Only TODOs marked as Invalid by the AI are eligible for deletion.
Workflow
- Run
towl scanto open the TUI (with--ai, a progress bar shows during analysis) - Browse the TODO list -- use
fto filter by type,s/rto sort - Press
pto peek at source code around a TODO - Select TODOs with
Space(orato select all visible) - Press
Enterto review selected TODOs - Press
yto create GitHub issues - towl creates the issues, skips duplicates, and replaces TODO comments with issue links in source files
AI Analysis
towl can use an LLM (Claude or any OpenAI-compatible model) to validate whether each TODO is still relevant. The --ai flag triggers analysis that determines if a TODO is Valid, Invalid, or Uncertain.
Setup
Set your API key as an environment variable:
# Claude (default)
export TOWL_LLM_API_KEY=sk-ant-your-key-here
# Or for OpenAI
export TOWL_LLM_API_KEY=sk-your-openai-key
export TOWL_LLM_PROVIDER=openai
The API key is stored as a SecretString and never written to config files or logs.
Basic Usage
# Non-interactive: analyse and filter out invalid TODOs
towl scan -N --ai
# Interactive: analyse and show results in TUI
towl scan --ai
# Combine with other flags
towl scan -N --ai -t fixme -f json -o fixmes.json
towl scan -N --ai -g # create GitHub issues for valid TODOs only
How It Works
For each TODO, the LLM receives:
- TODO description -- the comment text
- Expanded context -- ~30 lines of surrounding source code
- Function body -- the complete enclosing function (if detected)
The LLM determines:
- Is it resolved? -- Does the code already do what the TODO asks?
- Is it relevant? -- Does the code/feature still exist?
- Is it actionable? -- Is the TODO clear and specific?
Based on these checks, each TODO is classified as Valid, Invalid, or Uncertain with a confidence score (0-100%).
Non-Interactive Mode
With -N --ai, invalid TODOs are automatically filtered out of the results:
towl scan -N --ai
# Only valid and uncertain TODOs appear in output
towl scan -N --ai -g
# GitHub issues created only for valid TODOs, enriched with AI reasoning
Interactive Mode (TUI)
With --ai (no -N), a progress bar is displayed while TODOs are being analysed:
Analysing TODOs [████████████░░░░░░░░░░░░░░░░░░] 12/30
Once analysis completes, the TUI launches with results:
- Validity column -- Each TODO shows
V(Valid),I(Invalid), or?(Uncertain) - Colour coding -- Green for valid, red for invalid, yellow for uncertain
- Peek view -- Press
pto see the LLM's reasoning below the source code (text wraps to fit the popup width) - Delete invalid TODOs -- Select invalid TODOs and press
dto remove them from source files (with confirmation)
Delete Workflow
- Select invalid TODOs with
Space(orato select all visible) - Press
dto open the delete confirmation dialog - Review the list of TODOs that will be removed
- Press
yto confirm deletion, ornto cancel - towl removes the comment lines from source files using atomic writes
Note: Only TODOs marked as Invalid by the AI can be deleted via
d. Valid and Uncertain TODOs are excluded from deletion.
GitHub Issue Enrichment
When creating GitHub issues (either with -g or via the TUI), valid TODOs include an AI Analysis section in the issue body:
## AI Analysis
**Validity:** Valid
**Confidence:** 92%
### Reasoning
The caching layer referenced in this TODO has not been implemented.
The function currently makes direct database calls on every request.
### Enhanced Description
This TODO identifies a performance bottleneck where database queries
are executed on every request without caching. Adding a caching layer
would reduce database load and improve response times.
Configuration
Add a [llm] section to .towl.toml:
[llm]
provider = "claude" # "claude" or "openai"
model = "claude-opus-4-6" # model identifier
# base_url = "http://localhost:11434/v1" # for Ollama/vLLM
max_concurrent_analyses = 5 # concurrent LLM requests
max_analyse_count = 50 # max TODOs to analyse per scan
max_tokens = 4096 # LLM response token limit
Environment Variables
| Variable | Default | Description |
|---|---|---|
TOWL_LLM_API_KEY | -- | API key (required for --ai) |
TOWL_LLM_PROVIDER | claude | "claude", "openai", "claude-code", or "codex" |
TOWL_LLM_MODEL | claude-opus-4-6 | Model identifier |
TOWL_LLM_BASE_URL | Provider default | Custom endpoint URL |
Using Claude Code or Codex CLI
If you have claude (Claude Code) or codex (OpenAI Codex CLI) installed, you can use them directly without an API key:
# Use Claude Code CLI
export TOWL_LLM_PROVIDER=claude-code
towl scan --ai
# Use Codex CLI
export TOWL_LLM_PROVIDER=codex
towl scan --ai
Or set in .towl.toml:
[llm]
provider = "claude-code" # or "codex"
# command = "/custom/path/to/claude" # optional override
# args = ["-p", "--output-format", "json"] # optional override
No TOWL_LLM_API_KEY is needed -- the CLI agents manage their own authentication.
Auto-fallback: If the CLI binary is not found on PATH, towl automatically falls back to the corresponding API provider (claude-code -> Claude API, codex -> OpenAI API). The API fallback requires TOWL_LLM_API_KEY to be set.
Using with Ollama or Local Models
export TOWL_LLM_PROVIDER=openai
export TOWL_LLM_MODEL=llama3
export TOWL_LLM_BASE_URL=http://localhost:11434/v1
export TOWL_LLM_API_KEY=ollama # Ollama doesn't need a real key
towl scan -N --ai
Rate Limiting
Two configurable limits prevent excessive API usage:
| Limit | Default | Config field |
|---|---|---|
| Concurrent requests | 5 | max_concurrent_analyses |
| Total TODOs analysed | 50 | max_analyse_count |
When the TODO count exceeds max_analyse_count, only the first N TODOs are analysed. A warning is logged for the remainder.
Output Formats
In non-interactive mode (-N), towl supports five output formats. Terminal-based formats write to stdout; file-based formats require the -o flag with a matching file extension.
Note: Output format flags only apply in non-interactive mode. The interactive TUI has its own display. Use
towl scan -N -f <format>to select a format.
Terminal / Table (default)
towl scan -N
# or explicitly:
towl scan -N -f table
towl scan -N -f terminal
Renders an ASCII table to stdout:
┌──────┬─────────────────────────┬──────────────────┬──────┬──────────┐
│ Type │ Description │ File │ Line │ Function │
├──────┼─────────────────────────┼──────────────────┼──────┼──────────┤
│ TODO │ Implement caching │ src/lib/cache.rs │ 42 │ process │
│ FIXME│ Handle timeout │ src/lib/net.rs │ 108 │ connect │
└──────┴─────────────────────────┴──────────────────┴──────┴──────────┘
Note:
tableandterminalare aliases -- both produce the same output.
JSON
towl scan -N -f json -o todos.json
Produces structured JSON with a summary and TODOs grouped by type:
{
"summary": {
"total": 2,
"by_type": {
"TODO": 1,
"FIXME": 1
}
},
"todos": {
"TODO": [
{
"id": "abc123",
"file_path": "src/lib/cache.rs",
"line_number": 42,
"column_start": 5,
"column_end": 30,
"todo_type": "Todo",
"description": "Implement caching",
"original_text": "// TODO: Implement caching",
"context_lines": ["fn process() {", " // TODO: Implement caching", " unimplemented!()"],
"function_context": "process"
}
]
}
}
CSV
towl scan -N -f csv -o todos.csv
Produces a CSV file with a header row:
Type,Description,File,Line,Column Start,Column End,Function,Original Text,Context Lines
TODO,Implement caching,src/lib/cache.rs,42,5,30,process,// TODO: Implement caching,"fn process() {| // TODO: Implement caching| unimplemented!()"
Context lines are joined with | separators within a single quoted field.
Markdown
towl scan -N -f markdown -o todos.md
Produces a Markdown document with sections grouped by TODO type:
# TODOs
## TODO (1)
### Implement caching
- **File:** src/lib/cache.rs
- **Line:** 42
- **Function:** process
**Context:**
> fn process() {
> // TODO: Implement caching
> unimplemented!()
TOML
towl scan -N -f toml -o todos.toml
Produces a TOML file with a summary table and grouped items:
[summary]
total = 2
[summary.by_type]
TODO = 1
FIXME = 1
[[todos.TODO]]
description = "Implement caching"
file_path = "src/lib/cache.rs"
line_number = 42
function_context = "process"
Extension Validation
File-based formats require the output path to have a matching extension:
| Format | Required extension |
|---|---|
json | .json |
csv | .csv |
toml | .toml |
markdown | .md |
Mismatched extensions produce an error:
Error: Invalid output path: expected .json extension for JSON format
Choosing a Format
| Use case | Format |
|---|---|
| Interactive browsing | TUI (default, no -N) |
| Quick terminal check | table (-N, default format) |
| CI/CD integration | json |
| Spreadsheet import | csv |
| Documentation / reports | markdown |
| Config-style tooling | toml |
Filtering
towl supports filtering scan results by TODO type using the -t / --todo-type flag.
Filter by Type
towl scan -t todo # Only TODO comments
towl scan -t fixme # Only FIXME comments
towl scan -t hack # Only HACK comments
towl scan -t note # Only NOTE comments
towl scan -t bug # Only BUG comments
The filter value is case-insensitive -- TODO, todo, and Todo all work.
Available Types
towl recognises five built-in TODO types:
| Type | Matches | Typical use |
|---|---|---|
todo | TODO: | Planned work |
fixme | FIXME: | Known broken code |
hack | HACK: | Temporary workarounds |
note | NOTE: | Important context |
bug | BUG: | Known defects |
Each type is matched via the corresponding regex pattern in the todo_patterns configuration. The default patterns are case-insensitive ((?i)).
Combining with Output Formats
Filtering works with any output format:
# FIXMEs as JSON
towl scan -t fixme -f json -o fixmes.json
# BUGs as Markdown
towl scan -t bug -f markdown -o bugs.md
# NOTEs in terminal table
towl scan -t note
Without Filtering
When no -t flag is provided, all recognised types are included in the output. The results are grouped by type in all formats.
Custom Patterns
You can add custom TODO patterns in .towl.toml. Each pattern must contain a capture group (.*) for extracting the description:
[parsing]
todo_patterns = [
"(?i)\\bTODO:\\s*(.*)",
"(?i)\\bFIXME:\\s*(.*)",
"(?i)\\bHACK:\\s*(.*)",
"(?i)\\bNOTE:\\s*(.*)",
"(?i)\\bBUG:\\s*(.*)",
"(?i)\\bXXX:\\s*(.*)",
]
Note: Custom patterns extend the set of matched comments but do not add new filter types to
-t. The built-in five types are always available for filtering.
API Overview
towl is structured as a library (towl crate) with a thin binary wrapper. The library exposes modules for configuration, scanning, parsing, output, and error handling.
Module Map
towl (lib)
├── cli Command-line argument parsing (clap)
├── comment TODO types and comment structures
│ ├── todo TodoType enum, TodoComment struct
│ └── error TowlCommentError
├── config Configuration loading and validation
│ ├── types TowlConfig, ParsingConfig, GitHubConfig, Owner, Repo
│ ├── git GitRepoInfo (git remote discovery)
│ └── error TowlConfigError
├── scanner Directory walking and file filtering
│ ├── types Scanner, ScanResult
│ └── error TowlScannerError
├── parser Regex-based TODO extraction
│ ├── types Parser, Pattern
│ └── error TowlParserError
├── output Formatting and writing results
│ ├── formatter
│ │ ├── formatters CsvFormatter, JsonFormatter, MarkdownFormatter,
│ │ │ TableFormatter, TomlFormatter
│ │ └── error FormatterError
│ ├── writer
│ │ ├── writers StdoutWriter, FileWriter
│ │ └── error WriterError
│ └── error TowlOutputError
├── github GitHub issue creation
│ ├── client GitHubClient
│ ├── types CreatedIssue
│ └── error TowlGitHubError
├── processor TODO replacement with issue links
│ ├── types Processor, ProcessorResult
│ └── error TowlProcessorError
├── llm LLM-powered TODO validation
│ ├── analyse analyse_todos, gather_expanded_context
│ ├── claude ClaudeProvider (Anthropic API)
│ ├── openai OpenAiProvider (OpenAI-compatible API)
│ ├── cli ClaudeCodeProvider, CodexProvider (CLI agents)
│ ├── prompt System prompt and user content construction
│ ├── types AnalysisResult, AnalysisSummary, Validity, LlmUsage
│ └── error TowlLlmError
├── tui Interactive terminal UI
│ ├── app App, AppMode, SortField, PeekState
│ ├── input Action, handle_input
│ ├── render draw
│ └── error TowlTuiError
└── error Top-level TowlError (aggregates all error types)
Data Flow
TowlConfig ──► Scanner ──► Parser ──► Output
│ │ │ │
│ │ │ ├─ FormatterImpl (enum dispatch)
│ │ │ └─ WriterImpl (enum dispatch)
│ │ │
│ │ └─ Vec<TodoComment>
│ │
│ └─ ScanResult { todos, files_scanned, ... }
│
├─ ParsingConfig + GitHubConfig + LlmConfig
│
└─ LlmConfig ──► LlmProvider ──► analyse_todos ──► AnalysisSummary
Key Types
| Type | Module | Purpose |
|---|---|---|
TowlConfig | config | Top-level configuration container |
ParsingConfig | config | File extensions, patterns, context lines |
GitHubConfig | config | Owner, repo, token |
Scanner | scanner | Directory walk + file filtering |
ScanResult | scanner | Structured scan output with metrics |
Parser | parser | Regex-based TODO extraction |
TodoComment | comment | A single extracted TODO item |
TodoType | comment | Enum: Todo, Fixme, Hack, Note, Bug |
Output | output | Formatter + writer combination |
GitHubClient | github | Authenticated GitHub API client |
CreatedIssue | github | Metadata for a created GitHub issue |
Processor | processor | Replaces TODOs with issue links in source files |
ProcessorResult | processor | Summary of a batch replacement operation |
LlmProvider | llm | Enum-dispatched LLM provider (Claude, OpenAI, CLI agents) |
AnalysisResult | llm | LLM validation result for a single TODO |
AnalysisSummary | llm | Aggregate counts from a batch analysis run |
Validity | llm | TODO validity classification (Valid, Invalid, Uncertain) |
App | tui | TUI application state and mode management |
AppMode | tui | Current UI mode (Browse, Peek, Confirm, etc.) |
TowlError | error | Top-level error aggregating all sub-errors |
Error Hierarchy
TowlError
├── TowlConfigError Config loading, TOML parsing, git discovery
├── TowlScannerError File walk, I/O, resource limits
│ └── TowlParserError Regex compilation, pattern validation
├── TowlOutputError Formatting, file writing
│ ├── FormatterError Serialisation failures
│ └── WriterError I/O, path traversal
├── TowlGitHubError API errors, auth, rate limiting
├── TowlProcessorError File replacement errors
├── TowlTuiError Terminal I/O errors
└── TowlLlmError LLM API, auth, parsing, I/O
All error types use thiserror for Display and Error trait implementations. Conversion between levels uses #[from] attributes for ergonomic ? propagation.
Constants
| Name | Value | Module | Purpose |
|---|---|---|---|
MAX_FILE_SIZE | 10 MB | scanner | Skip oversized files |
MAX_TODO_COUNT | 10,000 | scanner | Per-file TODO cap |
MAX_TOTAL_TODO_COUNT | 100,000 | scanner | Global TODO cap |
MAX_FILES_SCANNED | 100,000 | scanner | Directory walk cap |
MAX_PATTERN_LENGTH | 256 chars | parser | Regex length limit |
REGEX_SIZE_LIMIT | 256 KB | parser | Compiled regex size limit |
MAX_TOTAL_PATTERNS | 50 | parser | Total patterns across all categories |
MAX_CONFIG_PATTERNS | 100 | config | Per-field pattern array cap |
DEFAULT_CONFIG_PATH | .towl.toml | config | Default config file |
Scanner
The scanner walks a directory tree, filters files by extension and exclude patterns, reads content, and delegates to the parser for TODO extraction.
Scanner
#![allow(unused)] fn main() { pub struct Scanner { parser: Parser, config: ParsingConfig, } }
Constructor
#![allow(unused)] fn main() { pub fn new(config: ParsingConfig) -> Result<Self, TowlScannerError> }
Creates a new scanner. Compiles all regex patterns from the config during construction so pattern errors are caught early.
scan
#![allow(unused)] fn main() { pub async fn scan(&self, path: PathBuf) -> Result<ScanResult, TowlScannerError> }
Recursively scans path for TODO comments. Returns a ScanResult on success.
Behaviour:
- Validates the path (rejects path traversal)
- Walks the directory using the
ignorecrate (respects.gitignore) - Filters files by extension (
file_extensionsconfig) - Skips files matching
exclude_patterns - Skips files larger than
MAX_FILE_SIZE(10 MB) - Reads and parses each file asynchronously via
tokio::fs - Collects results until a resource limit is reached or the walk completes
Errors:
InvalidPath-- Path contains traversal components (..)FileTooLarge-- File exceeds 10 MBTooManyTodos-- Single file exceeds 10,000 TODOsTooManyFiles-- Walk exceeds 100,000 filesUnableToReadFileAtPath-- I/O error reading a specific fileUnableToWalkFile-- Directory walk errorParsingError-- Regex or parsing failure (propagated from parser)
ScanResult
#![allow(unused)] fn main() { pub struct ScanResult { pub todos: Vec<TodoComment>, pub files_scanned: usize, pub files_skipped: usize, pub files_errored: usize, pub duration: std::time::Duration, } }
Methods
#![allow(unused)] fn main() { pub const fn all_files_failed(&self) -> bool }
Returns true when files_scanned == 0 and files_errored > 0. Indicates a likely permissions or path issue where no files could be read.
#![allow(unused)] fn main() { pub const fn is_clean(&self) -> bool }
Returns true when todos is empty and files_errored == 0. A clean scan with no issues.
Resource Limits
| Constant | Value | Trigger |
|---|---|---|
MAX_FILE_SIZE | 10,485,760 bytes (10 MB) | File skipped |
MAX_TODO_COUNT | 10,000 | Error for that file |
MAX_TOTAL_TODO_COUNT | 100,000 | Scan stops, returns partial |
MAX_FILES_SCANNED | 100,000 | Scan stops, returns partial |
Example
#![allow(unused)] fn main() { use towl::config::ParsingConfig; use towl::scanner::Scanner; use std::path::PathBuf; let config = ParsingConfig::default(); let scanner = Scanner::new(config)?; let result = scanner.scan(PathBuf::from(".")).await?; println!("Found {} TODOs in {} files", result.todos.len(), result.files_scanned); if result.all_files_failed() { eprintln!("Warning: no files could be read"); } }
Parser
The parser reads file content, identifies comment lines using regex patterns, extracts TODO items, and captures surrounding context.
Parser
#![allow(unused)] fn main() { pub struct Parser { comment_patterns: Vec<Regex>, patterns: Vec<Pattern>, function_patterns: Vec<Regex>, context_lines: usize, } }
The parser is pub(crate) -- it is used internally by Scanner and not exposed in the public API. The public interface is through the module-level functions.
Construction
Created internally by Scanner::new() using Parser::new(config). All regex patterns are compiled once during construction.
Public Functions
validate_patterns
#![allow(unused)] fn main() { pub fn validate_patterns(config: &ParsingConfig) -> Result<(), TowlParserError> }
Validates all regex patterns in the config without creating a parser. Useful for checking configuration before starting a scan.
Checks:
- Each pattern is valid regex
- Each pattern is within
MAX_PATTERN_LENGTH(256 characters) - Compiled regex is within
REGEX_SIZE_LIMIT(256 KB)
parse_content
#![allow(unused)] fn main() { pub fn parse_content( config: &ParsingConfig, path: &Path, content: &str, ) -> Result<Vec<TodoComment>, TowlParserError> }
Parses file content for TODO comments. Creates a temporary parser, runs extraction, and returns the results.
Parsing Pipeline
For each line in the file:
- Comment detection -- Check if the line matches any
comment_prefixespattern - TODO matching -- Check if the comment matches any
todo_patternspattern - Type classification -- Determine the
TodoTypefrom the matched pattern - Description extraction -- Extract the description via the first capture group
(.*) - Context capture -- Grab
include_context_lineslines above and below - Function detection -- Search upward (within 3 lines) for a
function_patternsmatch
Pattern Types
Comment Prefixes
Regex patterns that identify comment lines:
| Default pattern | Matches |
|---|---|
// | C-style line comments |
^\s*# | Shell/Python comments |
/\* | C-style block comment start |
^\s*\* | C-style block comment continuation |
TODO Patterns
Regex patterns with a capture group for the description:
| Default pattern | Matches |
|---|---|
(?i)\bTODO:\s*(.*) | TODO comments |
(?i)\bFIXME:\s*(.*) | FIXME comments |
(?i)\bHACK:\s*(.*) | HACK comments |
(?i)\bNOTE:\s*(.*) | NOTE comments |
(?i)\bBUG:\s*(.*) | BUG comments |
All default patterns are case-insensitive ((?i)).
Function Patterns
Regex patterns to detect enclosing function names:
| Default pattern | Language |
|---|---|
^\s*(pub\s+)?fn\s+(\w+) | Rust |
^\s*def\s+(\w+) | Python |
^\s*(async\s+)?function\s+(\w+) | JavaScript |
^\s*(public|private|protected)?\s*(static\s+)?\w+\s+(\w+)\s*\( | Java/C# |
^\s*func\s+(\w+) | Go/Swift |
Constants
| Constant | Value | Purpose |
|---|---|---|
MIN_CONTEXT_LINES | 1 | Minimum context window |
MAX_CONTEXT_LINES | 50 | Maximum context window |
FORWARD_SEARCH_LINES | 3 | Lines searched upward for function context |
MAX_PATTERN_LENGTH | 256 | Maximum regex pattern string length |
REGEX_SIZE_LIMIT | 262,144 | Maximum compiled regex size (256 KB) |
MAX_TOTAL_PATTERNS | 50 | Maximum total patterns across all categories |
Errors
#![allow(unused)] fn main() { pub enum TowlParserError { InvalidRegexPattern(String, regex::Error), UnknownConfigPattern(TowlCommentError), RegexGroupMissing, PatternTooLong(usize, usize), TooManyTotalPatterns { count: usize, max_allowed: usize }, } }
| Variant | Cause |
|---|---|
InvalidRegexPattern | Regex failed to compile |
UnknownConfigPattern | Pattern matched but type could not be determined |
RegexGroupMissing | Pattern lacks a capture group |
PatternTooLong | Pattern exceeds 256 characters |
TooManyTotalPatterns | Total patterns across all categories exceeds 50 |
Config
The config module loads settings from .towl.toml, merges environment variables, and provides the init command for generating default configuration.
TowlConfig
#![allow(unused)] fn main() { pub struct TowlConfig { pub parsing: ParsingConfig, pub github: GitHubConfig, pub llm: LlmConfig, } }
TowlConfig::load
#![allow(unused)] fn main() { impl TowlConfig { pub fn load(path: Option<&PathBuf>) -> Result<Self, TowlConfigError>; } }
Loads configuration with this precedence:
- Built-in defaults
- Config file resolved as: explicit
pathargument >TOWL_CONFIGenv var >.towl.toml - Git remote auto-detection for owner/repo
- Environment variable overrides (
TOWL_GITHUB_*,TOWL_LLM_*)
If no config file exists, defaults are used without error.
init
#![allow(unused)] fn main() { pub async fn init(path: &Path, force: bool) -> Result<(), TowlConfigError> }
Creates a .towl.toml file at the given path. Validates that a GitHub git remote exists but does not write owner/repo to the file (they are always detected at runtime).
- Fails if the file already exists (unless
forceistrue) - Validates the path for traversal attacks
- Serializes
ParsingConfigandLlmConfigdefaults to TOML
ParsingConfig
#![allow(unused)] fn main() { pub struct ParsingConfig { pub file_extensions: HashSet<String>, pub exclude_patterns: Vec<String>, pub include_context_lines: usize, pub comment_prefixes: Vec<String>, pub todo_patterns: Vec<String>, pub function_patterns: Vec<String>, } }
All fields have defaults via #[serde(default)]:
| Field | Default |
|---|---|
file_extensions | rs, toml, json, yaml, yml, sh, bash |
exclude_patterns | target/*, .git/* |
include_context_lines | 10 |
comment_prefixes | //, ^\s*#, /\*, ^\s*\* |
todo_patterns | TODO:, FIXME:, HACK:, NOTE:, BUG: (case-insensitive) |
function_patterns | Rust, Python, JS, Java/C#, Go patterns |
Each pattern array is limited to MAX_CONFIG_PATTERNS (100) entries.
GitHubConfig
#![allow(unused)] fn main() { pub struct GitHubConfig { pub token: SecretString, pub owner: Owner, pub repo: Repo, pub rate_limit_delay_ms: u64, } }
tokenis stored assecrecy::SecretStringand masked in debug/display outputownerandrepoare auto-detected fromgit remote get-url originat runtime (not serialised to config)rate_limit_delay_msadds a delay between GitHub API calls (default: 1000ms)
Environment Variable Overrides
| Variable | Overrides |
|---|---|
TOWL_CONFIG | DEFAULT_CONFIG_PATH (overridden by explicit path argument) |
TOWL_GITHUB_TOKEN | -- (env-only) |
TOWL_GITHUB_OWNER | git remote detection |
TOWL_GITHUB_REPO | git remote detection |
Owner / Repo
Validated newtype wrappers providing type safety:
#![allow(unused)] fn main() { pub struct Owner(String); pub struct Repo(String); }
try_new
#![allow(unused)] fn main() { pub fn try_new(s: impl Into<String>) -> Result<Self, TowlConfigError> }
Constructs a new Owner or Repo, rejecting values exceeding MAX_CONFIG_STRING_LENGTH (512 characters).
Errors:
ConfigValueTooLong-- Value exceeds 512 characters
Both also implement:
Display,Default,Debug,Clone,PartialEq,EqSerialize,Deserialize
LlmConfig
#![allow(unused)] fn main() { pub struct LlmConfig { pub provider: String, pub model: String, pub base_url: Option<String>, pub api_key: SecretString, pub max_concurrent_analyses: usize, pub max_analyse_count: usize, pub max_tokens: u32, pub max_retries: usize, pub command: Option<String>, pub args: Option<Vec<String>>, } }
api_keyis stored assecrecy::SecretStringand masked in debug output (env-only viaTOWL_LLM_API_KEY)providerselects the LLM backend:"claude","openai","claude-code", or"codex"commandandargsallow overriding the CLI binary path and arguments for CLI providers
| Field | Default |
|---|---|
provider | "claude" |
model | "claude-opus-4-6" |
base_url | None (provider default) |
max_concurrent_analyses | 5 |
max_analyse_count | 50 |
max_tokens | 4096 |
max_retries | 3 |
Environment Variable Overrides
| Variable | Overrides |
|---|---|
TOWL_LLM_API_KEY | -- (env-only) |
TOWL_LLM_PROVIDER | llm.provider |
TOWL_LLM_MODEL | llm.model |
TOWL_LLM_BASE_URL | llm.base_url |
GitRepoInfo (internal)
#![allow(unused)] fn main() { pub(crate) struct GitRepoInfo { pub owner: Owner, pub repo: Repo, } }
from_path
#![allow(unused)] fn main() { pub(crate) async fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, TowlConfigError> }
Internal function that discovers the git remote URL by running git remote get-url origin and parses the owner and repo name. Supports both HTTPS and SSH URL formats. Not part of the public API.
Errors:
GitRepoNotFound-- Not inside a git repositoryGitRemoteNotFound-- Nooriginremote configuredGitInvalidUrl-- Could not parse owner/repo from the URL
Errors
#![allow(unused)] fn main() { pub enum TowlConfigError { PathTraversalAttempt(PathBuf), ConfigAlreadyExists(PathBuf), WriteToFileError(PathBuf, std::io::Error), UnableToParseToml(toml::ser::Error), CouldNotCreateConfig(ConfigError), GitRepoNotFound { message: String }, GitRemoteNotFound { message: String }, GitInvalidUrl { url: String, message: String }, TooManyConfigPatterns { field: String, count: usize, max_allowed: usize }, ConfigValueTooLong { field: String, length: usize, max_length: usize }, ContextLinesOutOfRange { value: usize, min: usize, max: usize }, } }
Constants
| Constant | Value | Purpose |
|---|---|---|
DEFAULT_CONFIG_PATH | .towl.toml | Default config file name |
MAX_CONFIG_PATTERNS | 100 | Maximum entries per pattern array |
MAX_CONFIG_STRING_LENGTH | 512 | Maximum length for any single config string |
MIN_CONTEXT_LINES | 1 | Minimum include_context_lines value |
MAX_CONTEXT_LINES | 50 | Maximum include_context_lines value |
Output
The output module combines a formatter and a writer to produce scan results in the requested format and destination.
Output
#![allow(unused)] fn main() { pub struct Output { writer: WriterImpl, formatter: FormatterImpl, } }
Constructor
#![allow(unused)] fn main() { pub fn new( output_format: OutputFormat, output_path: Option<PathBuf>, ) -> Result<Self, TowlOutputError> }
Creates an output handler by selecting the appropriate formatter and writer.
Format-to-writer mapping:
| Format | Writer | Output path |
|---|---|---|
Table / Terminal | StdoutWriter | Must be None |
Json | FileWriter | Required, must end in .json |
Csv | FileWriter | Required, must end in .csv |
Toml | FileWriter | Required, must end in .toml |
Markdown | FileWriter | Required, must end in .md |
save
#![allow(unused)] fn main() { pub async fn save(&self, todos: &[TodoComment]) -> Result<(), TowlOutputError> }
Formats the TODOs and writes them to the destination. TODOs are grouped by type before formatting.
OutputFormat
#![allow(unused)] fn main() { pub enum OutputFormat { Table, Json, Csv, Toml, Markdown, Terminal, } }
Used as a CLI argument via clap::ValueEnum. Table and Terminal are treated identically.
Formatter Dispatch
Internally, FormatterImpl is an enum that dispatches to the correct formatter without dynamic dispatch:
#![allow(unused)] fn main() { pub(crate) enum FormatterImpl { Csv(CsvFormatter), Json(JsonFormatter), Markdown(MarkdownFormatter), Table(TableFormatter), Toml(TomlFormatter), } }
Each formatter implements the internal Formatter trait:
#![allow(unused)] fn main() { pub(crate) trait Formatter { fn format( &self, todos: &HashMap<&TodoType, Vec<&TodoComment>>, total_count: usize, ) -> Result<Vec<String>, FormatterError>; } }
Writer Dispatch
WriterImpl dispatches between stdout and file output:
#![allow(unused)] fn main() { pub(crate) enum WriterImpl { Stdout(StdoutWriter), File(FileWriter), } }
Each writer implements the internal Writer trait:
#![allow(unused)] fn main() { pub(crate) trait Writer { async fn write(&self, content: Vec<String>) -> Result<(), WriterError>; } }
FileWriter
Validates the output path on construction:
- Rejects path traversal (
..components) - Resolves symlinks before writing
StdoutWriter
Writes each formatted line to stdout followed by a newline.
Errors
TowlOutputError
#![allow(unused)] fn main() { pub enum TowlOutputError { InvalidOutputPath(String), UnableToFormatTodos(FormatterError), UnableToWriteTodos(WriterError), } }
FormatterError
#![allow(unused)] fn main() { pub enum FormatterError { SerializationError(String), IntegerOverflow(usize), } }
WriterError
#![allow(unused)] fn main() { pub enum WriterError { IoError(std::io::Error), PathTraversal(PathBuf), } }
GitHub
The GitHub module creates issues from TODO comments, detects duplicates, and handles rate limiting.
GitHubClient
#![allow(unused)] fn main() { pub struct GitHubClient { // private fields } }
Authenticated GitHub API client for creating issues from TODO comments. Maintains a cache of existing issue titles and TODO IDs for deduplication. Includes rate-limit handling with configurable delays and automatic retries.
new
#![allow(unused)] fn main() { pub fn new(config: &GitHubConfig) -> Result<Self, TowlGitHubError> }
Creates a new client from a GitHubConfig. Exposes the SecretString token once to build the Octocrab API client.
Errors:
MissingToken-- Token is emptyApiError-- Octocrab client failed to build
load_existing_issues
#![allow(unused)] fn main() { pub async fn load_existing_issues(&mut self) -> Result<(), TowlGitHubError> }
Paginates through all existing issues (open and closed) in the repository, caching their titles and embedded TODO IDs. Call this before create_issue to enable duplicate detection.
Errors:
ApiError-- GitHub API call failedAuthError-- Invalid or expired tokenRepositoryNotFound-- Owner/repo combination does not exist
issue_exists
#![allow(unused)] fn main() { pub fn issue_exists(&self, todo: &TodoComment) -> bool }
Returns true if a matching issue already exists, checked by TODO ID (embedded in issue body) or by generated title.
create_issue
#![allow(unused)] fn main() { pub async fn create_issue( &mut self, todo: &TodoComment, ) -> Result<CreatedIssue, TowlGitHubError> }
Creates a GitHub issue for a TODO comment. Generates a title with type prefix, truncated description, and file location. The body includes file path, line number, column range, description, function context, original comment, and surrounding code.
Automatically retries on rate limiting (up to 3 attempts).
Errors:
IssueAlreadyExists-- Duplicate detectedRateLimitExceeded-- Rate limit hit after max retriesApiError-- GitHub API failureAuthError-- Authentication failure
Issue Title Format
[TODO] Implement caching (cache.rs:42)
Titles are capped at 50 characters (excluding the type prefix and location suffix). Long descriptions are truncated at word boundaries with ....
Issue Body Sections
- TODO Details -- Type, file, line, column range
- Description -- Extracted description text (Markdown-escaped)
- Function Context -- Enclosing function name (if detected)
- Original Comment -- Full comment line in a code block
- Context -- Surrounding source lines in a code block
- TODO ID -- Embedded identifier for deduplication
Duplicate Detection
Issues are deduplicated by two methods:
- TODO ID -- Each issue body contains
*TODO ID: {file_path}_L{line_number}*. If any existing issue body contains the same ID, the TODO is skipped. - Title match -- If the generated title matches an existing issue title exactly, the TODO is skipped.
CreatedIssue
#![allow(unused)] fn main() { pub struct CreatedIssue { pub number: u64, pub title: String, pub html_url: String, pub todo_id: String, } }
Metadata for a successfully created GitHub issue. Implements Serialize and Deserialize for JSON roundtripping.
Errors
#![allow(unused)] fn main() { pub enum TowlGitHubError { ApiError { message: String, source: Option<octocrab::Error> }, AuthError, RateLimitExceeded { retry_after_secs: u64 }, IssueAlreadyExists { title: String }, RepositoryNotFound { owner: String, repo: String }, MissingToken, } }
| Variant | Cause |
|---|---|
ApiError | General GitHub API failure |
AuthError | 401 response -- invalid or expired token |
RateLimitExceeded | 403 with "rate limit" in message |
IssueAlreadyExists | Duplicate detected before creation |
RepositoryNotFound | 404 response -- owner/repo not found |
MissingToken | TOWL_GITHUB_TOKEN not set or empty |
Example
#![allow(unused)] fn main() { use towl::config::TowlConfig; use towl::github::GitHubClient; let config = TowlConfig::load(None)?; let mut client = GitHubClient::new(&config.github)?; // Load existing issues for duplicate detection client.load_existing_issues().await?; // Create an issue (skips if duplicate) if !client.issue_exists(&todo) { let issue = client.create_issue(&todo).await?; println!("Created #{}: {}", issue.number, issue.html_url); } }
LLM
The LLM module provides AI-powered TODO validation using Claude (Anthropic API) or any OpenAI-compatible endpoint.
LlmProvider
#![allow(unused)] fn main() { pub enum LlmProvider { Claude(ClaudeProvider), OpenAi(OpenAiProvider), ClaudeCode(ClaudeCodeProvider), Codex(CodexProvider), } }
Dispatches LLM calls to the configured provider. Follows towl's existing enum dispatch pattern (FormatterImpl, WriterImpl).
call_raw
#![allow(unused)] fn main() { pub async fn call_raw( &self, user_content: &str, system_prompt: &str, api_key: &SecretString, ) -> Result<(String, LlmUsage), TowlLlmError> }
Sends a prompt to the LLM and returns the response text and token usage.
build_provider
#![allow(unused)] fn main() { pub fn build_provider(config: &LlmConfig) -> Result<LlmProvider, TowlLlmError> }
Factory function that creates the appropriate provider from configuration.
analyse_todos
#![allow(unused)] fn main() { pub async fn analyse_todos( todos: &mut [TodoComment], config: &LlmConfig, on_progress: impl FnMut(usize, usize), ) -> Result<AnalysisSummary, TowlLlmError> }
Main entry point for TODO analysis. Calls on_progress(completed, total) after each TODO is analysed, allowing callers to render progress feedback (e.g. a progress bar).
For each TODO (up to max_analyse_count):
- Reads expanded context (~30 lines around the TODO + full function body)
- Constructs a prompt with the TODO description, file path, and code context
- Calls the LLM to determine validity
- Parses the structured JSON response into an
AnalysisResult - Attaches the result to
TodoComment.analysis - Calls
on_progresswith the current count
Errors:
NotConfigured--TOWL_LLM_API_KEYnot setUnsupportedProvider-- Provider is not "claude" or "openai"ApiError,AuthError,RateLimited-- From the LLM API
Validity
#![allow(unused)] fn main() { pub enum Validity { Valid, Invalid, Uncertain, } }
Whether a TODO is still valid:
| Value | Meaning |
|---|---|
Valid | TODO describes work that still needs to be done |
Invalid | TODO has been resolved, is irrelevant, or is nonsensical |
Uncertain | Cannot determine validity from available context |
AnalysisResult
#![allow(unused)] fn main() { pub struct AnalysisResult { pub validity: Validity, pub reasoning: String, pub is_resolved: bool, pub is_relevant: bool, pub is_actionable: bool, pub confidence: f64, pub enrichment: String, } }
| Field | Description |
|---|---|
validity | Overall assessment |
reasoning | Explanation of why the TODO is valid/invalid/uncertain |
is_resolved | Whether the code already implements what the TODO asks |
is_relevant | Whether the code/feature the TODO references still exists |
is_actionable | Whether the TODO describes a clear, specific task |
confidence | 0.0-1.0 confidence in the assessment |
enrichment | Enhanced description suitable for a GitHub issue body |
AnalysisSummary
#![allow(unused)] fn main() { pub struct AnalysisSummary { pub valid_count: usize, pub invalid_count: usize, pub uncertain_count: usize, pub error_count: usize, } }
Summary counts returned by analyse_todos().
Providers
ClaudeProvider
POST to https://api.anthropic.com/v1/messages with headers:
x-api-key: API keyanthropic-version:2023-06-01
System prompt is a top-level system field (not in the messages array).
OpenAiProvider
POST to {base_url}/chat/completions with Authorization: Bearer {key}.
Default base URL: https://api.openai.com/v1. Configurable for Ollama, vLLM, etc.
System prompt is the first message in the messages array with role: "system".
ClaudeCodeProvider
Invokes the claude CLI as a subprocess with -p --output-format json. The combined system prompt and user content are passed as the final argument. No API key required.
Default command: claude. Configurable via llm.command and llm.args.
Auto-falls back to ClaudeProvider (API) if the CLI binary is not found on PATH.
CodexProvider
Invokes the codex CLI as a subprocess with -q. The combined prompt is passed as the final argument. No API key required.
Default command: codex. Configurable via llm.command and llm.args.
Auto-falls back to OpenAiProvider (API) with gpt-4o if the CLI binary is not found on PATH.
is_cli_provider
#![allow(unused)] fn main() { pub const fn is_cli_provider(&self) -> bool }
Returns true for ClaudeCode and Codex variants. Used to skip the API key requirement for CLI-based providers.
Configuration
See Configuration for the [llm] config section.
Errors
#![allow(unused)] fn main() { pub enum TowlLlmError { ApiError { message: String, status: Option<u16> }, AuthError, RateLimited { retry_after_secs: u64 }, ParseError { message: String }, NotConfigured, UnsupportedProvider { provider: String }, IoError { message: String }, } }
| Variant | Cause |
|---|---|
ApiError | LLM API returned a non-200 status |
AuthError | 401 -- invalid or missing API key |
RateLimited | 429 -- too many requests |
ParseError | LLM response could not be parsed as valid JSON |
NotConfigured | TOWL_LLM_API_KEY environment variable not set |
UnsupportedProvider | Provider is not "claude", "openai", "claude-code", or "codex" |
IoError | File I/O error during context gathering |
Retryable Errors
TowlLlmError implements is_retryable() which returns true for:
RateLimited-- always retryableApiErrorwith status >= 500 -- server errorsApiErrorwith no status -- network failures
Processor
The processor replaces TODO comments in source files with GitHub issue links after issues are created.
Processor
#![allow(unused)] fn main() { pub struct Processor; }
Stateless processor that operates on batches of (TodoComment, CreatedIssue) pairs. All methods are associated functions (no self).
replace_todos
#![allow(unused)] fn main() { pub async fn replace_todos( repo_root: &Path, replacements: &[(TodoComment, CreatedIssue)], ) -> ProcessorResult }
Replaces TODO comments in source files with GH_ISSUE: <issue_url> links.
Behaviour:
- Groups replacements by file path for efficient batch processing
- For each file, validates the path stays within
repo_root - Reads file content, replaces each TODO line, writes back atomically
- Returns a
ProcessorResultwith counts and per-file errors
Path safety:
- Both the file path and repo root are canonicalised before comparison
- Files outside the repo root are rejected with
PathOutsideRoot - Issue URLs must start with
https://github.com/or are rejected
Replacement format:
The comment prefix (e.g., // , # , /* ) is preserved. The TODO text after the prefix is replaced:
// TODO: Implement caching --> // GH_ISSUE: https://github.com/owner/repo/issues/42
# FIXME: Handle timeout --> # GH_ISSUE: https://github.com/owner/repo/issues/43
Atomic writes:
Files are written via a tempfile in the same directory, then atomically persisted. This prevents partial writes if the process is interrupted.
Empty input:
If replacements is empty, returns immediately with zero counts and no I/O.
ProcessorResult
#![allow(unused)] fn main() { pub struct ProcessorResult { pub files_modified: usize, pub todos_replaced: usize, pub errors: Vec<(PathBuf, TowlProcessorError)>, } }
Summary of a batch replacement operation. The errors field contains per-file errors that did not abort the overall operation -- other files continue processing.
Errors
#![allow(unused)] fn main() { pub enum TowlProcessorError { FileReadError(PathBuf, std::io::Error), FileWriteError(PathBuf, std::io::Error), LineOutOfBounds { path: PathBuf, line: usize, total_lines: usize }, CommentPrefixNotFound { path: PathBuf, line: usize }, PathOutsideRoot { path: PathBuf, root: PathBuf }, InvalidIssueUrl { url: String }, } }
| Variant | Cause |
|---|---|
FileReadError | Failed to read source file |
FileWriteError | Failed to write modified file |
LineOutOfBounds | TODO line number exceeds file length |
CommentPrefixNotFound | Column offset points past end of line |
PathOutsideRoot | File is outside the repository root |
InvalidIssueUrl | URL does not start with https://github.com/ |
Example
#![allow(unused)] fn main() { use towl::processor::Processor; let replacements = vec![(todo, created_issue)]; let result = Processor::replace_todos(repo_root, &replacements).await; println!("Modified {} files, replaced {} TODOs", result.files_modified, result.todos_replaced); for (path, err) in &result.errors { eprintln!("Error in {}: {}", path.display(), err); } }
TUI
The TUI module provides an interactive terminal interface for browsing, filtering, and acting on TODO comments. Built on ratatui and crossterm.
run
#![allow(unused)] fn main() { pub fn run( todos: Vec<TodoComment>, github_config: &GitHubConfig, repo_root: &Path, ) -> Result<(), TowlTuiError> }
Launches the interactive TUI. Takes ownership of the terminal (raw mode, alternate screen). Terminal state is always restored on exit, even on error.
Errors:
TowlTuiError::Io-- Terminal I/O failure
App
#![allow(unused)] fn main() { pub struct App { // private fields } }
Core TUI application state. Manages the TODO list, selection set, cursor position, filtering, sorting, and mode transitions.
Constructor
#![allow(unused)] fn main() { pub fn new(todos: Vec<TodoComment>) -> Self }
Creates a new app with all TODOs visible, no selection, sorted by file path.
State Accessors
| Method | Returns | Description |
|---|---|---|
todos() | &[TodoComment] | Full TODO list |
filtered_indices() | &[usize] | Indices into todos() after filtering/sorting |
cursor() | usize | Current cursor position in filtered list |
filter_type() | Option<TodoType> | Active type filter (None = show all) |
sort_field() | SortField | Current sort field |
sort_ascending() | bool | Sort direction |
is_selected(idx) | bool | Whether a TODO index is selected |
selected_count() | usize | Number of selected TODOs |
selected_todos() | Vec<TodoComment> | Cloned copies of selected TODOs |
mode() | &AppMode | Current UI mode |
Navigation
| Method | Effect |
|---|---|
move_up() | Move cursor up (clamped to 0) |
move_down() | Move cursor down (clamped to list end) |
Selection
| Method | Effect |
|---|---|
toggle_select() | Toggle selection on cursor item |
select_all_visible() | Select all items in filtered view |
deselect_all() | Clear all selections |
Filtering and Sorting
| Method | Effect |
|---|---|
cycle_filter() | Cycle: All -> TODO -> FIXME -> HACK -> NOTE -> BUG -> All |
cycle_sort() | Cycle: File -> Line -> Priority -> Type -> File |
reverse_sort() | Toggle ascending/descending |
Mode Transitions
| Method | Transition |
|---|---|
enter_confirm() | Browse -> Confirm (requires selection) |
cancel_confirm() | Confirm -> Browse |
start_creating() | Confirm -> Creating |
finish_creating() | Creating -> Done |
enter_peek() | Browse -> Peek (loads source context) |
exit_peek() | Peek -> Browse |
AppMode
#![allow(unused)] fn main() { pub enum AppMode { Browse, Peek(PeekState), Confirm, Creating(CreatingState), Done(DoneState), DeleteConfirm(Vec<TodoComment>), } }
The current UI mode determines which view is rendered and which keys are active.
| Mode | View | Input |
|---|---|---|
Browse | Scrollable TODO list | Navigate, select, filter, sort, peek |
Peek | Source code overlay around a TODO | Scroll, dismiss |
Confirm | Summary of selected TODOs | Confirm or cancel |
Creating | Progress indicator during issue creation | None (Ctrl+C to abort) |
Done | Results summary (issues created, errors) | Dismiss to exit |
DeleteConfirm | Confirmation dialog for deleting invalid TODOs | Confirm or cancel |
SortField
#![allow(unused)] fn main() { pub enum SortField { File, Line, Priority, Type, } }
Field used to sort the TODO list. Cycle with the s key in Browse mode.
- File -- Sort by file path, then by line number within each file
- Line -- Sort by line number globally
- Priority -- Sort by TODO type priority (Bug=1, Fixme=2, Hack=3, Todo=4, Note=5)
- Type -- Sort alphabetically by type name
Supporting Types
PeekState
#![allow(unused)] fn main() { pub struct PeekState { pub lines: Vec<(usize, String)>, pub file: String, pub todo_line: usize, pub scroll: usize, pub analysis: Option<AnalysisResult>, } }
State for the source-code peek overlay. Contains numbered source lines around the TODO, with scroll position. When --ai is active, analysis holds the LLM result -- the reasoning text is word-wrapped to the popup width during rendering.
CreatingState
#![allow(unused)] fn main() { pub struct CreatingState { pub phase: String, pub progress: usize, pub total: usize, pub errors: Vec<String>, pub created_issues: Vec<CreatedIssue>, } }
State tracked during background GitHub issue creation. Updated via channel messages from the spawned task.
DoneState
#![allow(unused)] fn main() { pub struct DoneState { pub created_issues: Vec<CreatedIssue>, pub errors: Vec<String>, } }
Final state after issue creation completes, showing results and any errors.
Action
#![allow(unused)] fn main() { pub enum Action { Continue, Quit, } }
Result of processing a keyboard event. Continue keeps the event loop running; Quit exits the TUI.
handle_input
#![allow(unused)] fn main() { pub fn handle_input( app: &mut App, timeout: std::time::Duration, ) -> std::io::Result<Action> }
Polls for keyboard input and dispatches to mode-specific handlers. Returns Action::Quit on q, Esc (in appropriate modes), or Ctrl+C.
Errors
#![allow(unused)] fn main() { pub enum TowlTuiError { Io(std::io::Error), } }
Terminal I/O errors from crossterm or ratatui operations.
Types
Core data types used across the towl library.
TodoType
#![allow(unused)] fn main() { pub enum TodoType { Todo, Fixme, Hack, Note, Bug, } }
Represents the category of a TODO comment.
Display
| Variant | Display |
|---|---|
Todo | TODO |
Fixme | FIXME |
Hack | HACK |
Note | NOTE |
Bug | BUG |
as_filter_str
#![allow(unused)] fn main() { pub const fn as_filter_str(&self) -> &'static str }
Returns the lowercase filter string used for CLI filtering:
| Variant | Filter string |
|---|---|
Todo | "todo" |
Fixme | "fixme" |
Hack | "hack" |
Note | "note" |
Bug | "bug" |
Conversions
TryFrom<&str>-- Case-insensitive conversion from stringclap::ValueEnum-- CLI argument parsing
Trait Implementations
Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum
TodoComment
#![allow(unused)] fn main() { pub struct TodoComment { pub id: String, pub file_path: PathBuf, pub line_number: usize, pub column_start: usize, pub column_end: usize, pub todo_type: TodoType, pub original_text: String, pub description: String, pub context_lines: Vec<String>, pub function_context: Option<String>, pub analysis: Option<AnalysisResult>, } }
A single TODO comment extracted from a source file.
| Field | Description |
|---|---|
id | Unique identifier (generated per extraction) |
file_path | Path to the source file |
line_number | 1-based line number |
column_start | 0-based start column of the TODO marker |
column_end | 0-based end column of the TODO marker |
todo_type | Category (Todo, Fixme, etc.) |
original_text | The full original comment line |
description | Extracted description text after the marker |
context_lines | Surrounding source lines (configurable window) |
function_context | Enclosing function name, if detected |
analysis | LLM validation result, populated when --ai is used (skipped during serialisation if None) |
Trait Implementations
Debug, Clone, PartialEq, Serialize, Deserialize
ScanResult
#![allow(unused)] fn main() { pub struct ScanResult { pub todos: Vec<TodoComment>, pub files_scanned: usize, pub files_skipped: usize, pub files_errored: usize, pub duration: std::time::Duration, } }
Returned by Scanner::scan(). See Scanner for details.
Owner / Repo
Newtype wrappers for GitHub owner and repository names:
#![allow(unused)] fn main() { pub struct Owner(String); pub struct Repo(String); }
Both provide try_new(impl Into<String>) -> Result<Self, TowlConfigError> (validates length) and Display. See Config for details.
OutputFormat
#![allow(unused)] fn main() { pub enum OutputFormat { Table, Json, Csv, Toml, Markdown, Terminal, } }
CLI-facing enum for selecting output format. Table and Terminal produce identical output. See Output for details.
Errors
towl uses typed errors throughout, built with thiserror. Each module defines its own error enum, and the top-level TowlError aggregates them.
Error Hierarchy
TowlError
├── TowlConfigError
├── TowlScannerError
│ └── TowlParserError
│ └── TowlCommentError
├── TowlOutputError
│ ├── FormatterError
│ └── WriterError
├── TowlGitHubError
├── TowlProcessorError
├── TowlTuiError
└── TowlLlmError
TowlError
Top-level error type used by the CLI binary. All sub-error types convert automatically via #[from].
#![allow(unused)] fn main() { pub enum TowlError { Config(TowlConfigError), Scanner(TowlScannerError), Output(TowlOutputError), GitHub(TowlGitHubError), Processor(TowlProcessorError), Tui(TowlTuiError), Llm(TowlLlmError), } }
TowlConfigError
Errors during configuration loading, initialisation, and validation.
| Variant | Cause |
|---|---|
PathTraversalAttempt(PathBuf) | Config path contains .. |
ConfigAlreadyExists(PathBuf) | towl init without --force on existing file |
WriteToFileError(PathBuf, io::Error) | Failed to write config file |
UnableToParseToml(toml::ser::Error) | TOML serialisation failure |
CouldNotCreateConfig(ConfigError) | Config crate loading error |
GitRepoNotFound { message } | Not inside a git repository |
GitRemoteNotFound { message } | No origin remote |
GitInvalidUrl { url, message } | Cannot parse owner/repo from remote URL |
TooManyConfigPatterns { field, count, max_allowed } | Pattern array exceeds 100 entries |
ConfigValueTooLong { field, length, max_length } | Config string exceeds 512 characters |
ContextLinesOutOfRange { value, min, max } | Context lines outside 1..=50 |
RateLimitDelayTooHigh { value, max } | Rate limit delay exceeds maximum |
TowlScannerError
Errors during directory walking and file reading.
| Variant | Cause |
|---|---|
UnableToWalkFile(ignore::Error) | Directory traversal error |
ParsingError(TowlParserError) | Parser failure (propagated) |
UnableToReadFileAtPath(PathBuf, io::Error) | File I/O error |
InvalidPath { path } | Path could not be canonicalised |
FileTooLarge { path, size, max_allowed } | File exceeds 10 MB |
TooManyTodos { path, count, max_allowed } | File exceeds 10,000 TODOs |
TowlParserError
Errors during regex compilation and TODO extraction.
| Variant | Cause |
|---|---|
InvalidRegexPattern(String, regex::Error) | Regex failed to compile |
UnknownConfigPattern(TowlCommentError) | Pattern matched but type unknown |
RegexGroupMissing | Pattern lacks a capture group (.*) |
PatternTooLong(usize, usize) | Pattern exceeds 256 characters |
TooManyTotalPatterns { count, max_allowed } | Total patterns across all categories exceeds 50 |
TowlCommentError
Errors in comment type resolution.
| Variant | Cause |
|---|---|
UnknownTodoType { comment } | String does not map to a known TodoType |
TowlOutputError
Errors during formatting and writing.
| Variant | Cause |
|---|---|
InvalidOutputPath(String) | Missing/wrong extension, terminal format with file path |
UnableToFormatTodos(FormatterError) | Formatter failure |
UnableToWriteTodos(WriterError) | Writer failure |
FormatterError
Errors in output formatting.
| Variant | Cause |
|---|---|
SerializationError(String) | JSON/TOML/CSV serialisation failure |
IntegerOverflow(usize) | Count exceeds safe integer bounds |
WriterError
Errors in output writing.
| Variant | Cause |
|---|---|
IoError(io::Error) | File system I/O error |
PathTraversal(PathBuf) | Output path contains .. |
TowlGitHubError
Errors from GitHub API interactions.
| Variant | Cause |
|---|---|
ApiError { message, source } | General GitHub API failure |
AuthError | 401 response -- invalid or expired token |
RateLimitExceeded { retry_after_secs } | 403 with rate limit message |
IssueAlreadyExists { title } | Duplicate detected before creation |
RepositoryNotFound { owner, repo } | 404 response -- owner/repo not found |
MissingToken | TOWL_GITHUB_TOKEN not set or empty |
TowlProcessorError
Errors from replacing TODO comments with issue links in source files.
| Variant | Cause |
|---|---|
FileReadError(PathBuf, io::Error) | Failed to read source file |
FileWriteError(PathBuf, io::Error) | Failed to write modified file |
LineOutOfBounds { path, line, total_lines } | TODO line number exceeds file length |
CommentPrefixNotFound { path, line } | Column offset points past end of line |
PathOutsideRoot { path, root } | File is outside the repository root |
InvalidIssueUrl { url } | URL does not start with https://github.com/ |
TowlLlmError
Errors from LLM API interactions and analysis.
| Variant | Cause |
|---|---|
ApiError { message, status } | LLM API returned a non-200 status |
AuthError | 401 -- invalid or missing API key |
RateLimited { retry_after_secs } | 429 -- too many requests |
ParseError { message } | LLM response could not be parsed as valid JSON |
NotConfigured | TOWL_LLM_API_KEY environment variable not set |
UnsupportedProvider { provider } | Provider is not "claude", "openai", "claude-code", or "codex" |
IoError { message } | File I/O error during context gathering |
is_retryable() returns true for RateLimited, ApiError with status >= 500, and ApiError with no status (network failures).
TowlTuiError
Errors from the interactive terminal UI.
| Variant | Cause |
|---|---|
Io(io::Error) | Terminal I/O error from crossterm or ratatui |
Error Propagation
Errors propagate upward using ? and #[from]:
TowlCommentError --> TowlParserError --> TowlScannerError --> TowlError
FormatterError --> TowlOutputError --> TowlError
WriterError --> TowlOutputError --> TowlError
TowlGitHubError ----------------------------> TowlError
TowlProcessorError ---------------------------> TowlError
TowlTuiError ----------------------------> TowlError
TowlLlmError ----------------------------> TowlError
All errors implement std::fmt::Display with human-readable messages and std::error::Error for standard error handling.
Architecture
towl follows a pipeline architecture: Config -> Scanner -> Parser -> TUI / Output. Each stage is a separate module with clear boundaries and typed errors.
Pipeline
┌──────────┐
│ Config │ --config / TOWL_CONFIG / .towl.toml + env vars
└────┬─────┘
│
┌────▼─────┐
│ Scanner │ Walks directory tree, scans files concurrently
└────┬─────┘
│
┌────▼─────┐
│ Parser │ Matches comment prefixes + TODO patterns
└────┬─────┘
│
┌──────┼──────┐
│ │ │
┌─────▼────┐ │ ┌────▼─────┐
│ TUI │ │ │ Output │ Non-interactive: formats + writes
│ (default)│ │ │ (-N) │
└─────┬────┘ │ └──────────┘
│ │
┌─────▼─────┐│
│ Processor ││ Replaces TODOs with GitHub issue links
└───────────┘│
┌────▼─────┐
│ LLM │ --ai: validates TODOs with AI
└──────────┘
Module Boundaries
Config (src/lib/config/)
- Resolves config file path:
--configflag >TOWL_CONFIGenv var >.towl.toml - Loads config using the
configcrate - Merges environment variable overrides (
TOWL_GITHUB_*,TOWL_LLM_*) - Discovers GitHub owner/repo from
git remote get-url origin - Validates pattern array sizes
- Produces
TowlConfigcontainingParsingConfig+GitHubConfig+LlmConfig
Submodules:
types.rs--TowlConfig,ParsingConfig,GitHubConfigdefaults.rs-- Default values for config fieldsdisplay.rs--Displayimplementation for config tree viewnewtypes.rs--OwnerandReponewtype wrappersvalidation.rs-- Config validation logicgit.rs--GitRepoInfofor parsing git remoteserror.rs--TowlConfigError
Scanner (src/lib/scanner/)
- Accepts a
ParsingConfigand a root path - Walks the directory tree using the
ignorecrate (respects.gitignore) - Filters files by extension and exclude patterns
- Scans files concurrently with bounded parallelism (up to 64 files)
- Reads files asynchronously via
tokio::fs - Enforces resource limits (file size, TODO counts, file counts)
- Delegates content parsing to the
Parser - Returns
ScanResultwith TODOs and scan metrics
Submodules:
types.rs--Scannerimplementationlimits.rs--ScanResultand resource limit constantswalker.rs-- Directory walker constructionerror.rs--TowlScannerError
Parser (src/lib/parser/)
- Compiles regex patterns once during construction
- Identifies comment lines via
comment_prefixes - Extracts TODO items via
todo_patterns - Captures context lines (configurable window, 1-50)
- Detects enclosing function names via
function_patterns - Produces
Vec<TodoComment>
Submodules:
types.rs--Parserimplementationcontext.rs-- Context line extraction logicpattern.rs-- Pattern compilation and matchingerror.rs--TowlParserError
TUI (src/lib/tui/)
- Full-screen terminal interface using ratatui and crossterm
- Browse, filter, sort, and peek at TODOs
- Select TODOs and create GitHub issues with progress tracking
- Replaces TODO comments in source files with issue links via the Processor
Submodules:
app.rs--Appstate machine andAppModeenum (Browse, Peek, Confirm, Creating, Done)input.rs-- Keyboard event handling and action dispatchrender.rs-- UI rendering (list, peek popup, confirm dialog, progress view)error.rs--TowlTuiError
LLM (src/lib/llm/)
- AI-powered TODO validation using Claude, OpenAI, or local CLI agents
- Enum-dispatched providers following the same pattern as
FormatterImpl/WriterImpl - Gathers expanded context (~30 lines) and full function bodies for each TODO
- Constructs structured prompts and parses JSON responses
- Retry logic with exponential backoff via
backon - CLI providers (
claude-code,codex) auto-fall back to API providers if the binary is not on PATH
Submodules:
analyse.rs--analyse_todos(),gather_expanded_context(), retry logicclaude.rs--ClaudeProvider(Anthropic Messages API)openai.rs--OpenAiProvider(OpenAI Chat Completions API)cli.rs--ClaudeCodeProvider,CodexProvider(subprocess-based)prompt.rs-- System prompt and user content constructiontypes.rs--AnalysisResult,AnalysisSummary,Validity,LlmUsage, JSON extractionerror.rs--TowlLlmError
Processor (src/lib/processor/)
- Replaces TODO comments in source files with GitHub issue links
- Groups replacements by file for efficient batch processing
- Validates file paths stay within the repository root
- Returns
ProcessorResultwith counts and error details
Submodules:
types.rs--ProcessorandProcessorResulterror.rs--TowlProcessorError
GitHub (src/lib/github/)
- Creates GitHub issues from
TodoCommentitems via the Octocrab API - Loads existing issues to detect and skip duplicates
- Constructs issue titles and bodies with file/line metadata
Output (src/lib/output/)
- Combines a
FormatterImpland aWriterImpl - Groups TODOs by type before formatting
- Uses enum dispatch (not trait objects) for zero-cost abstraction
Output
├── FormatterImpl (enum dispatch)
│ ├── CsvFormatter
│ ├── JsonFormatter
│ ├── MarkdownFormatter
│ ├── TableFormatter
│ └── TomlFormatter
└── WriterImpl (enum dispatch)
├── StdoutWriter
└── FileWriter
Key Design Decisions
Enum Dispatch Over Trait Objects
Both FormatterImpl and WriterImpl use enum variants rather than Box<dyn Trait>. This provides:
- Static dispatch (no vtable overhead)
- Exhaustive matching at compile time
- Simpler lifetime management
Regex Compilation Strategy
All regex patterns are compiled once during Scanner::new() / Parser::new() and reused for every file. This avoids per-file compilation overhead.
Concurrent File Scanning
The scanner discovers all scannable files first, then scans them concurrently using futures::stream::buffer_unordered with a concurrency limit of 64. This provides significant speedup on large codebases while bounding resource usage.
Async I/O
File reading uses tokio::fs for non-blocking I/O. The scanner is async, allowing integration into async applications. The CLI uses #[tokio::main].
TUI Event Loop
The TUI uses a synchronous event loop with crossterm polling. GitHub issue creation runs in a background tokio task, communicating progress back to the UI via an mpsc channel. This keeps the UI responsive during network operations.
Error Type Hierarchy
Each module owns its error type. Errors propagate upward via #[from] conversions:
TowlCommentError → TowlParserError → TowlScannerError → TowlError
FormatterError → TowlOutputError → TowlError
WriterError → TowlOutputError → TowlError
TowlProcessorError → TowlError
TowlTuiError → TowlError
TowlLlmError → TowlError
Newtype Pattern
Owner and Repo are newtype wrappers over String, preventing accidental misuse (e.g., passing an owner where a repo is expected).
Secret Handling
The GitHub token is stored as secrecy::SecretString, which:
- Masks the value in
DebugandDisplayoutput - Zeroes memory on drop
- Prevents accidental logging
Directory Layout
src/
├── bin/
│ └── towl.rs CLI binary
└── lib/
├── mod.rs Library root
├── cli/
│ └── mod.rs Clap argument definitions
├── comment/
│ ├── mod.rs
│ ├── todo.rs TodoType, TodoComment
│ └── error.rs TowlCommentError
├── config/
│ ├── mod.rs
│ ├── types.rs TowlConfig, ParsingConfig, GitHubConfig
│ ├── defaults.rs Default config values
│ ├── display.rs Config Display implementation
│ ├── newtypes.rs Owner, Repo newtypes
│ ├── validation.rs Config validation
│ ├── git.rs GitRepoInfo
│ └── error.rs TowlConfigError
├── scanner/
│ ├── mod.rs
│ ├── types.rs Scanner
│ ├── limits.rs ScanResult, resource limits
│ ├── walker.rs Directory walker construction
│ └── error.rs TowlScannerError
├── parser/
│ ├── mod.rs
│ ├── types.rs Parser
│ ├── context.rs Context line extraction
│ ├── pattern.rs Pattern compilation
│ └── error.rs TowlParserError
├── github/
│ ├── mod.rs
│ ├── client.rs GitHubClient
│ ├── types.rs CreatedIssue
│ └── error.rs TowlGitHubError
├── llm/
│ ├── mod.rs LlmProvider enum dispatch
│ ├── analyse.rs analyse_todos, gather_expanded_context
│ ├── claude.rs ClaudeProvider
│ ├── openai.rs OpenAiProvider
│ ├── cli.rs ClaudeCodeProvider, CodexProvider
│ ├── prompt.rs System prompt construction
│ ├── types.rs AnalysisResult, Validity, JSON extraction
│ └── error.rs TowlLlmError
├── processor/
│ ├── mod.rs
│ ├── types.rs Processor, ProcessorResult
│ └── error.rs TowlProcessorError
├── tui/
│ ├── mod.rs TUI entry point and event loop
│ ├── app.rs App state machine, AppMode
│ ├── input.rs Keyboard input handling
│ ├── render.rs UI rendering
│ └── error.rs TowlTuiError
├── output/
│ ├── mod.rs Output
│ ├── error.rs TowlOutputError
│ ├── formatter/
│ │ ├── mod.rs FormatterImpl
│ │ ├── error.rs FormatterError
│ │ └── formatters/
│ │ ├── mod.rs Formatter dispatch
│ │ ├── csv.rs
│ │ ├── json.rs
│ │ ├── markdown.rs
│ │ ├── table.rs
│ │ └── toml.rs
│ └── writer/
│ ├── mod.rs WriterImpl
│ ├── error.rs WriterError
│ └── writers/
│ ├── file.rs FileWriter
│ └── stdout.rs StdoutWriter
└── error/
└── mod.rs TowlError
tests/
├── integration/ Integration tests
├── property/ Property-based tests
└── fixtures/ Test fixtures
Dependencies
| Crate | Purpose |
|---|---|
clap | CLI argument parsing |
tokio | Async runtime and file I/O |
serde / serde_json / toml | Serialisation |
regex | TODO pattern matching |
ignore | Directory walking (respects .gitignore) |
thiserror | Error type derivation |
secrecy | Secret string handling |
config | Configuration file loading |
octocrab | GitHub API client |
ratatui | Terminal UI framework |
crossterm | Terminal input/output |
futures | Async stream utilities |
reqwest | HTTP client (rustls TLS) |
backon | Retry logic with exponential backoff |
which | CLI binary PATH detection |
proptest | Property-based testing |
rstest | Parameterised testing |
insta | Snapshot testing |
Security
towl applies defence-in-depth across configuration, scanning, and output.
Path Traversal Protection
All user-supplied paths are checked for .. components before use:
- Config paths --
towl init --pathrejects traversal attempts - Scan paths --
towl scan <path>validates before walking - Output paths --
-o <path>is validated and symlinks are resolved
The check uses contains_path_traversal() which inspects each path component for ...
Symlink Resolution
Output file paths are resolved via std::fs::canonicalize() before writing. This prevents symlink-based escape from the intended output directory.
Resource Limits
Hard limits prevent denial-of-service via large repositories or malicious inputs:
| Limit | Value | Purpose |
|---|---|---|
| Max file size | 10 MB | Prevents reading huge binary/generated files |
| Max TODOs per file | 10,000 | Bounds per-file memory usage |
| Max total TODOs | 100,000 | Bounds overall memory usage |
| Max files scanned | 100,000 | Bounds directory walk |
| Max pattern length | 256 chars | Prevents regex DoS via long patterns |
| Max compiled regex | 256 KB | Bounds regex engine memory |
| Max total patterns (combined) | 50 | Bounds total regex compilation across all categories |
| Max patterns per config field | 100 | Limits config file attack surface |
Secret Handling
The GitHub token (TOWL_GITHUB_TOKEN) is:
- Never stored in config files -- Only accepted via environment variable
- Stored as
SecretString-- Uses thesecrecycrate - Masked in debug output --
DebugandDisplayshow[REDACTED] - Zeroed on drop -- Memory is cleared when the config is dropped
Environment Variable Restriction
Eight environment variables are read:
| Variable | Purpose |
|---|---|
TOWL_CONFIG | Config file path override |
TOWL_GITHUB_TOKEN | GitHub authentication |
TOWL_GITHUB_OWNER | Repository owner override |
TOWL_GITHUB_REPO | Repository name override |
TOWL_LLM_API_KEY | LLM API authentication |
TOWL_LLM_PROVIDER | LLM provider override |
TOWL_LLM_MODEL | LLM model override |
TOWL_LLM_BASE_URL | Custom LLM endpoint URL |
Secrets (TOWL_GITHUB_TOKEN, TOWL_LLM_API_KEY) are stored as SecretString and never written to config files or logs. No other environment variables influence behaviour.
Config File Safety
--forcerequired for overwriting existing config files- Pattern array limits -- Each pattern field is capped at 100 entries
- Pattern length limits -- Individual regex patterns capped at 256 characters
- TOML parsing -- Uses
configcrate withserdefor type-safe deserialization
Git Integration
- Git operations use
tokio::process::Commandto rungitas a subprocess - Only read-only git commands are executed (
git remote get-url origin) - No git credentials are accessed or stored
LLM CLI Subprocess Safety
When using claude-code or codex providers, towl spawns CLI subprocesses:
- Command validation -- Relative paths containing
..or non-absolute/are rejected - Timeout -- CLI processes are killed after 120 seconds
- Input via stdin -- Prompts are piped through stdin, not shell arguments (prevents injection)
- Stderr capture -- CLI error output is captured and included in error messages
.gitignore Respect
The ignore crate automatically respects .gitignore rules during directory walking, preventing scanning of files the user has excluded from version control.
Error Messages
Error messages include file paths and context for debugging but do not expose internal implementation details or sensitive data. The SecretString type ensures tokens cannot leak through error formatting.
Threat Model
| Threat | Mitigation |
|---|---|
| Path traversal via config/scan/output paths | .. component detection, symlink resolution |
| Regex DoS via malicious patterns | Pattern length limit (256), regex size limit (256 KB), total pattern cap (50) |
| Memory exhaustion via large repos | File size, TODO count, and file count limits |
| Token leakage | SecretString, env-only token, masked debug |
| Config file overwrite | --force flag required |
| Arbitrary file write via symlinks | canonicalize() on output paths |
| Scanning outside intended directory | .gitignore respect, extension filtering |
| CLI command injection via LLM providers | Relative path rejection, stdin piping (not shell args), 120s timeout |
CI/CD
towl uses GitHub Actions for continuous integration and documentation deployment.
Documentation Deployment
The docs.yml workflow builds and deploys the mdBook documentation to GitHub Pages.
Trigger: Pushes to main that modify files in docs/ or the workflow file itself. Can also be triggered manually via workflow_dispatch.
Pipeline:
- Build -- Installs mdBook, runs
mdbook build docs, uploads thedocs/book/directory as a Pages artifact - Deploy -- Deploys the artifact to GitHub Pages
Permissions required:
contents: read-- Read repository filespages: write-- Deploy to GitHub Pagesid-token: write-- Authenticate with Pages
The workflow uses concurrency control (group: pages, cancel-in-progress: true) to prevent overlapping deployments.
Running Locally
Build and preview the documentation locally:
# Install mdBook
cargo install mdbook
# Build docs
mdbook build docs
# Serve with live reload
mdbook serve docs
The built documentation is output to docs/book/.
Contributing
Getting Started
git clone https://github.com/glottologist/towl.git
cd towl
cargo build
Requirements
- Rust 1.75+ (see
rust-toolchain.toml) - git on
PATH
Development Commands
# Build
cargo build
# Run all tests
cargo nextest run # preferred
cargo test # fallback
# Clippy (strict)
cargo clippy --all-targets --all-features
# Format
cargo fmt
# Run the binary
cargo run -- scan
cargo run -- scan -f json -o todos.json
cargo run -- config
cargo run -- init
Project Structure
See Architecture for a full layout. Key entry points:
src/bin/towl.rs-- CLI binarysrc/lib/mod.rs-- Library roottests/-- Integration and property-based tests
Testing
Test Hierarchy
Tests follow a strict hierarchy:
- proptest (property-based) -- First choice for pure functions, parsers, validators, serialisation roundtrips
- rstest (parameterized) -- For specific known cases (< 10 inputs with exact expected outputs)
- Standalone -- Last resort, for complex integration scenarios
Running Tests
# All tests
cargo nextest run
# Specific module
cargo nextest run scanner
# Property-based tests only
cargo nextest run proptest
# Integration tests only
cargo nextest run --test '*'
Code Style
- Follow Rust naming conventions (
snake_casefor functions,CamelCasefor types) - All public items need doc comments (
///) - No
#[allow(...)]attributes -- fix the underlying issue - No
.unwrap()/.expect()in production code -- use?with typed errors - No
asnumeric casts -- usetry_from/into/From - Minimise
.clone()-- prefer borrowing, see Clone Reduction Policy
Error Handling
- Use
thiserrorfor error type derivation - Each module defines its own error enum
- Errors propagate upward via
?and#[from] - Never silently discard
Resultvalues
Adding a New Output Format
- Create
src/lib/output/formatter/formatters/yourformat.rs - Implement the
Formattertrait - Add a variant to
FormatterImplinformatters/mod.rs - Add dispatch in
FormatterImpl::format() - Add a variant to
OutputFormatinsrc/lib/cli/mod.rs - Update the format-to-writer mapping in
Output::new() - Add tests (proptest for roundtrips, rstest for edge cases)
Adding a New TODO Type
- Add a variant to
TodoTypeinsrc/lib/comment/todo.rs - Update
Display,TryFrom<&str>,as_filter_str() - Add a default pattern to
default_todo_patterns()insrc/lib/config/types.rs - Add a pattern mapping in the parser
- Update tests
Pull Requests
- Keep PRs focused on a single change
- Include tests for new functionality
- Ensure
cargo clippypasses with zero warnings - Ensure
cargo fmtproduces no changes - Ensure all tests pass