Building a Rust Ratatui CLI that manages Syncthing via its REST API — listing folders, showing contents, adding .stignore rules, and deleting directories safely with Docker path-mapping support.
YOU MUST WRITE TESTS FIRST. NO EXCEPTIONS.
Every code change requires this exact workflow:
- STOP and think: "What tests do I need?"
- Write tests FIRST that expose the bug or define the feature
- Verify tests fail (proving they test the right thing)
- Implement minimal code to pass tests
- Verify tests pass
- Only then commit
Why this matters:
- Without tests, you waste user's time and money debugging blind
- Tests document expected behavior and catch regressions
- TDD prevents over-engineering and scope creep
- Example: Commit fcf4362 (reconnection fix) - 10 tests written first, exposed exact bug, guided perfect solution
Red flags that you're doing it wrong:
- ❌ "Let me try this change and see if it works"
- ❌ "I'll add some debug logging to investigate"
- ❌ Making multiple attempts without tests
- ❌ Saying "I think this should work"
What you should do instead:
- ✅ "Let me write a test that reproduces this bug"
- ✅ "I'll write tests for these 3 scenarios first"
- ✅ "Here's a failing test - now I'll implement the fix"
- ✅ "All 10 tests pass, ready to commit"
When to write tests:
- Adding new features → Write feature tests first
- Fixing bugs → Write test that reproduces bug first
- Refactoring → Ensure existing tests pass, add coverage if missing
- Changing state logic → Write state transition tests first
- User reports "X doesn't work" → Write test showing X failing
If you catch yourself coding before testing:
- STOP immediately
- Delete/revert the code
- Write tests first
- Start over with proper TDD
This is not optional. This is not a suggestion. This is how professional software is built.
CRITICAL: Avoid "STDIN" prefix in commit messages
The user has cat aliased to bat, which adds "STDIN" label when reading from heredocs. Always use /bin/cat instead of cat in git commit commands.
Bad pattern (adds "STDIN" prefix):
git commit -m "$(cat <<'EOF'
commit message
EOF
)"Good pattern (no STDIN prefix):
git commit -m "$(/bin/cat <<'EOF'
commit message
EOF
)"Why: cat is aliased to bat --style header --style snip --style changes --style header, and bat labels stdin input as "STDIN".
CRITICAL: Never add co-authored-by attribution
Do not add "Co-Authored-By: Claude" or similar attribution lines to commit messages.
Bad pattern:
git commit -m "$(/bin/cat <<'EOF'
feat: Add new feature
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"Good pattern:
git commit -m "$(/bin/cat <<'EOF'
feat: Add new feature
EOF
)"Why: Commit authorship is already tracked by Git. Additional attribution is redundant and clutters commit history.
CRITICAL: Always read API credentials from user config file
When testing Syncthing API endpoints with curl commands:
- Read the config file first:
~/.config/stui/config.yaml - Extract
api_keyandbase_urlfrom the config - Use those values in curl commands
Example workflow:
# Read config to get API key and base URL
cat ~/.config/stui/config.yaml
# Then use extracted values in curl
curl -s -H "X-API-Key: <key-from-config>" "<base_url-from-config>/rest/db/need?folder=lok75-7d42r"NEVER:
- ❌ Use hardcoded API keys
- ❌ Guess at API credentials
- ❌ Use old/stale credentials from previous sessions
ALWAYS:
- ✅ Read config file first
- ✅ Use current credentials from config
- ✅ Verify base_url matches user's setup
Use ag (The Silver Surfer) for content search:
# Search for text pattern in code
ag "search_term"
# Search in specific file type
ag --rust "pattern"
# Search with context
ag -C 3 "pattern"
# Case-insensitive search
ag -i "pattern"CRITICAL: Always put ag options BEFORE the pattern, never after files:
# ✅ CORRECT - options before pattern
ag -C 3 "pattern" src/
# ❌ WRONG - options after files will fail
ag "pattern" src/ -C 3Use fd for finding files:
# Find files by name pattern
fd "pattern"
# Find in specific directory
fd "pattern" src/
# Find by file type
fd -e rs # Rust files
fd -e toml # TOML files
# Combine with other commands
fd "test" | head -10Why these tools:
ag: Faster than grep, respects .gitignore, better syntax highlightingfd: Faster than find, simpler syntax, respects .gitignore
AVOID using:
- ❌
grep -r- useaginstead - ❌
find- usefdinstead
CRITICAL: All code must pass cargo fmt and cargo clippy
These checks run on every PR and release - failures will block merging/releasing.
Before committing, always run:
cargo fmtThis auto-formats all Rust code to match Rustfmt style. Never commit code that fails this check.
Formatting rules (enforced by Rustfmt):
Imports:
- Use alphabetical ordering:
use crate::...,use std::...,use external::... - Group related imports together with blank lines between groups
- Remove unused imports
Example:
// Good - alphabetical order, grouped
use crate::utils;
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::PathBuf;
// Bad - not alphabetical
use anyhow::{Context, Result};
use crate::utils;
use std::path::PathBuf;Line length:
- Max 100 characters per line (Rustfmt default)
- Long function arguments and match arms split across multiple lines
Example:
// Good - split long argument list
log_debug(&format!(
"Failed to get files for {}: {}",
folder_id, error
));
// Bad - exceeds line length
log_debug(&format!("Failed to get files for {}: {}", folder_id, error));Spacing:
- Use consistent spacing around operators and delimiters
- Single space after keywords (
if,for,match, etc.) - No space between function name and opening parenthesis
Example:
// Good
if x > 0 {
do_something();
}
let result = calculate(a, b);
// Bad
if(x>0){
do_something();
}
let result = calculate (a, b);Type annotations:
- Always include explicit type annotations on public functions
- Use
:with no space before type:foo: String
Example:
// Good
pub fn get_path() -> PathBuf {
let cache_dir: Option<PathBuf> = dirs::cache_dir();
cache_dir
}
// Bad
pub fn get_path() { // missing return type
let cache_dir = dirs::cache_dir();
}Before committing, run: cargo clippy -- -D warnings (failures block CI/CD)
Key patterns:
- Use
is_some_and/is_none_orinstead ofmap_orwith booleans - Use
.first()not.get(0),contains_key()not.get().is_none() - Use pattern matching instead of unwrap after is_ok/is_err checks
- Collapse nested ifs with
&&, useif letfor single pattern matches - Use
saturating_sub()for safe subtraction - Avoid
format!()for static strings, use string literals or.to_string() - Add
Defaultimpl for types withnew() - Box large enum variants (>3x size difference)
- Prefix unused variables with
_ - Use
#[allow(clippy::too_many_arguments)]for Ratatui render functions (idiomatic pattern)
The CI pipeline runs these checks automatically:
-
tests.yml (on PRs and pushes):
cargo test- Run test suitecargo fmt -- --check- Verify formattingcargo clippy -- -D warnings- Run lintercargo build --release- Build project
-
release.yml (on version tags):
cargo test --verbose- Run all tests before building
If checks fail:
- Run locally to see the issue:
cargo fmtandcargo clippy - Fix the issues (most cargo fmt issues auto-fix)
- Commit the fixes
- Push again to re-run checks
- If you make a change that doesn't work, do not just keep adding more things on. If a change didn't fix things, consider that and revert it before attempting a new solution.
- Use debug logs for general development and troubleshooting
- Make logging comprehensive but concise - debug logs should be informative without overwhelming
- When adding new features or fixes, git commit once user has confirmed working and tests are written
BrowseItem.item_type values (from Syncthing API):
- Directory:
"FILE_INFO_TYPE_DIRECTORY"(the ONLY directory type) - File: Anything else (various file types, but NOT equal to
"FILE_INFO_TYPE_DIRECTORY")
NEVER use:
- ❌
"directory" - ❌
"file" - ❌
"dir"
ALWAYS use:
- ✅
item.item_type == "FILE_INFO_TYPE_DIRECTORY"for checking directories - ✅
item.item_type != "FILE_INFO_TYPE_DIRECTORY"for checking files
See src/api.rs:43 for BrowseItem struct definition.
- Syncthing Environment: Runs in Docker container
- CLI Environment: Runs on host machine
- Path Translation: Container paths differ from host paths; use configurable
path_mapto translate container paths to host paths for file operations - Data Source: Syncthing REST API (not direct filesystem scanning)
- Language: Rust
- TUI Framework: Ratatui
- Dependencies:
reqwest(HTTP client)serde/serde_json/serde_yaml(serialization)crossterm(terminal handling)tokio(async runtime)rusqlite(SQLite cache)ratatui-image(terminal image rendering)image(image processing and resizing)anyhow(error handling)urlencoding(URL encoding)dirs(directory paths)glob(pattern matching)clap(CLI argument parsing)unicode-width(text width calculations)codepage-437(CP437 encoding for ANSI art)
- List folders from
/rest/system/configwith real-time status icons - Browse folder contents via
/rest/db/browsewith recursive traversal - Multi-pane breadcrumb navigation showing directory hierarchy
- Keyboard navigation:
↑/↓: Navigate itemsEnter: Preview file (if file) or drill into folder (if directory)←/Backspace: Go backq: Quit
Display visual indicators for file/folder states following <file|dir><status> pattern:
📄✅/📁✅Synced📄☁️/📁☁️Remote-only📄💻/📁💻Local-only📄⚠️/📁⚠️Out-of-sync OR Ignored (exists on disk)📄⏸/📁⏸Paused📄🚫/📁🚫Ignored (deleted from disk)📄🔄/📁🔄Syncing (actively downloading/uploading)📄❓/📁❓Unknown
?orEnter(on files): Show detailed file info popup with metadata and preview:- Text files: Scrollable preview with vim keybindings (j/k, gg/G, Ctrl-d/u/f/b, PgUp/PgDn)
- ANSI art files: Auto-detects ANSI codes, CP437 encoding, 80-column wrapping, SGR colors
- Image files: Terminal rendering (Kitty/iTerm2/Sixel/Halfblocks), non-blocking load
- Binary files: Extracted text or metadata
c: Context-aware action:- Folder view (focus_level == 0): Change folder type (Send Only, Send & Receive, Receive Only) with selection menu
- Breadcrumb view (focus_level > 0): Copy file/directory path to clipboard (uses mapped host paths)
i: Toggle ignore state (add/remove from.stignore) viaPUT /rest/db/ignoresI: Ignore AND delete locally (immediate action, no confirmation)o: Open file/directory with configured command (e.g.,xdg-open,code,vim)d: Delete file/directory from disk (with confirmation prompt)r: Rescan folder - Shows confirmation dialog with options:y: Normal rescan - Trigger Syncthing scan, wait for sequence change to invalidate cachef: Force refresh - Immediately invalidate cache and trigger rescan (useful for stale cache bugs)norEsc: Cancel
R: Restore deleted files (revert receive-only folder)s: Cycle sort mode (Sync State → A-Z → Timestamp → Size)S: Toggle reverse sort ordert: Toggle info display (Off → TimestampOnly → TimestampAndSize → Off)p: Pause/resume folder (folder view only, with confirmation)u: Folder Update History - Shows recent file updates for the selected folder with lazy-loading pagination- Loads files in batches of 100 as you scroll
- Auto-loads when within 10 items of bottom
- Press
Enteron a file to jump directly to that file's location in breadcrumbs
- Vim keybindings (optional):
hjkl,gg,G,Ctrl-d/u,Ctrl-f/b
Real-time recursive search with wildcards (*), case-insensitive, shows parent dirs with matching descendants. Trigger with Ctrl-F or / (vim mode). Search persists when drilling down, context-aware clearing when backing out past origin. SQLite cache enables instant recursive queries across all subdirectories.
UI Layout: System bar → Main content (folders + breadcrumb panels with smart sizing) → Hotkey legend → Status bar
Folder List (focus_level == 0):
- Card-based rendering with inline stats (3 lines per folder)
- Shows folder name, state icon, type, size, file count, and status message
- Out-of-sync folders show detailed breakdown (remote needed, local changes)
- Dynamic title shows counts (total, synced, syncing, paused)
Status Bar (context-aware):
- Folder view (focus_level == 0): Activity feed + device count
- Shows last sync activity with timestamp ("SYNCED file 'example.txt' • 5 sec ago")
- Shows connected device count ("3 devices connected")
- Breadcrumb view (focus_level > 0): Folder/file details + sort mode + filter status
Key UI features:
- Context-aware hotkey legend (folder view vs breadcrumb view)
- Three-state file info toggle (Off/TimestampOnly/TimestampAndSize)
- Multi-mode sorting (Sync State/A-Z/Timestamp/Size) with visual indicators
- Scrollbar indicators on breadcrumb panels when content exceeds viewport
- Confirmation dialogs for destructive operations
YAML config at ~/.config/stui/config.yaml (Linux) with: API key, base URL, path_map, vim_mode, icon_mode, open_command, clipboard_command, image preview settings
CLI flags: --debug, --vim, --config <path>
/rest/system/config # Get folders and devices
/rest/config/folders/<id> # PATCH to modify folder config (e.g., pause/resume, folder type)
/rest/db/status?folder=<id> # Folder sync status (with sequence numbers)
/rest/db/browse?folder=<id>[&prefix=subdir/] # Browse contents
/rest/db/file?folder=<id>&file=<path> # Get file sync details
/rest/db/ignores?folder=<id> # GET/PUT .stignore rules
/rest/db/scan?folder=<id> # Trigger folder rescan
/rest/db/revert?folder=<id> # Revert receive-only folder
/rest/db/localchanged?folder=<id> # Get local changes (receive-only)
/rest/db/need?folder=<id> # Get files needed from remote
/rest/stats/folder # Folder statistics (matches web GUI)
/rest/events?since=<id>&timeout=60 # Event stream (long-polling)
/rest/system/status # System status (device info, uptime)
/rest/system/connections # Connection/transfer statistics
Main modules:
src/main.rs(~1,180 lines) - App struct, main event loop (starts ~line 909)src/app/- App orchestration: file_ops, filters, ignore, navigation, preview, sorting, sync_statessrc/handlers/- Event handlers: keyboard, api, eventssrc/services/- Background: api (async queue), events (long-polling)src/model/- Pure state (Elm): syncthing, navigation, ui, performance, typessrc/logic/- Pure business logic (16 modules): file, folder, folder_card, formatting, ignore, layout, navigation, path, performance, platform, search, sorting, sync_states, ui, errorssrc/ui/- Rendering (13 modules): render, folder_list (card-based), breadcrumb, dialogs, icons, legend, search, status_bar (activity feed), system_bar, out_of_sync_summary (filter modal), toast, layoutsrc/api.rs,src/cache.rs,src/config.rs,src/utils.rs- Core utilities
Key patterns: App initialization loads folders, spawns services. Main event loop (~line 909) processes API responses, keyboard, cache events. Keyboard handler has confirmation dialogs first.
CRITICAL Architecture Rules:
-
UI Side Effects (toasts, dialogs) MUST be in
handlers/keyboard.rs- ❌ WRONG: Calling
show_toast()in helper methods inmain.rsorsrc/app/ - ✅ CORRECT: Calling
show_toast()in keyboard handler where user action happens - Helper methods in
main.rsandsrc/app/should only do business logic (update state, call APIs) - All user feedback (toasts, error messages) belongs at the call site in keyboard handler
- ❌ WRONG: Calling
-
Separation of Concerns:
src/api.rs: Pure API client methods (no UI, no state mutation beyond return values)src/handlers/keyboard.rs: Keyboard events → business logic → UI feedback (toasts, dialogs)src/main.rs+src/app/: Orchestration methods (pure business logic, no UI side effects)src/model/: Pure state (cloneable, no side effects, no I/O)src/logic/: Pure functions (testable, no state mutation, no I/O)src/ui/: Pure rendering (takes state, returns widgets, no mutation)
-
Adding UI Feedback Pattern:
// ❌ WRONG - toast in helper method (main.rs or src/app/) fn cycle_sort_mode(&mut self) { self.model.ui.sort_mode = new_mode; self.model.ui.show_toast("Sort changed"); // WRONG! } // ✅ CORRECT - toast at call site in keyboard handler KeyCode::Char('s') => { app.cycle_sort_mode(); // Pure business logic app.model.ui.show_toast(format!("Sort: {}", app.model.ui.sort_mode.as_str())); // UI feedback here }
Long-polling /rest/events for real-time updates. Granular invalidation (file/dir/folder). Handles LocalIndexUpdated, ItemStarted, ItemFinished. Persistent event ID, auto-recovery.
Activity Event Deduplication: Activity events from ItemFinished are deduplicated by timestamp. Only events newer than existing activity are stored, preventing event replay from overwriting fresh data during event stream reconnection.
Async API service with priority queue, cache-first rendering, sequence-based validation, request deduplication, 300ms idle threshold, 250ms poll timeout (~1-2% CPU idle).
SQLite at ~/.cache/stui/cache.db with browse, sync state, folder status caches. Event ID persists. Manual clear on schema changes: rm ~/.cache/stui/cache.db
Auto-detects ANSI codes (ESC[ sequences), CP437 encoding, 80-column wrapping, line buffer with cursor positioning, SGR colors (fg 30-37/90-97, bg 40-47/100-107), SAUCE stripping.
ManualStateChange tracks SetIgnored/SetUnignored. After SetIgnored → only accept Ignored. After SetUnignored → accept any except Ignored. 10s safety timeout. syncing_files HashSet tracks ItemStarted/ItemFinished.
603 tests passing, zero warnings, clean Model/Runtime separation. Full ANSI/CP437 support. Version 0.10.0.
- Safety First: All destructive operations require confirmation (except
Iwhich is intentionally immediate) - Path Mapping: Always translate container paths to host paths before file operations
- Error Handling: Graceful degradation, show errors in status bar or toast messages
- Non-Blocking: Keep UI responsive during all API calls
- Cache Coherency: Use sequence numbers to validate cached data
- Testing - CRITICAL REQUIREMENT:
- Test-Driven Development is MANDATORY (see top of file for detailed TDD workflow)
- ALWAYS write tests when:
- Adding new features (especially state management)
- Fixing bugs or edge cases
- Refactoring existing code
- Adding new model fields or business logic
- Test-Driven Development Pattern:
- User reports bug or requests feature
- IMMEDIATELY think: "What tests do I need?"
- Write tests FIRST that cover:
- Happy path (expected behavior)
- Edge cases (boundary conditions)
- Error cases (what happens when things go wrong)
- State transitions (before/after)
- Implement the feature/fix
- Run tests to verify
- If tests fail, fix implementation (not tests)
- Test Coverage Requirements:
- Model state changes → tests in
src/model/*/tests - Business logic → tests in
src/logic/*/tests - Integration tests →
tests/*.rsfiles (seetests/reconnection_test.rs) - Aim for 100% coverage of new code paths
- Model state changes → tests in
- Real-World Success Story - Commit fcf4362:
- Problem: Folders not populating after reconnection (cost $20 debugging blind)
- TDD Approach: Wrote 10 tests first exposing exact bug
- Test
test_state_already_connected_before_system_statusrevealed root cause - Solution: Simple 1-line fix guided by tests
- Result: All tests pass, bug fixed perfectly on first try
- Lesson: TDD saves time and money
- When Claude forgets to write tests:
- User should immediately call it out
- Claude should apologize and write tests before proceeding
- This is a critical discipline for production code quality
- Existing test guidelines:
- Test with real Syncthing Docker instances with large datasets
- Pure business logic in
src/logic/should have comprehensive test coverage - Model state transitions should have tests in corresponding test modules
- Run
cargo testbefore committing to ensure all 603+ tests pass - Aim for zero compiler warnings (
cargo buildshould be clean)
- Test Organization Standards:
- Keep tests inline using
#[cfg(test)] mod testsat the bottom of each module - Use section headers for visual organization when files have >10 tests:
// ======================================== // SECTION NAME // ========================================
- Group tests logically by feature/function being tested
- When to reorganize:
- File has >20 tests and they're randomly ordered → Major reorganization
- File has >10 tests but well-ordered → Add section headers only
- File has <10 tests → No changes needed
- Examples of well-organized test modules:
src/logic/file.rs- 35 tests in 5 sections (Image Detection, Binary Detection, ANSI Code Detection, ANSI Parsing, Binary Text Extraction)src/logic/ignore.rs- 13 tests in 4 sections (Pattern Matching, Find Matching, Validation Valid/Invalid/Edge Cases)src/model/ui.rs- 16 tests in 4 sections (UI Model Creation, Search Mode, Search Query Operations, Search Origin Level)src/logic/navigation.rs- 14 tests in 4 sections (Next Selection, Prev Selection, Edge Cases, Find Item By Name)
- Benefits: Tests can be collapsed by section in IDEs, clear grouping makes finding related tests easy, maintains locality with implementation code
- Keep tests inline using
- Debug Mode: Use
--debugflag for verbose logging to/tmp/stui-debug.log
Adding a new API endpoint:
- Add method to
SyncthingClientinsrc/api.rs(follow existing patterns) - Add request type to
ApiRequestenum insrc/services/api.rsif using async service - Add response type to
ApiResponseenum insrc/services/api.rs - Add handler in
src/handlers/api.rsto process response
Adding a new keybinding:
- Add state to
Model(usuallymodel.uifor dialogs/popups) - Add keybinding handler in
src/handlers/keyboard.rs- Confirmation dialogs go at top of match statement (processed first)
- Regular keys go in main match block with conditional guards (e.g.,
focus_level == 0)
- Add dialog rendering in
src/ui/dialogs.rs(if confirmation needed) - Add rendering call in
src/ui/render.rs - Update hotkey legend in
src/ui/legend.rswith context guards
Example 1: Pause/Resume Feature (confirmation dialog pattern)
- API:
src/api.rs-set_folder_paused()using PATCH/rest/config/folders/{id} - State:
model.ui.confirm_pause_resume: Option<(folder_id, label, is_paused)> - Keybinding:
KeyCode::Char('p') if focus_level == 0opens confirmation - Confirmation: Handles 'y' (execute), 'n'/Esc (cancel)
- Execution: Call API, reload folders via
client.get_folders(), updatemodel.syncthing.folders - Dialog:
render_pause_resume_confirmation()with color-coded borders - Legend: Shows "p:Pause/Resume" only in folder view
- Visual: Pause icon (⏸ emoji / nerdfont) via
FolderState::Paused
Example 2: Change Folder Type (selection menu pattern)
- API:
src/api.rs-set_folder_type()using PATCH/rest/config/folders/{id}with{"type": "sendonly|sendreceive|receiveonly"} - Data:
Folderstruct hasfolder_type: Stringfield (serde renamed from "type") - State:
model.ui.folder_type_selection: Option<FolderTypeSelectionState>withfolder_id,folder_label,current_type,selected_index - Keybinding:
KeyCode::Char('c') if focus_level == 0opens selection menu (context-aware - 'c' copies path in breadcrumbs) - Selection Menu:
- Uses
Listwidget with ↑↓ navigation, Enter to select, Esc to cancel - Current type highlighted in cyan/italic
- Handler near top of keyboard.rs (check for
folder_type_selectionmatch)
- Uses
- Execution: Call API, reload folders, update
model.syncthing.folders, show toast - Dialog:
render_folder_type_selection()shows 3 options with user-friendly names - Legend: Shows "c:Change Type" only in folder view
- Status Bar: Displays folder type (Send Only, Send & Receive, Receive Only) before state field
State management patterns:
- Model fields: All application state lives in
Modelstruct (pure, cloneable) - Runtime fields: Services, channels, caches in
Appstruct (not cloneable) - State updates: Mutate
app.model.*directly, reload from API when needed - Toast messages:
app.model.ui.show_toast()for user feedback - Modal dialogs: Set
model.ui.confirm_*field, handled at top of keyboard handler
UI rendering patterns:
- Icon rendering: Use
IconRendererwithFolderStateorSyncStateenums - Scrollbars: Automatically rendered by breadcrumb panels using Ratatui's
Scrollbarwidget - Context-aware display: Check
focus_levelto show/hide keys in legend - Color coding: Use
Color::Cyan(focused),Color::Blue(parent),Color::Gray(inactive) - Text wrapping:
- Legend uses
.wrap(ratatui::widgets::Wrap { trim: false })for text wrapping - Fixed height of 3 lines (
Constraint::Length(3)) - wraps content within available space - System bar and status bar also use
Constraint::Length(3)(fixed height) - Note: On very narrow terminals, some hotkeys may be clipped if content exceeds 3 lines
- Legend uses