Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
973042ce7d | ||
|
|
0f27bedc94 | ||
|
|
da6311bc53 | ||
|
|
2b1773375a | ||
|
|
36aaa65945 | ||
|
|
10a38a1f85 | ||
|
|
42bdc1d409 | ||
|
|
a592c99375 | ||
|
|
ff6050c718 | ||
|
|
15a11ef79c | ||
|
|
6050cb70f3 | ||
|
|
61f6a9fee3 | ||
|
|
e0da58d114 | ||
|
|
437584aac7 | ||
|
|
960724685c | ||
|
|
639bcdf643 | ||
|
|
ddb4b70234 | ||
|
|
32155bc1d2 | ||
|
|
e4cc280f28 | ||
|
|
868540b92a | ||
|
|
0af46bd6a5 | ||
|
|
ba3f923781 | ||
|
|
fcb7c7f6a6 | ||
|
|
3c78a5afe3 | ||
|
|
4dcac9a6aa | ||
|
|
32c40b7226 | ||
|
|
c1adc92c1c | ||
|
|
e6180b57c3 | ||
|
|
90e3b65820 | ||
|
|
fc2f503886 | ||
|
|
b29baee9c4 | ||
|
|
6d842b83ba | ||
|
|
b4877007f2 | ||
|
|
29ff9535a7 | ||
|
|
dec566320f | ||
|
|
6275c638d9 | ||
|
|
4d6af13134 | ||
|
|
b2a0ee5e08 | ||
|
|
2bc516ed6d | ||
|
|
7e70157097 | ||
|
|
ef13e884ba | ||
|
|
b6e33c37ac | ||
|
|
20f516cffb | ||
|
|
a5429c3543 | ||
|
|
8a7df892e8 | ||
|
|
f0949e08c5 | ||
|
|
d80857090e | ||
|
|
19f36060a0 | ||
|
|
2dc12e37c9 | ||
|
|
280a180d3e | ||
|
|
5f281b1f9e | ||
|
|
9150f93f6a | ||
|
|
a076cf2543 | ||
|
|
9198c481c0 | ||
|
|
bfdc1e2093 | ||
|
|
d35acdeb55 | ||
|
|
30d6c3d1c5 | ||
|
|
562febaafe | ||
|
|
71da4149b8 |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash -c 'if [ -f Cargo.toml ]; then if command -v cargo-nextest >/dev/null 2>&1; then cargo nextest run 2>&1; else cargo test 2>&1; fi; fi'",
|
||||
"timeout": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
target/
|
||||
.git/
|
||||
.claude/
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: ci
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- push
|
||||
- pull_request
|
||||
|
||||
steps:
|
||||
- name: test
|
||||
image: rust:latest
|
||||
commands:
|
||||
- cargo fmt --check
|
||||
- cargo clippy -- -D warnings
|
||||
- cargo test
|
||||
|
||||
- name: coverage
|
||||
image: xd009642/tarpaulin
|
||||
privileged: true
|
||||
environment:
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
commands:
|
||||
- |
|
||||
apt-get update -qq && apt-get install -y -qq jq curl
|
||||
PCT=$(cargo tarpaulin 2>&1 | awk '/coverage,/{print int($1)}')
|
||||
[ -z "$PCT" ] && PCT=0
|
||||
if [ "$PCT" -ge 80 ]; then FILL="#4c1"
|
||||
elif [ "$PCT" -ge 60 ]; then FILL="#dfb317"
|
||||
else FILL="#e05d44"; fi
|
||||
echo "PCT=$PCT FILL=$FILL"
|
||||
printf '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20"><rect width="76" height="20" fill="#555"/><rect x="76" width="44" height="20" fill="%s"/><g fill="#fff" text-anchor="middle" font-family="sans-serif" font-size="11"><text x="38" y="14">coverage</text><text x="98" y="14">%s%%</text></g></svg>' "$FILL" "$PCT" > coverage.svg
|
||||
CONTENT=$(base64 coverage.svg | tr -d '\n')
|
||||
SHA=$(curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/contents/coverage.svg?ref=badges" \
|
||||
| jq -r '.sha')
|
||||
jq -n --arg msg "ci: update coverage badge [CI SKIP]" \
|
||||
--arg content "$CONTENT" --arg sha "$SHA" --arg branch "badges" \
|
||||
'{message: $msg, content: $content, sha: $sha, branch: $branch}' \
|
||||
| curl -fsSL -X PUT \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/contents/coverage.svg" \
|
||||
-d @-
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: release
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
ref:
|
||||
- refs/tags/[0-9]*
|
||||
|
||||
steps:
|
||||
- name: build-x86_64
|
||||
image: messense/rust-musl-cross:x86_64-musl
|
||||
commands:
|
||||
- cargo build --release --target x86_64-unknown-linux-musl
|
||||
- cp target/x86_64-unknown-linux-musl/release/tmuxido tmuxido-x86_64-linux
|
||||
|
||||
- name: build-aarch64
|
||||
image: messense/rust-musl-cross:aarch64-musl
|
||||
commands:
|
||||
- cargo build --release --target aarch64-unknown-linux-musl
|
||||
- cp target/aarch64-unknown-linux-musl/release/tmuxido tmuxido-aarch64-linux
|
||||
|
||||
- name: publish
|
||||
image: alpine
|
||||
environment:
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
commands:
|
||||
- apk add --no-cache curl jq
|
||||
- |
|
||||
# Delete existing release for this tag if present (handles retag scenarios)
|
||||
EXISTING_ID=$(curl -fsSL \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases/tags/$DRONE_TAG" \
|
||||
| jq -r '.id // empty')
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
curl -fsSL -X DELETE \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases/$EXISTING_ID"
|
||||
fi
|
||||
# Read DRONE_TAG via ENVIRON inside awk to avoid Drone's ${VAR} substitution
|
||||
# which would replace ${TAG} with an empty string before the shell runs.
|
||||
BODY=$(awk '
|
||||
BEGIN { tag = ENVIRON["DRONE_TAG"] }
|
||||
/^## \[/ { in_section = (index($0, "[" tag "]") > 0); next }
|
||||
in_section && /^## \[/ { exit }
|
||||
in_section { print }
|
||||
' CHANGELOG.md)
|
||||
RELEASE_ID=$(curl -fsSL -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases" \
|
||||
-d "{\"tag_name\":\"$DRONE_TAG\",\"name\":\"$DRONE_TAG\",\"body\":$(echo "$BODY" | jq -Rs .)}" \
|
||||
| jq -r .id)
|
||||
for ASSET in tmuxido-x86_64-linux tmuxido-aarch64-linux; do
|
||||
curl -fsSL -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases/$RELEASE_ID/assets" \
|
||||
-F "attachment=@$ASSET"
|
||||
done
|
||||
depends_on:
|
||||
- build-x86_64
|
||||
- build-aarch64
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"rust-mcp": {
|
||||
"type": "stdio",
|
||||
"command": "rust-mcp-server",
|
||||
"args": []
|
||||
},
|
||||
"crates": {
|
||||
"type": "stdio",
|
||||
"command": "crates-mcp",
|
||||
"args": []
|
||||
},
|
||||
"drone-ci-mcp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"drone-ci-mcp",
|
||||
"--access-token=${DRONE_TOKEN}",
|
||||
"--server-url=https://drone.cincoeuzebio.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[[windows]]
|
||||
name = "tmuxido"
|
||||
|
||||
[[windows]]
|
||||
name = "editor"
|
||||
layout = "main-horizontal"
|
||||
panes = [
|
||||
"code . ; claude --dangerously-skip-permissions",
|
||||
"clear",
|
||||
"clear"
|
||||
]
|
||||
|
||||
# [[windows]]
|
||||
# name = "build"
|
||||
# panes = []
|
||||
|
||||
# [[windows]]
|
||||
# name = "git"
|
||||
# panes = []
|
||||
@@ -0,0 +1,255 @@
|
||||
# ============================================================================
|
||||
# Project-specific tmux session configuration
|
||||
# ============================================================================
|
||||
# Place this file as .tmuxido.toml in your project root directory
|
||||
#
|
||||
# This configuration will be used when opening this specific project.
|
||||
# If this file doesn't exist, the global default_session from
|
||||
# ~/.config/tmuxido/tmuxido.toml will be used.
|
||||
#
|
||||
# Compatible with any tmux base-index setting (0 or 1)
|
||||
# ============================================================================
|
||||
|
||||
# ============================================================================
|
||||
# BASIC EXAMPLE: Single window with one pane
|
||||
# ============================================================================
|
||||
# [[windows]]
|
||||
# name = "editor"
|
||||
# panes = [] # Empty = just open a shell in the project directory
|
||||
|
||||
# ============================================================================
|
||||
# INTERMEDIATE EXAMPLE: Single window with multiple panes and layout
|
||||
# ============================================================================
|
||||
# This creates the classic layout:
|
||||
# - Main pane on top (nvim)
|
||||
# - Two smaller panes below, side by side
|
||||
#
|
||||
# [[windows]]
|
||||
# name = "editor"
|
||||
# layout = "main-horizontal"
|
||||
# panes = [
|
||||
# "nvim .", # Pane 0: Opens nvim in project root
|
||||
# "clear", # Pane 1: Shell ready for commands
|
||||
# "clear" # Pane 2: Another shell
|
||||
# ]
|
||||
|
||||
# ============================================================================
|
||||
# ADVANCED EXAMPLE: Multiple windows for a complete workflow
|
||||
# ============================================================================
|
||||
|
||||
# Window 1: Editor with side terminal
|
||||
[[windows]]
|
||||
name = "editor"
|
||||
layout = "main-vertical"
|
||||
panes = [
|
||||
"nvim .", # Main pane: Editor
|
||||
"clear" # Side pane: Terminal for quick commands
|
||||
]
|
||||
|
||||
# Window 2: Development server
|
||||
[[windows]]
|
||||
name = "server"
|
||||
panes = [
|
||||
"npm run dev" # Auto-start dev server
|
||||
]
|
||||
|
||||
# Window 3: Git operations
|
||||
[[windows]]
|
||||
name = "git"
|
||||
panes = [
|
||||
"git status", # Show current status
|
||||
"lazygit" # Or use lazygit if installed
|
||||
]
|
||||
|
||||
# Window 4: Database/Logs
|
||||
[[windows]]
|
||||
name = "logs"
|
||||
layout = "even-horizontal"
|
||||
panes = [
|
||||
"tail -f logs/development.log",
|
||||
"docker-compose logs -f"
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# PRACTICAL EXAMPLES BY PROJECT TYPE
|
||||
# ============================================================================
|
||||
|
||||
# --- Frontend React/Vue/Angular Project ---
|
||||
# [[windows]]
|
||||
# name = "code"
|
||||
# layout = "main-horizontal"
|
||||
# panes = ["nvim .", "clear", "clear"]
|
||||
#
|
||||
# [[windows]]
|
||||
# name = "dev"
|
||||
# panes = ["npm run dev"]
|
||||
#
|
||||
# [[windows]]
|
||||
# name = "test"
|
||||
# panes = ["npm run test:watch"]
|
||||
|
||||
# --- Backend API Project ---
|
||||
# [[windows]]
|
||||
# name = "editor"
|
||||
# layout = "main-vertical"
|
||||
# panes = ["nvim src/", "cargo watch -x run"] # For Rust
|
||||
# # Or: panes = ["nvim .", "nodemon server.js"] # For Node.js
|
||||
# # Or: panes = ["nvim .", "python manage.py runserver"] # For Django
|
||||
#
|
||||
# [[windows]]
|
||||
# name = "database"
|
||||
# panes = ["psql mydb"] # Or mysql, redis-cli, etc
|
||||
#
|
||||
# [[windows]]
|
||||
# name = "logs"
|
||||
# panes = ["tail -f logs/app.log"]
|
||||
|
||||
# --- Full Stack Project ---
|
||||
# [[windows]]
|
||||
# name = "frontend"
|
||||
# layout = "main-horizontal"
|
||||
# panes = [
|
||||
# "cd frontend && nvim .",
|
||||
# "cd frontend && npm run dev"
|
||||
# ]
|
||||
#
|
||||
# [[windows]]
|
||||
# name = "backend"
|
||||
# layout = "main-horizontal"
|
||||
# panes = [
|
||||
# "cd backend && nvim .",
|
||||
# "cd backend && cargo run"
|
||||
# ]
|
||||
#
|
||||
# [[windows]]
|
||||
# name = "database"
|
||||
# panes = ["docker-compose up postgres redis"]
|
||||
|
||||
# --- DevOps/Infrastructure Project ---
|
||||
# [[windows]]
|
||||
# name = "code"
|
||||
# panes = ["nvim ."]
|
||||
#
|
||||
# [[windows]]
|
||||
# name = "terraform"
|
||||
# panes = ["terraform plan"]
|
||||
#
|
||||
# [[windows]]
|
||||
# name = "k8s"
|
||||
# layout = "even-vertical"
|
||||
# panes = [
|
||||
# "kubectl get pods -w",
|
||||
# "stern -l app=myapp", # Log streaming
|
||||
# "k9s" # Kubernetes TUI
|
||||
# ]
|
||||
|
||||
# --- Data Science/ML Project ---
|
||||
# [[windows]]
|
||||
# name = "jupyter"
|
||||
# panes = ["jupyter lab"]
|
||||
#
|
||||
# [[windows]]
|
||||
# name = "editor"
|
||||
# panes = ["nvim ."]
|
||||
#
|
||||
# [[windows]]
|
||||
# name = "training"
|
||||
# layout = "even-vertical"
|
||||
# panes = [
|
||||
# "python train.py",
|
||||
# "watch -n 1 nvidia-smi" # GPU monitoring
|
||||
# ]
|
||||
|
||||
# ============================================================================
|
||||
# AVAILABLE LAYOUTS
|
||||
# ============================================================================
|
||||
# Layout determines how panes are arranged in a window:
|
||||
#
|
||||
# main-horizontal: Main pane on top, others stacked below horizontally
|
||||
# ┌─────────────────────────────┐
|
||||
# │ Main Pane │
|
||||
# ├──────────────┬──────────────┤
|
||||
# │ Pane 2 │ Pane 3 │
|
||||
# └──────────────┴──────────────┘
|
||||
#
|
||||
# main-vertical: Main pane on left, others stacked right vertically
|
||||
# ┌──────────┬──────────┐
|
||||
# │ │ Pane 2 │
|
||||
# │ Main ├──────────┤
|
||||
# │ Pane │ Pane 3 │
|
||||
# └──────────┴──────────┘
|
||||
#
|
||||
# tiled: All panes in a grid
|
||||
# ┌──────────┬──────────┐
|
||||
# │ Pane 1 │ Pane 2 │
|
||||
# ├──────────┼──────────┤
|
||||
# │ Pane 3 │ Pane 4 │
|
||||
# └──────────┴──────────┘
|
||||
#
|
||||
# even-horizontal: All panes in a row, equal width
|
||||
# ┌────┬────┬────┬────┐
|
||||
# │ P1 │ P2 │ P3 │ P4 │
|
||||
# └────┴────┴────┴────┘
|
||||
#
|
||||
# even-vertical: All panes in a column, equal height
|
||||
# ┌──────────────┐
|
||||
# │ Pane 1 │
|
||||
# ├──────────────┤
|
||||
# │ Pane 2 │
|
||||
# ├──────────────┤
|
||||
# │ Pane 3 │
|
||||
# └──────────────┘
|
||||
|
||||
# ============================================================================
|
||||
# TIPS & TRICKS
|
||||
# ============================================================================
|
||||
# 1. Commands are executed with "Enter" automatically
|
||||
# 2. Use "clear" to just open a clean shell
|
||||
# 3. Commands run in the project directory by default
|
||||
# 4. Use "cd subdir && command" to run in subdirectories
|
||||
# 5. First pane in array is pane 0 (uses the window's initial pane)
|
||||
# 6. Subsequent panes are created by splitting
|
||||
# 7. Layout is applied after all panes are created
|
||||
# 8. Empty panes array = single pane window
|
||||
# 9. You can have as many windows as you want
|
||||
# 10. Compatible with tmux base-index 0 or 1 (auto-detected)
|
||||
|
||||
# ============================================================================
|
||||
# COMMON PATTERNS
|
||||
# ============================================================================
|
||||
|
||||
# Pattern: Editor + horizontal terminal split
|
||||
# [[windows]]
|
||||
# name = "work"
|
||||
# layout = "main-horizontal"
|
||||
# panes = ["nvim .", "clear"]
|
||||
|
||||
# Pattern: Vertical split with commands side by side
|
||||
# [[windows]]
|
||||
# name = "dev"
|
||||
# layout = "even-vertical"
|
||||
# panes = ["npm run dev", "npm run test:watch"]
|
||||
|
||||
# Pattern: Monitoring dashboard
|
||||
# [[windows]]
|
||||
# name = "monitor"
|
||||
# layout = "tiled"
|
||||
# panes = [
|
||||
# "htop",
|
||||
# "watch -n 1 df -h",
|
||||
# "tail -f /var/log/syslog",
|
||||
# "docker stats"
|
||||
# ]
|
||||
|
||||
# Pattern: Simple workflow (no special layout needed)
|
||||
# [[windows]]
|
||||
# name = "code"
|
||||
# panes = []
|
||||
#
|
||||
# [[windows]]
|
||||
# name = "run"
|
||||
# panes = []
|
||||
#
|
||||
# [[windows]]
|
||||
# name = "git"
|
||||
# panes = []
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [0.6.0] - 2026-03-01
|
||||
|
||||
### Added
|
||||
- Periodic update check: on startup, if `update_check_interval_hours` have elapsed since
|
||||
the last check, tmuxido fetches the latest release tag from the Gitea API and prints a
|
||||
notice when a newer version is available (silent on network failure or no update found)
|
||||
- New `update_check` module (`src/update_check.rs`) with injected fetcher for testability
|
||||
- `update_check_interval_hours` config field (default 24, set to 0 to disable)
|
||||
- Cache file `~/.cache/tmuxido/update_check.json` tracks last-checked timestamp and
|
||||
latest known version across runs
|
||||
|
||||
## [0.5.2] - 2026-03-01
|
||||
|
||||
### Added
|
||||
- Test for `detect_arch` asserting asset name follows `tmuxido-{arch}-linux` format
|
||||
|
||||
## [0.5.1] - 2026-03-01
|
||||
|
||||
### Fixed
|
||||
- Tmux window creation now targets windows by name instead of numeric index, eliminating
|
||||
"index in use" and "can't find window" errors when `base-index` is not 0
|
||||
- Self-update asset name corrected from `x86_64-linux` to `tmuxido-x86_64-linux` to match
|
||||
what CI actually uploads, fixing 404 on `--update`
|
||||
- CI release pipeline now deletes any existing release for the tag before recreating,
|
||||
preventing 409 Conflict errors on retagged releases
|
||||
|
||||
## [0.5.0] - 2026-03-01
|
||||
|
||||
### Added
|
||||
- Interactive configuration wizard on first run with styled prompts
|
||||
- `lipgloss` dependency for beautiful terminal UI with Tokyo Night theme colors
|
||||
- Emoji-enhanced prompts and feedback during setup
|
||||
- Configure project paths interactively with comma-separated input
|
||||
- Configure `max_depth` for project discovery scanning
|
||||
- Configure cache settings (`cache_enabled`, `cache_ttl_hours`)
|
||||
- Configure default session windows interactively
|
||||
- Configure panes within each window with custom names
|
||||
- Configure startup commands for each pane (e.g., `nvim .`, `npm run dev`)
|
||||
- New `ui` module with styled render functions for all prompts
|
||||
- Comprehensive summary showing all configured settings after setup
|
||||
|
||||
## [0.4.2] - 2026-03-01
|
||||
|
||||
### Fixed
|
||||
- Version mismatch: bumped Cargo.toml version to match release tag, fixing `--update` false positive
|
||||
|
||||
## [0.4.1] - 2026-03-01
|
||||
|
||||
### Added
|
||||
- Self-update feature (`tmuxido --update`) to update binary from latest GitHub release
|
||||
|
||||
## [0.4.0] - 2026-03-01
|
||||
|
||||
### Added
|
||||
- Self-update feature (`tmuxido --update`) to update binary from latest GitHub release
|
||||
- New `self_update` module with version comparison and atomic binary replacement
|
||||
- `--update` CLI flag for in-place binary updates
|
||||
- Backup and rollback mechanism if update fails
|
||||
|
||||
## [0.3.0] - 2026-03-01
|
||||
|
||||
### Added
|
||||
- Dependency check for `fzf` and `tmux` at startup, before any operation
|
||||
- Automatic Linux package manager detection (apt, pacman, dnf, yum, zypper, emerge, xbps, apk)
|
||||
- Interactive installation prompt when required tools are missing
|
||||
- `deps` module with injectable `BinaryChecker` trait for unit testing without hitting the real system
|
||||
- Integration tests in `tests/deps.rs` (11 tests using real `SystemBinaryChecker`)
|
||||
- Docker test suite in `tests/docker/` with 15 scenarios simulating a fresh Ubuntu 24.04 user
|
||||
|
||||
### Fixed
|
||||
- Release pipeline `publish` step now reads `DRONE_TAG` via awk `ENVIRON` to prevent Drone's
|
||||
`${VAR}` substitution from wiping local shell variables before the shell runs
|
||||
|
||||
## [0.2.4] - 2026-03-01
|
||||
|
||||
### Fixed
|
||||
- Coverage percentage calculation in CI (correct field from tarpaulin JSON output)
|
||||
- Release pipeline trigger now matches `v*` tag format instead of `[0-9]*`
|
||||
|
||||
## [0.2.2] - 2026-02-28
|
||||
|
||||
### Added
|
||||
- Coverage badge generated by `cargo-tarpaulin` in CI, hosted in Gitea Generic Package Registry
|
||||
- CI status, coverage, version, and Rust edition badges in README
|
||||
|
||||
## [0.2.1] - 2026-02-28
|
||||
|
||||
### Added
|
||||
- Drone CI pipeline (`ci`) running `cargo fmt --check`, `cargo clippy`, and `cargo test` on every push and pull request
|
||||
|
||||
## [0.2.0] - 2026-02-28
|
||||
|
||||
### Added
|
||||
- Unit tests for `cache`, `session`, and `config` modules
|
||||
- Integration tests for scan, session config, and cache lifecycle
|
||||
|
||||
### Changed
|
||||
- Refactored business logic into `lib.rs` for better testability; `main.rs` is now a thin entrypoint
|
||||
|
||||
## [0.1.1] - 2026-02-28
|
||||
|
||||
### Fixed
|
||||
- Removed personal path references from default configuration and examples
|
||||
|
||||
## [0.1.0] - 2026-02-28
|
||||
|
||||
### Added
|
||||
- Initial release of tmuxido
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Rust Project — Claude Instructions
|
||||
|
||||
## Mandatory Rules
|
||||
|
||||
1. **Always write tests** alongside production code — no feature ships without tests
|
||||
2. **Always verify tests pass** after every change — the PostToolUse hook runs automatically;
|
||||
if it shows failures, fix them before moving on
|
||||
3. Run `cargo clippy -- -D warnings` and resolve all warnings
|
||||
4. Run `cargo fmt` before considering any task complete
|
||||
|
||||
## Available MCP Tools
|
||||
|
||||
Install with `curl -sSf https://raw.githubusercontent.com/USUARIO/claude-rust-scaffold/main/install.sh | sh`
|
||||
|
||||
| Server | Tools | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `rust-mcp` | `cargo_check`, `cargo_build`, `cargo_test`, `cargo_clippy`, `cargo_fmt`, `cargo_add` | Run cargo commands directly |
|
||||
| `crates` | search, versions, dependencies, docs | Explore crates.io and docs.rs |
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Unit Tests — inside `src/`
|
||||
|
||||
Place at the bottom of each source file:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_return_error_when_input_is_empty() {
|
||||
// arrange
|
||||
let input = "";
|
||||
// act
|
||||
let result = parse(input);
|
||||
// assert
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Name tests descriptively: `should_<outcome>_when_<condition>`
|
||||
- Cover: happy path, edge cases (empty, max values), error cases
|
||||
|
||||
### Integration Tests — `tests/` directory
|
||||
|
||||
- One file per feature or behavior
|
||||
- Use only public interfaces (`pub`)
|
||||
- Simulate real usage end-to-end
|
||||
|
||||
```rust
|
||||
// tests/parsing.rs
|
||||
use tmuxido::parse;
|
||||
|
||||
#[test]
|
||||
fn parses_valid_input_successfully() {
|
||||
let result = parse("valid input");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
```
|
||||
|
||||
### Snapshot Testing with `insta`
|
||||
|
||||
For complex outputs or large structs:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn renders_report_correctly() {
|
||||
let report = generate_report(&data);
|
||||
insta::assert_snapshot!(report);
|
||||
}
|
||||
```
|
||||
|
||||
Review snapshots: `cargo insta review`
|
||||
|
||||
### Property Testing with `proptest`
|
||||
|
||||
For pure functions over wide input domains:
|
||||
|
||||
```rust
|
||||
use proptest::prelude::*;
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn round_trip_encode_decode(s in ".*") {
|
||||
let encoded = encode(&s);
|
||||
prop_assert_eq!(decode(&encoded), s);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Recommended `Cargo.toml` dev-dependencies
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
insta = { version = "1", features = ["json", "yaml"] }
|
||||
mockall = "0.13"
|
||||
|
||||
# if async:
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
```
|
||||
|
||||
## Recommended Project Structure
|
||||
|
||||
```
|
||||
tmuxido/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── lib.rs # core logic (unit tests at bottom)
|
||||
│ ├── main.rs # entrypoint (thin, delegates to lib)
|
||||
│ └── module/
|
||||
│ └── mod.rs # #[cfg(test)] mod tests {} at bottom
|
||||
├── tests/
|
||||
│ └── integration.rs # integration tests
|
||||
└── benches/
|
||||
└── bench.rs # benchmarks (optional)
|
||||
```
|
||||
|
||||
Prefer `lib.rs` + `main.rs` split so logic stays testable independently of the binary entrypoint.
|
||||
Generated
+1310
File diff suppressed because it is too large
Load Diff
+18
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "tmuxido"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
toml = "0.8"
|
||||
dirs = "5.0"
|
||||
walkdir = "2.4"
|
||||
anyhow = "1.0"
|
||||
shellexpand = "3.1"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
lipgloss = "0.1"
|
||||
@@ -0,0 +1,184 @@
|
||||
<div align="center">
|
||||
<img src="docs/assets/tmuxido-logo.png" alt="tmuxido logo" width="200"/>
|
||||
</div>
|
||||
<div align="center">
|
||||
|
||||
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
||||
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
||||
[](https://git.cincoeuzebio.com/cinco/Tmuxido/releases)
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
# tmuxido
|
||||
|
||||
A Rust-based tool to quickly find and open projects in tmux using fzf. No external dependencies except tmux and fzf!
|
||||
|
||||
## Features
|
||||
|
||||
- Search for git repositories in configurable paths
|
||||
- Interactive selection using fzf
|
||||
- Native tmux session creation (no tmuxinator required!)
|
||||
- Support for project-specific `.tmuxido.toml` configs
|
||||
- Smart session switching (reuses existing sessions)
|
||||
- TOML-based configuration
|
||||
- Smart caching system for fast subsequent runs
|
||||
- Configurable cache TTL
|
||||
- Self-update capability (`tmuxido --update`)
|
||||
- Zero external dependencies (except tmux and fzf)
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.cincoeuzebio.com/cinco/Tmuxido/raw/branch/main/install.sh | sh
|
||||
```
|
||||
|
||||
Installs the latest release binary to `~/.local/bin/tmuxido`. On first run, the config file is created automatically at `~/.config/tmuxido/tmuxido.toml`.
|
||||
|
||||
### Build from source
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
cp target/release/tmuxido ~/.local/bin/
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The configuration file is located at `~/.config/tmuxido/tmuxido.toml`.
|
||||
|
||||
On first run, a default configuration will be created automatically.
|
||||
|
||||
Example configuration:
|
||||
```toml
|
||||
# List of paths where to search for projects (git repositories)
|
||||
paths = [
|
||||
"~/Projects",
|
||||
# "~/work",
|
||||
]
|
||||
|
||||
# Maximum depth to search for .git directories
|
||||
max_depth = 5
|
||||
|
||||
# Enable project caching (default: true)
|
||||
cache_enabled = true
|
||||
|
||||
# Cache TTL in hours (default: 24)
|
||||
cache_ttl_hours = 24
|
||||
|
||||
# Default session configuration (used when project has no .tmuxido.toml)
|
||||
[default_session]
|
||||
|
||||
[[default_session.windows]]
|
||||
name = "editor"
|
||||
panes = []
|
||||
|
||||
[[default_session.windows]]
|
||||
name = "terminal"
|
||||
panes = []
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Run without arguments to search all configured paths and select with fzf:
|
||||
```bash
|
||||
tmuxido
|
||||
```
|
||||
|
||||
Or provide a specific directory:
|
||||
```bash
|
||||
tmuxido /path/to/project
|
||||
```
|
||||
|
||||
Force refresh the cache (useful after adding new projects):
|
||||
```bash
|
||||
tmuxido --refresh
|
||||
# or
|
||||
tmuxido -r
|
||||
```
|
||||
|
||||
Check cache status:
|
||||
```bash
|
||||
tmuxido --cache-status
|
||||
```
|
||||
|
||||
Update tmuxido to the latest version:
|
||||
```bash
|
||||
tmuxido --update
|
||||
```
|
||||
|
||||
View help:
|
||||
```bash
|
||||
tmuxido --help
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- [tmux](https://github.com/tmux/tmux) - Terminal multiplexer
|
||||
- [fzf](https://github.com/junegunn/fzf) - For interactive selection
|
||||
|
||||
## How it works
|
||||
|
||||
1. Searches for git repositories (directories containing `.git`) in configured paths
|
||||
2. Caches the results for faster subsequent runs
|
||||
3. Presents them using fzf for selection
|
||||
4. Creates or switches to a tmux session for the selected project
|
||||
5. If a `.tmuxido.toml` config exists in the project, uses it to set up custom windows and panes
|
||||
6. Otherwise, creates a default session with two windows: "editor" and "terminal"
|
||||
|
||||
## Caching
|
||||
|
||||
The tool uses an incremental cache to keep subsequent runs fast:
|
||||
|
||||
- **Cache location**: `~/.cache/tmuxido/projects.json`
|
||||
- **Incremental updates**: On each run, only directories whose mtime changed are rescanned — no full rescans
|
||||
- **Manual refresh**: Use `--refresh` to force a full rescan
|
||||
- **Cache status**: Use `--cache-status` to inspect the cache
|
||||
|
||||
The cache persists indefinitely and is updated automatically when the filesystem changes.
|
||||
|
||||
## Project-specific Configuration
|
||||
|
||||
You can customize the tmux session layout for individual projects by creating a `.tmuxido.toml` file in the project root.
|
||||
|
||||
Example `.tmuxido.toml`:
|
||||
```toml
|
||||
[[windows]]
|
||||
name = "editor"
|
||||
panes = ["nvim"]
|
||||
layout = "main-horizontal"
|
||||
|
||||
[[windows]]
|
||||
name = "server"
|
||||
panes = ["npm run dev"]
|
||||
|
||||
[[windows]]
|
||||
name = "git"
|
||||
panes = []
|
||||
```
|
||||
|
||||
### Available Layouts
|
||||
|
||||
- `main-horizontal` - Main pane on top, others below
|
||||
- `main-vertical` - Main pane on left, others on right
|
||||
- `tiled` - All panes tiled
|
||||
- `even-horizontal` - All panes in horizontal row
|
||||
- `even-vertical` - All panes in vertical column
|
||||
|
||||
### Panes
|
||||
|
||||
Each window can have multiple panes with commands that run automatically:
|
||||
- First pane is the main window pane
|
||||
- Additional panes are created by splitting
|
||||
- Empty panes array = just open the window in the project directory
|
||||
|
||||
## Author
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/cinco">
|
||||
<img src="https://github.com/cinco.png" width="100" height="100" style="border-radius: 50%;" alt="Cinco avatar"/>
|
||||
</a>
|
||||
<br><br>
|
||||
<strong>Cinco</strong>
|
||||
<br>
|
||||
<a href="https://github.com/cinco">@cinco</a>
|
||||
</div>
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20"><rect width="76" height="20" fill="#555"/><rect x="76" width="44" height="20" fill="#e05d44"/><g fill="#fff" text-anchor="middle" font-family="sans-serif" font-size="11"><text x="38" y="14">coverage</text><text x="98" y="14">44%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 309 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
REPO="cinco/Tmuxido"
|
||||
BASE_URL="https://git.cincoeuzebio.com"
|
||||
INSTALL_DIR="$HOME/.local/bin"
|
||||
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64) file="tmuxido-x86_64-linux" ;;
|
||||
aarch64|arm64) file="tmuxido-aarch64-linux" ;;
|
||||
*) echo "Unsupported architecture: $arch" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
tag=$(curl -fsSL "$BASE_URL/api/v1/repos/$REPO/releases?limit=1&page=1" \
|
||||
| grep -o '"tag_name":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
[ -z "$tag" ] && { echo "Could not fetch latest release" >&2; exit 1; }
|
||||
|
||||
echo "Installing tmuxido $tag..."
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
curl -fsSL "$BASE_URL/$REPO/releases/download/$tag/$file" -o "$INSTALL_DIR/tmuxido"
|
||||
chmod +x "$INSTALL_DIR/tmuxido"
|
||||
echo "Installed: $INSTALL_DIR/tmuxido"
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$INSTALL_DIR:"*) ;;
|
||||
*) echo "Note: add $INSTALL_DIR to your PATH (e.g. export PATH=\"\$HOME/.local/bin:\$PATH\")" ;;
|
||||
esac
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["rustfmt", "clippy"]
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ProjectCache {
|
||||
pub projects: Vec<PathBuf>,
|
||||
pub last_updated: u64,
|
||||
/// mtime de cada diretório visitado durante o scan.
|
||||
/// Usado para detectar mudanças incrementais sem precisar varrer tudo.
|
||||
#[serde(default)]
|
||||
pub dir_mtimes: HashMap<PathBuf, u64>,
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn mtime_secs(time: SystemTime) -> u64 {
|
||||
time.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// Retorna o subconjunto mínimo de diretórios: aqueles que não têm nenhum
|
||||
/// ancestral também na lista. Evita rescanear a mesma subárvore duas vezes.
|
||||
pub(crate) fn minimal_roots(dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
dirs.iter()
|
||||
.filter(|dir| {
|
||||
!dirs
|
||||
.iter()
|
||||
.any(|other| other != *dir && dir.starts_with(other))
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl ProjectCache {
|
||||
pub fn new(projects: Vec<PathBuf>, dir_mtimes: HashMap<PathBuf, u64>) -> Self {
|
||||
Self {
|
||||
projects,
|
||||
last_updated: now_secs(),
|
||||
dir_mtimes,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cache_path() -> Result<PathBuf> {
|
||||
let cache_dir = dirs::cache_dir()
|
||||
.context("Could not determine cache directory")?
|
||||
.join("tmuxido");
|
||||
|
||||
fs::create_dir_all(&cache_dir).with_context(|| {
|
||||
format!("Failed to create cache directory: {}", cache_dir.display())
|
||||
})?;
|
||||
|
||||
Ok(cache_dir.join("projects.json"))
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Option<Self>> {
|
||||
let cache_path = Self::cache_path()?;
|
||||
|
||||
if !cache_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&cache_path)
|
||||
.with_context(|| format!("Failed to read cache file: {}", cache_path.display()))?;
|
||||
|
||||
let cache: ProjectCache = serde_json::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse cache file: {}", cache_path.display()))?;
|
||||
|
||||
Ok(Some(cache))
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let cache_path = Self::cache_path()?;
|
||||
|
||||
let content = serde_json::to_string_pretty(self).context("Failed to serialize cache")?;
|
||||
|
||||
fs::write(&cache_path, content)
|
||||
.with_context(|| format!("Failed to write cache file: {}", cache_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Valida e atualiza o cache de forma incremental.
|
||||
///
|
||||
/// 1. Remove projetos cujo `.git` não existe mais.
|
||||
/// 2. Detecta diretórios com mtime alterado.
|
||||
/// 3. Resscaneia apenas as subárvores mínimas que mudaram.
|
||||
///
|
||||
/// Retorna `true` se o cache foi modificado.
|
||||
/// Retorna `false` com `dir_mtimes` vazio (cache antigo) — chamador deve fazer rescan completo.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn validate_and_update(
|
||||
&mut self,
|
||||
scan_fn: &dyn Fn(&Path) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
|
||||
) -> Result<bool> {
|
||||
let mut changed = false;
|
||||
|
||||
// Passo 1: remover projetos cujo .git não existe mais
|
||||
let before = self.projects.len();
|
||||
self.projects.retain(|p| p.join(".git").exists());
|
||||
if self.projects.len() != before {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Sem fingerprints = cache no formato antigo; sinaliza ao chamador
|
||||
if self.dir_mtimes.is_empty() {
|
||||
return Ok(changed);
|
||||
}
|
||||
|
||||
// Passo 2: encontrar diretórios com mtime diferente do armazenado
|
||||
let changed_dirs: Vec<PathBuf> = self
|
||||
.dir_mtimes
|
||||
.iter()
|
||||
.filter(|(dir, stored_mtime)| {
|
||||
fs::metadata(dir)
|
||||
.and_then(|m| m.modified())
|
||||
.map(|t| mtime_secs(t) != **stored_mtime)
|
||||
.unwrap_or(true) // diretório sumiu = tratar como mudança
|
||||
})
|
||||
.map(|(dir, _)| dir.clone())
|
||||
.collect();
|
||||
|
||||
if changed_dirs.is_empty() {
|
||||
return Ok(changed);
|
||||
}
|
||||
|
||||
// Passo 3: resscanear apenas as raízes mínimas das subárvores alteradas
|
||||
for root in minimal_roots(&changed_dirs) {
|
||||
eprintln!("Rescanning: {}", root.display());
|
||||
|
||||
// Remover entradas antigas desta subárvore
|
||||
self.projects.retain(|p| !p.starts_with(&root));
|
||||
self.dir_mtimes.retain(|d, _| !d.starts_with(&root));
|
||||
|
||||
// Resscanear e mesclar
|
||||
let (new_projects, new_fingerprints) = scan_fn(&root)?;
|
||||
self.projects.extend(new_projects);
|
||||
self.dir_mtimes.extend(new_fingerprints);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if changed {
|
||||
self.projects.sort();
|
||||
self.projects.dedup();
|
||||
self.last_updated = now_secs();
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn age_in_seconds(&self) -> u64 {
|
||||
now_secs().saturating_sub(self.last_updated)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn should_return_empty_when_input_is_empty() {
|
||||
let result = minimal_roots(&[]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_single_dir_as_root() {
|
||||
let dirs = vec![PathBuf::from("/home/user/projects")];
|
||||
let result = minimal_roots(&dirs);
|
||||
assert_eq!(result, dirs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_exclude_nested_dirs_when_parent_is_present() {
|
||||
let dirs = vec![
|
||||
PathBuf::from("/home/user"),
|
||||
PathBuf::from("/home/user/projects"),
|
||||
];
|
||||
let result = minimal_roots(&dirs);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(result.contains(&PathBuf::from("/home/user")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_keep_sibling_dirs_that_are_not_nested() {
|
||||
let dirs = vec![
|
||||
PathBuf::from("/home/user/projects"),
|
||||
PathBuf::from("/home/user/work"),
|
||||
];
|
||||
let result = minimal_roots(&dirs);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_remove_stale_projects_when_git_dir_missing() {
|
||||
let dir = tempdir().unwrap();
|
||||
let project = dir.path().join("myproject");
|
||||
fs::create_dir_all(project.join(".git")).unwrap();
|
||||
|
||||
let mut cache = ProjectCache::new(vec![project.clone()], HashMap::new());
|
||||
assert_eq!(cache.projects.len(), 1);
|
||||
|
||||
fs::remove_dir_all(project.join(".git")).unwrap();
|
||||
|
||||
let result = cache.validate_and_update(&|_| Ok((vec![], HashMap::new())));
|
||||
assert_eq!(result.unwrap(), true);
|
||||
assert!(cache.projects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_false_when_nothing_changed() {
|
||||
let dir = tempdir().unwrap();
|
||||
let actual_mtime = fs::metadata(dir.path())
|
||||
.unwrap()
|
||||
.modified()
|
||||
.unwrap()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let mut dir_mtimes = HashMap::new();
|
||||
dir_mtimes.insert(dir.path().to_path_buf(), actual_mtime);
|
||||
let mut cache = ProjectCache::new(vec![], dir_mtimes);
|
||||
|
||||
let result = cache.validate_and_update(&|_| Ok((vec![], HashMap::new())));
|
||||
assert_eq!(result.unwrap(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_rescan_dirs_when_mtime_changed() {
|
||||
let dir = tempdir().unwrap();
|
||||
let tracked = dir.path().to_path_buf();
|
||||
|
||||
// Store mtime 0 — guaranteed to differ from the actual mtime
|
||||
let mut dir_mtimes = HashMap::new();
|
||||
dir_mtimes.insert(tracked, 0u64);
|
||||
let mut cache = ProjectCache::new(vec![], dir_mtimes);
|
||||
|
||||
let new_project = dir.path().join("discovered");
|
||||
let scan_called = std::cell::Cell::new(false);
|
||||
let result = cache.validate_and_update(&|_root| {
|
||||
scan_called.set(true);
|
||||
Ok((vec![new_project.clone()], HashMap::new()))
|
||||
});
|
||||
|
||||
assert_eq!(result.unwrap(), true);
|
||||
assert!(scan_called.get());
|
||||
assert!(cache.projects.contains(&new_project));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_false_when_dir_mtimes_empty() {
|
||||
let mut cache = ProjectCache::new(vec![], HashMap::new());
|
||||
let result = cache.validate_and_update(&|_| Ok((vec![], HashMap::new())));
|
||||
assert_eq!(result.unwrap(), false);
|
||||
}
|
||||
}
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::session::SessionConfig;
|
||||
use crate::ui;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Config {
|
||||
pub paths: Vec<String>,
|
||||
#[serde(default = "default_max_depth")]
|
||||
pub max_depth: usize,
|
||||
#[serde(default = "default_cache_enabled")]
|
||||
pub cache_enabled: bool,
|
||||
#[serde(default = "default_cache_ttl_hours")]
|
||||
pub cache_ttl_hours: u64,
|
||||
#[serde(default = "default_update_check_interval_hours")]
|
||||
pub update_check_interval_hours: u64,
|
||||
#[serde(default = "default_session_config")]
|
||||
pub default_session: SessionConfig,
|
||||
}
|
||||
|
||||
fn default_max_depth() -> usize {
|
||||
5
|
||||
}
|
||||
|
||||
fn default_cache_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_cache_ttl_hours() -> u64 {
|
||||
24
|
||||
}
|
||||
|
||||
fn default_update_check_interval_hours() -> u64 {
|
||||
24
|
||||
}
|
||||
|
||||
fn default_session_config() -> SessionConfig {
|
||||
use crate::session::Window;
|
||||
|
||||
SessionConfig {
|
||||
windows: vec![
|
||||
Window {
|
||||
name: "editor".to_string(),
|
||||
panes: vec![],
|
||||
layout: None,
|
||||
},
|
||||
Window {
|
||||
name: "terminal".to_string(),
|
||||
panes: vec![],
|
||||
layout: None,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
let config_path = Self::config_path()?;
|
||||
|
||||
if !config_path.exists() {
|
||||
return Ok(Self::default_config());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&config_path)
|
||||
.with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
|
||||
|
||||
let config: Config = toml::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse config file: {}", config_path.display()))?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn config_path() -> Result<PathBuf> {
|
||||
let config_dir = dirs::config_dir()
|
||||
.context("Could not determine config directory")?
|
||||
.join("tmuxido");
|
||||
|
||||
Ok(config_dir.join("tmuxido.toml"))
|
||||
}
|
||||
|
||||
pub fn ensure_config_exists() -> Result<PathBuf> {
|
||||
let config_path = Self::config_path()?;
|
||||
|
||||
if !config_path.exists() {
|
||||
let config_dir = config_path
|
||||
.parent()
|
||||
.context("Could not get parent directory")?;
|
||||
|
||||
fs::create_dir_all(config_dir).with_context(|| {
|
||||
format!(
|
||||
"Failed to create config directory: {}",
|
||||
config_dir.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Run interactive configuration wizard
|
||||
let paths = Self::prompt_for_paths()?;
|
||||
let max_depth = Self::prompt_for_max_depth()?;
|
||||
let cache_enabled = Self::prompt_for_cache_enabled()?;
|
||||
let cache_ttl_hours = if cache_enabled {
|
||||
Self::prompt_for_cache_ttl()?
|
||||
} else {
|
||||
24
|
||||
};
|
||||
let windows = Self::prompt_for_windows()?;
|
||||
|
||||
// Render styled success message before moving windows
|
||||
ui::render_config_created(&paths, max_depth, cache_enabled, cache_ttl_hours, &windows);
|
||||
|
||||
let config = Config {
|
||||
paths: paths.clone(),
|
||||
max_depth,
|
||||
cache_enabled,
|
||||
cache_ttl_hours,
|
||||
update_check_interval_hours: default_update_check_interval_hours(),
|
||||
default_session: SessionConfig { windows },
|
||||
};
|
||||
|
||||
let toml_string =
|
||||
toml::to_string_pretty(&config).context("Failed to serialize config")?;
|
||||
|
||||
fs::write(&config_path, toml_string).with_context(|| {
|
||||
format!("Failed to write config file: {}", config_path.display())
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(config_path)
|
||||
}
|
||||
|
||||
fn prompt_for_paths() -> Result<Vec<String>> {
|
||||
// Render styled welcome banner
|
||||
ui::render_welcome_banner();
|
||||
|
||||
// Get input with styled prompt
|
||||
let input = ui::render_paths_prompt()?;
|
||||
let paths = Self::parse_paths_input(&input);
|
||||
|
||||
if paths.is_empty() {
|
||||
ui::render_fallback_message();
|
||||
Ok(vec![
|
||||
dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join("Projects")
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
])
|
||||
} else {
|
||||
Ok(paths)
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_for_max_depth() -> Result<usize> {
|
||||
ui::render_section_header("Scan Settings");
|
||||
let input = ui::render_max_depth_prompt()?;
|
||||
Ok(ui::parse_max_depth_input(&input).unwrap_or(5))
|
||||
}
|
||||
|
||||
fn prompt_for_cache_enabled() -> Result<bool> {
|
||||
ui::render_section_header("Cache Settings");
|
||||
let input = ui::render_cache_enabled_prompt()?;
|
||||
Ok(ui::parse_cache_enabled_input(&input).unwrap_or(true))
|
||||
}
|
||||
|
||||
fn prompt_for_cache_ttl() -> Result<u64> {
|
||||
let input = ui::render_cache_ttl_prompt()?;
|
||||
Ok(ui::parse_cache_ttl_input(&input).unwrap_or(24))
|
||||
}
|
||||
|
||||
fn prompt_for_windows() -> Result<Vec<crate::session::Window>> {
|
||||
ui::render_section_header("Default Session");
|
||||
let input = ui::render_windows_prompt()?;
|
||||
|
||||
let window_names = ui::parse_comma_separated_list(&input);
|
||||
|
||||
let names = if window_names.is_empty() {
|
||||
vec!["editor".to_string(), "terminal".to_string()]
|
||||
} else {
|
||||
window_names
|
||||
};
|
||||
|
||||
// Configure panes for each window
|
||||
let mut windows = Vec::new();
|
||||
for name in names {
|
||||
let panes = Self::prompt_for_panes(&name)?;
|
||||
windows.push(crate::session::Window {
|
||||
name,
|
||||
panes,
|
||||
layout: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(windows)
|
||||
}
|
||||
|
||||
fn prompt_for_panes(window_name: &str) -> Result<Vec<String>> {
|
||||
let input = ui::render_panes_prompt(window_name)?;
|
||||
|
||||
let pane_names = ui::parse_comma_separated_list(&input);
|
||||
|
||||
if pane_names.is_empty() {
|
||||
// Single pane, no commands
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// Ask for commands for each pane
|
||||
let mut panes = Vec::new();
|
||||
for pane_name in pane_names {
|
||||
let command = ui::render_pane_command_prompt(&pane_name)?;
|
||||
panes.push(command);
|
||||
}
|
||||
|
||||
Ok(panes)
|
||||
}
|
||||
|
||||
fn parse_paths_input(input: &str) -> Vec<String> {
|
||||
input
|
||||
.trim()
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn default_config() -> Self {
|
||||
Config {
|
||||
paths: vec![
|
||||
dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
.join("Projects")
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
],
|
||||
max_depth: 5,
|
||||
cache_enabled: true,
|
||||
cache_ttl_hours: 24,
|
||||
update_check_interval_hours: default_update_check_interval_hours(),
|
||||
default_session: default_session_config(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_use_defaults_when_optional_fields_missing() {
|
||||
let toml_str = r#"paths = ["/home/user/projects"]"#;
|
||||
let config: Config = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.max_depth, 5);
|
||||
assert!(config.cache_enabled);
|
||||
assert_eq!(config.cache_ttl_hours, 24);
|
||||
assert_eq!(config.update_check_interval_hours, 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_full_config_correctly() {
|
||||
let toml_str = r#"
|
||||
paths = ["/foo", "/bar"]
|
||||
max_depth = 3
|
||||
cache_enabled = false
|
||||
cache_ttl_hours = 12
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.paths, vec!["/foo", "/bar"]);
|
||||
assert_eq!(config.max_depth, 3);
|
||||
assert!(!config.cache_enabled);
|
||||
assert_eq!(config.cache_ttl_hours, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_reject_invalid_toml() {
|
||||
let result: Result<Config, _> = toml::from_str("not valid toml ]][[");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_single_path() {
|
||||
let input = "~/Projects";
|
||||
let paths = Config::parse_paths_input(input);
|
||||
assert_eq!(paths, vec!["~/Projects"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_multiple_paths_with_commas() {
|
||||
let input = "~/Projects, ~/work, ~/repos";
|
||||
let paths = Config::parse_paths_input(input);
|
||||
assert_eq!(paths, vec!["~/Projects", "~/work", "~/repos"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_trim_whitespace_from_paths() {
|
||||
let input = " ~/Projects , ~/work ";
|
||||
let paths = Config::parse_paths_input(input);
|
||||
assert_eq!(paths, vec!["~/Projects", "~/work"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_empty_vec_for_empty_input() {
|
||||
let input = "";
|
||||
let paths = Config::parse_paths_input(input);
|
||||
assert!(paths.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_empty_vec_for_whitespace_only() {
|
||||
let input = " ";
|
||||
let paths = Config::parse_paths_input(input);
|
||||
assert!(paths.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_handle_empty_parts_between_commas() {
|
||||
let input = "~/Projects,,~/work";
|
||||
let paths = Config::parse_paths_input(input);
|
||||
assert_eq!(paths, vec!["~/Projects", "~/work"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_ui_parse_functions_for_max_depth() {
|
||||
// Test that our UI parsing produces expected results
|
||||
assert_eq!(ui::parse_max_depth_input(""), None);
|
||||
assert_eq!(ui::parse_max_depth_input("5"), Some(5));
|
||||
assert_eq!(ui::parse_max_depth_input("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_ui_parse_functions_for_cache_enabled() {
|
||||
assert_eq!(ui::parse_cache_enabled_input(""), None);
|
||||
assert_eq!(ui::parse_cache_enabled_input("y"), Some(true));
|
||||
assert_eq!(ui::parse_cache_enabled_input("n"), Some(false));
|
||||
assert_eq!(ui::parse_cache_enabled_input("maybe"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_ui_parse_functions_for_cache_ttl() {
|
||||
assert_eq!(ui::parse_cache_ttl_input(""), None);
|
||||
assert_eq!(ui::parse_cache_ttl_input("24"), Some(24));
|
||||
assert_eq!(ui::parse_cache_ttl_input("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_ui_parse_functions_for_window_names() {
|
||||
let result = ui::parse_comma_separated_list("editor, terminal, server");
|
||||
assert_eq!(result, vec!["editor", "terminal", "server"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_config_with_windows_and_panes() {
|
||||
let toml_str = r#"
|
||||
paths = ["/projects"]
|
||||
max_depth = 3
|
||||
cache_enabled = true
|
||||
cache_ttl_hours = 12
|
||||
|
||||
[default_session]
|
||||
[[default_session.windows]]
|
||||
name = "editor"
|
||||
panes = ["nvim .", "git status"]
|
||||
|
||||
[[default_session.windows]]
|
||||
name = "terminal"
|
||||
panes = []
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.paths, vec!["/projects"]);
|
||||
assert_eq!(config.max_depth, 3);
|
||||
assert!(config.cache_enabled);
|
||||
assert_eq!(config.cache_ttl_hours, 12);
|
||||
assert_eq!(config.default_session.windows.len(), 2);
|
||||
assert_eq!(config.default_session.windows[0].name, "editor");
|
||||
assert_eq!(config.default_session.windows[0].panes.len(), 2);
|
||||
assert_eq!(config.default_session.windows[0].panes[0], "nvim .");
|
||||
assert_eq!(config.default_session.windows[0].panes[1], "git status");
|
||||
assert_eq!(config.default_session.windows[1].name, "terminal");
|
||||
assert!(config.default_session.windows[1].panes.is_empty());
|
||||
}
|
||||
}
|
||||
+425
@@ -0,0 +1,425 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::io::{self, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Required external tool dependencies.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Dep {
|
||||
Fzf,
|
||||
Tmux,
|
||||
}
|
||||
|
||||
/// Supported Linux package managers.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PackageManager {
|
||||
Apt,
|
||||
Pacman,
|
||||
Dnf,
|
||||
Yum,
|
||||
Zypper,
|
||||
Emerge,
|
||||
Xbps,
|
||||
Apk,
|
||||
}
|
||||
|
||||
/// Injectable binary availability checker — enables unit testing without hitting the real system.
|
||||
pub trait BinaryChecker {
|
||||
fn is_available(&self, name: &str) -> bool;
|
||||
}
|
||||
|
||||
/// Production implementation: delegates to the system `which` command.
|
||||
pub struct SystemBinaryChecker;
|
||||
|
||||
impl BinaryChecker for SystemBinaryChecker {
|
||||
fn is_available(&self, name: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(name)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl Dep {
|
||||
pub fn all() -> Vec<Self> {
|
||||
vec![Self::Fzf, Self::Tmux]
|
||||
}
|
||||
|
||||
pub fn binary_name(&self) -> &str {
|
||||
match self {
|
||||
Self::Fzf => "fzf",
|
||||
Self::Tmux => "tmux",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn package_name(&self) -> &str {
|
||||
match self {
|
||||
Self::Fzf => "fzf",
|
||||
Self::Tmux => "tmux",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PackageManager {
|
||||
/// Ordered list for detection — more specific managers first.
|
||||
pub fn all_ordered() -> Vec<Self> {
|
||||
vec![
|
||||
Self::Apt,
|
||||
Self::Pacman,
|
||||
Self::Dnf,
|
||||
Self::Yum,
|
||||
Self::Zypper,
|
||||
Self::Emerge,
|
||||
Self::Xbps,
|
||||
Self::Apk,
|
||||
]
|
||||
}
|
||||
|
||||
/// Binary used to detect whether this package manager is installed.
|
||||
pub fn detection_binary(&self) -> &str {
|
||||
match self {
|
||||
Self::Apt => "apt",
|
||||
Self::Pacman => "pacman",
|
||||
Self::Dnf => "dnf",
|
||||
Self::Yum => "yum",
|
||||
Self::Zypper => "zypper",
|
||||
Self::Emerge => "emerge",
|
||||
Self::Xbps => "xbps-install",
|
||||
Self::Apk => "apk",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &str {
|
||||
match self {
|
||||
Self::Apt => "apt (Debian/Ubuntu)",
|
||||
Self::Pacman => "pacman (Arch Linux)",
|
||||
Self::Dnf => "dnf (Fedora)",
|
||||
Self::Yum => "yum (RHEL/CentOS)",
|
||||
Self::Zypper => "zypper (openSUSE)",
|
||||
Self::Emerge => "emerge (Gentoo)",
|
||||
Self::Xbps => "xbps-install (Void Linux)",
|
||||
Self::Apk => "apk (Alpine Linux)",
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the full install command (including `sudo`) for the given packages.
|
||||
pub fn install_command(&self, packages: &[&str]) -> Vec<String> {
|
||||
let mut cmd = vec!["sudo".to_string()];
|
||||
match self {
|
||||
Self::Apt => cmd.extend(["apt", "install", "-y"].map(String::from)),
|
||||
Self::Pacman => cmd.extend(["pacman", "-S", "--noconfirm"].map(String::from)),
|
||||
Self::Dnf => cmd.extend(["dnf", "install", "-y"].map(String::from)),
|
||||
Self::Yum => cmd.extend(["yum", "install", "-y"].map(String::from)),
|
||||
Self::Zypper => cmd.extend(["zypper", "install", "-y"].map(String::from)),
|
||||
Self::Emerge => cmd.extend(["emerge"].map(String::from)),
|
||||
Self::Xbps => cmd.extend(["xbps-install", "-y"].map(String::from)),
|
||||
Self::Apk => cmd.extend(["apk", "add"].map(String::from)),
|
||||
}
|
||||
cmd.extend(packages.iter().map(|&s| s.to_string()));
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the required deps that are not currently installed.
|
||||
pub fn check_missing<C: BinaryChecker>(checker: &C) -> Vec<Dep> {
|
||||
Dep::all()
|
||||
.into_iter()
|
||||
.filter(|dep| !checker.is_available(dep.binary_name()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the first supported package manager found on the system.
|
||||
pub fn detect_package_manager<C: BinaryChecker>(checker: &C) -> Option<PackageManager> {
|
||||
PackageManager::all_ordered()
|
||||
.into_iter()
|
||||
.find(|pm| checker.is_available(pm.detection_binary()))
|
||||
}
|
||||
|
||||
/// Checks for missing dependencies, informs the user, and offers to install them.
|
||||
///
|
||||
/// Returns `Ok(())` if all deps are available (or successfully installed).
|
||||
pub fn ensure_dependencies() -> Result<()> {
|
||||
let checker = SystemBinaryChecker;
|
||||
let missing = check_missing(&checker);
|
||||
|
||||
if missing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!("The following required tools are not installed:");
|
||||
for dep in &missing {
|
||||
eprintln!(" ✗ {}", dep.binary_name());
|
||||
}
|
||||
eprintln!();
|
||||
|
||||
let pm = detect_package_manager(&checker).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No supported package manager found. Please install {} manually.",
|
||||
missing
|
||||
.iter()
|
||||
.map(|d| d.binary_name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" and ")
|
||||
)
|
||||
})?;
|
||||
|
||||
let packages: Vec<&str> = missing.iter().map(|d| d.package_name()).collect();
|
||||
let cmd = pm.install_command(&packages);
|
||||
|
||||
eprintln!("Detected package manager: {}", pm.display_name());
|
||||
eprintln!("Install command: {}", cmd.join(" "));
|
||||
eprint!("\nProceed with installation? [Y/n] ");
|
||||
io::stdout().flush().ok();
|
||||
|
||||
let mut answer = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut answer)
|
||||
.context("Failed to read user input")?;
|
||||
|
||||
let answer = answer.trim().to_lowercase();
|
||||
if answer == "n" || answer == "no" {
|
||||
anyhow::bail!(
|
||||
"Installation cancelled. Please install {} manually before running tmuxido.",
|
||||
missing
|
||||
.iter()
|
||||
.map(|d| d.binary_name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" and ")
|
||||
);
|
||||
}
|
||||
|
||||
let (program, args) = cmd
|
||||
.split_first()
|
||||
.expect("install_command always returns at least one element");
|
||||
|
||||
let status = Command::new(program)
|
||||
.args(args)
|
||||
.status()
|
||||
.with_context(|| format!("Failed to run: {}", cmd.join(" ")))?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!(
|
||||
"Installation failed. Please install {} manually.",
|
||||
missing
|
||||
.iter()
|
||||
.map(|d| d.binary_name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" and ")
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!("Installation complete!");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct MockChecker {
|
||||
available: Vec<String>,
|
||||
}
|
||||
|
||||
impl MockChecker {
|
||||
fn with(available: &[&str]) -> Self {
|
||||
Self {
|
||||
available: available.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BinaryChecker for MockChecker {
|
||||
fn is_available(&self, name: &str) -> bool {
|
||||
self.available.iter().any(|s| s == name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dep ---
|
||||
|
||||
#[test]
|
||||
fn should_return_fzf_binary_name() {
|
||||
assert_eq!(Dep::Fzf.binary_name(), "fzf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_tmux_binary_name() {
|
||||
assert_eq!(Dep::Tmux.binary_name(), "tmux");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_include_fzf_and_tmux_in_all_deps() {
|
||||
let deps = Dep::all();
|
||||
assert!(deps.contains(&Dep::Fzf));
|
||||
assert!(deps.contains(&Dep::Tmux));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_same_package_name_as_binary_for_fzf() {
|
||||
assert_eq!(Dep::Fzf.package_name(), "fzf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_same_package_name_as_binary_for_tmux() {
|
||||
assert_eq!(Dep::Tmux.package_name(), "tmux");
|
||||
}
|
||||
|
||||
// --- check_missing ---
|
||||
|
||||
#[test]
|
||||
fn should_return_empty_when_all_deps_present() {
|
||||
let checker = MockChecker::with(&["fzf", "tmux"]);
|
||||
assert!(check_missing(&checker).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_detect_fzf_as_missing_when_only_tmux_present() {
|
||||
let checker = MockChecker::with(&["tmux"]);
|
||||
let missing = check_missing(&checker);
|
||||
assert_eq!(missing, vec![Dep::Fzf]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_detect_tmux_as_missing_when_only_fzf_present() {
|
||||
let checker = MockChecker::with(&["fzf"]);
|
||||
let missing = check_missing(&checker);
|
||||
assert_eq!(missing, vec![Dep::Tmux]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_detect_both_missing_when_none_present() {
|
||||
let checker = MockChecker::with(&[]);
|
||||
let missing = check_missing(&checker);
|
||||
assert_eq!(missing.len(), 2);
|
||||
assert!(missing.contains(&Dep::Fzf));
|
||||
assert!(missing.contains(&Dep::Tmux));
|
||||
}
|
||||
|
||||
// --- detect_package_manager ---
|
||||
|
||||
#[test]
|
||||
fn should_detect_apt_when_available() {
|
||||
let checker = MockChecker::with(&["apt"]);
|
||||
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Apt));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_detect_pacman_when_available() {
|
||||
let checker = MockChecker::with(&["pacman"]);
|
||||
assert_eq!(
|
||||
detect_package_manager(&checker),
|
||||
Some(PackageManager::Pacman)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_detect_dnf_when_available() {
|
||||
let checker = MockChecker::with(&["dnf"]);
|
||||
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Dnf));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_detect_xbps_when_xbps_install_available() {
|
||||
let checker = MockChecker::with(&["xbps-install"]);
|
||||
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Xbps));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_detect_apk_when_available() {
|
||||
let checker = MockChecker::with(&["apk"]);
|
||||
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Apk));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_none_when_no_pm_detected() {
|
||||
let checker = MockChecker::with(&["ls", "sh"]);
|
||||
assert_eq!(detect_package_manager(&checker), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_prefer_apt_over_pacman_when_both_available() {
|
||||
let checker = MockChecker::with(&["apt", "pacman"]);
|
||||
assert_eq!(detect_package_manager(&checker), Some(PackageManager::Apt));
|
||||
}
|
||||
|
||||
// --- PackageManager::install_command ---
|
||||
|
||||
#[test]
|
||||
fn should_build_apt_install_command() {
|
||||
let cmd = PackageManager::Apt.install_command(&["fzf", "tmux"]);
|
||||
assert_eq!(cmd, vec!["sudo", "apt", "install", "-y", "fzf", "tmux"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_pacman_install_command() {
|
||||
let cmd = PackageManager::Pacman.install_command(&["fzf", "tmux"]);
|
||||
assert_eq!(
|
||||
cmd,
|
||||
vec!["sudo", "pacman", "-S", "--noconfirm", "fzf", "tmux"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_dnf_install_command() {
|
||||
let cmd = PackageManager::Dnf.install_command(&["fzf"]);
|
||||
assert_eq!(cmd, vec!["sudo", "dnf", "install", "-y", "fzf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_yum_install_command() {
|
||||
let cmd = PackageManager::Yum.install_command(&["tmux"]);
|
||||
assert_eq!(cmd, vec!["sudo", "yum", "install", "-y", "tmux"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_zypper_install_command() {
|
||||
let cmd = PackageManager::Zypper.install_command(&["fzf", "tmux"]);
|
||||
assert_eq!(cmd, vec!["sudo", "zypper", "install", "-y", "fzf", "tmux"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_emerge_install_command() {
|
||||
let cmd = PackageManager::Emerge.install_command(&["fzf"]);
|
||||
assert_eq!(cmd, vec!["sudo", "emerge", "fzf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_xbps_install_command() {
|
||||
let cmd = PackageManager::Xbps.install_command(&["tmux"]);
|
||||
assert_eq!(cmd, vec!["sudo", "xbps-install", "-y", "tmux"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_apk_install_command() {
|
||||
let cmd = PackageManager::Apk.install_command(&["fzf", "tmux"]);
|
||||
assert_eq!(cmd, vec!["sudo", "apk", "add", "fzf", "tmux"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_command_for_single_package() {
|
||||
let cmd = PackageManager::Apt.install_command(&["fzf"]);
|
||||
assert_eq!(cmd, vec!["sudo", "apt", "install", "-y", "fzf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_include_sudo_for_all_package_managers() {
|
||||
for pm in PackageManager::all_ordered() {
|
||||
let cmd = pm.install_command(&["fzf"]);
|
||||
assert_eq!(
|
||||
cmd.first().map(String::as_str),
|
||||
Some("sudo"),
|
||||
"{} install command should start with sudo",
|
||||
pm.display_name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_include_all_packages_in_command() {
|
||||
let cmd = PackageManager::Apt.install_command(&["fzf", "tmux", "git"]);
|
||||
assert!(cmd.contains(&"fzf".to_string()));
|
||||
assert!(cmd.contains(&"tmux".to_string()));
|
||||
assert!(cmd.contains(&"git".to_string()));
|
||||
}
|
||||
}
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod deps;
|
||||
pub mod self_update;
|
||||
pub mod session;
|
||||
pub mod ui;
|
||||
pub mod update_check;
|
||||
|
||||
use anyhow::Result;
|
||||
use cache::ProjectCache;
|
||||
use config::Config;
|
||||
use session::{SessionConfig, TmuxSession};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
pub fn show_cache_status(config: &Config) -> Result<()> {
|
||||
if !config.cache_enabled {
|
||||
println!("Cache is disabled in configuration");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(cache) = ProjectCache::load()? {
|
||||
let age_seconds = cache.age_in_seconds();
|
||||
let age_hours = age_seconds / 3600;
|
||||
let age_minutes = (age_seconds % 3600) / 60;
|
||||
|
||||
println!("Cache status:");
|
||||
println!(" Location: {}", ProjectCache::cache_path()?.display());
|
||||
println!(" Projects cached: {}", cache.projects.len());
|
||||
println!(" Directories tracked: {}", cache.dir_mtimes.len());
|
||||
println!(" Last updated: {}h {}m ago", age_hours, age_minutes);
|
||||
} else {
|
||||
println!("No cache found");
|
||||
println!(" Run without --cache-status to create it");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>> {
|
||||
get_projects_internal(
|
||||
config,
|
||||
force_refresh,
|
||||
&ProjectCache::load,
|
||||
&|cache| cache.save(),
|
||||
&scan_all_roots,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn get_projects_internal(
|
||||
config: &Config,
|
||||
force_refresh: bool,
|
||||
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
|
||||
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
|
||||
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
|
||||
) -> Result<Vec<PathBuf>> {
|
||||
if !config.cache_enabled || force_refresh {
|
||||
let (projects, fingerprints) = scanner(config)?;
|
||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||
cache_saver(&cache)?;
|
||||
eprintln!("Cache updated with {} projects", projects.len());
|
||||
return Ok(projects);
|
||||
}
|
||||
|
||||
if let Some(mut cache) = cache_loader()? {
|
||||
// Cache no formato antigo (sem dir_mtimes) → atualizar com rescan completo
|
||||
if cache.dir_mtimes.is_empty() {
|
||||
eprintln!("Upgrading cache, scanning for projects...");
|
||||
let (projects, fingerprints) = scanner(config)?;
|
||||
let new_cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||
cache_saver(&new_cache)?;
|
||||
eprintln!("Cache updated with {} projects", projects.len());
|
||||
return Ok(projects);
|
||||
}
|
||||
|
||||
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
|
||||
if changed {
|
||||
cache_saver(&cache)?;
|
||||
eprintln!(
|
||||
"Cache updated incrementally ({} projects)",
|
||||
cache.projects.len()
|
||||
);
|
||||
} else {
|
||||
eprintln!("Using cached projects ({} projects)", cache.projects.len());
|
||||
}
|
||||
return Ok(cache.projects);
|
||||
}
|
||||
|
||||
// Sem cache ainda — scan completo inicial
|
||||
eprintln!("No cache found, scanning for projects...");
|
||||
let (projects, fingerprints) = scanner(config)?;
|
||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||
cache_saver(&cache)?;
|
||||
eprintln!("Cache updated with {} projects", projects.len());
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
pub fn scan_all_roots(config: &Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)> {
|
||||
let mut all_projects = Vec::new();
|
||||
let mut all_fingerprints = HashMap::new();
|
||||
|
||||
for path_str in &config.paths {
|
||||
let path = PathBuf::from(shellexpand::tilde(path_str).to_string());
|
||||
|
||||
if !path.exists() {
|
||||
eprintln!("Warning: Path does not exist: {}", path.display());
|
||||
continue;
|
||||
}
|
||||
|
||||
eprintln!("Scanning: {}", path.display());
|
||||
|
||||
let (projects, fingerprints) = scan_from_root(&path, config)?;
|
||||
all_projects.extend(projects);
|
||||
all_fingerprints.extend(fingerprints);
|
||||
}
|
||||
|
||||
all_projects.sort();
|
||||
all_projects.dedup();
|
||||
|
||||
Ok((all_projects, all_fingerprints))
|
||||
}
|
||||
|
||||
pub fn scan_from_root(
|
||||
root: &Path,
|
||||
config: &Config,
|
||||
) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)> {
|
||||
let mut projects = Vec::new();
|
||||
let mut fingerprints = HashMap::new();
|
||||
|
||||
for entry in WalkDir::new(root)
|
||||
.max_depth(config.max_depth)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.filter_entry(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.map(|s| !s.starts_with('.') || s == ".git")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
{
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if entry.file_type().is_dir() {
|
||||
if entry.file_name() == ".git" {
|
||||
// Projeto encontrado
|
||||
if let Some(parent) = entry.path().parent() {
|
||||
projects.push(parent.to_path_buf());
|
||||
}
|
||||
} else {
|
||||
// Registrar mtime para detecção de mudanças futuras
|
||||
if let Ok(metadata) = entry.metadata()
|
||||
&& let Ok(modified) = metadata.modified()
|
||||
{
|
||||
let mtime = modified
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
fingerprints.insert(entry.path().to_path_buf(), mtime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((projects, fingerprints))
|
||||
}
|
||||
|
||||
pub fn launch_tmux_session(selected: &Path, config: &Config) -> Result<()> {
|
||||
// Try to load project-specific config, fallback to global default
|
||||
let session_config = SessionConfig::load_from_project(selected)?
|
||||
.unwrap_or_else(|| config.default_session.clone());
|
||||
|
||||
// Create tmux session
|
||||
let tmux_session = TmuxSession::new(selected);
|
||||
tmux_session.create(&session_config)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
fn create_test_config(cache_enabled: bool) -> Config {
|
||||
Config {
|
||||
paths: vec!["/tmp/test".to_string()],
|
||||
max_depth: 3,
|
||||
cache_enabled,
|
||||
cache_ttl_hours: 24,
|
||||
update_check_interval_hours: 24,
|
||||
default_session: session::SessionConfig { windows: vec![] },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_scan_when_cache_disabled() {
|
||||
let config = create_test_config(false);
|
||||
let projects = vec![PathBuf::from("/tmp/test/project1")];
|
||||
let fingerprints = HashMap::new();
|
||||
let expected_projects = projects.clone();
|
||||
|
||||
let scanner_called = RefCell::new(false);
|
||||
let saver_called = RefCell::new(false);
|
||||
|
||||
let result = get_projects_internal(
|
||||
&config,
|
||||
false,
|
||||
&|| panic!("should not load cache when disabled"),
|
||||
&|_| {
|
||||
*saver_called.borrow_mut() = true;
|
||||
Ok(())
|
||||
},
|
||||
&|_| {
|
||||
*scanner_called.borrow_mut() = true;
|
||||
Ok((expected_projects.clone(), fingerprints.clone()))
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(scanner_called.into_inner());
|
||||
assert!(saver_called.into_inner());
|
||||
assert_eq!(result.unwrap(), projects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_scan_when_force_refresh() {
|
||||
let config = create_test_config(true);
|
||||
let projects = vec![PathBuf::from("/tmp/test/project1")];
|
||||
let fingerprints = HashMap::new();
|
||||
let expected_projects = projects.clone();
|
||||
|
||||
let scanner_called = RefCell::new(false);
|
||||
let saver_called = RefCell::new(false);
|
||||
|
||||
let result = get_projects_internal(
|
||||
&config,
|
||||
true,
|
||||
&|| panic!("should not load cache when force refresh"),
|
||||
&|_| {
|
||||
*saver_called.borrow_mut() = true;
|
||||
Ok(())
|
||||
},
|
||||
&|_| {
|
||||
*scanner_called.borrow_mut() = true;
|
||||
Ok((expected_projects.clone(), fingerprints.clone()))
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(scanner_called.into_inner());
|
||||
assert!(saver_called.into_inner());
|
||||
assert_eq!(result.unwrap(), projects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_do_initial_scan_when_no_cache_exists() {
|
||||
let config = create_test_config(true);
|
||||
let projects = vec![PathBuf::from("/tmp/test/project1")];
|
||||
let fingerprints = HashMap::new();
|
||||
let expected_projects = projects.clone();
|
||||
|
||||
let loader_called = RefCell::new(false);
|
||||
let scanner_called = RefCell::new(false);
|
||||
let saver_called = RefCell::new(false);
|
||||
|
||||
let result = get_projects_internal(
|
||||
&config,
|
||||
false,
|
||||
&|| {
|
||||
*loader_called.borrow_mut() = true;
|
||||
Ok(None)
|
||||
},
|
||||
&|_| {
|
||||
*saver_called.borrow_mut() = true;
|
||||
Ok(())
|
||||
},
|
||||
&|_| {
|
||||
*scanner_called.borrow_mut() = true;
|
||||
Ok((expected_projects.clone(), fingerprints.clone()))
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(loader_called.into_inner());
|
||||
assert!(scanner_called.into_inner());
|
||||
assert!(saver_called.into_inner());
|
||||
assert_eq!(result.unwrap(), projects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_upgrade_old_cache_format() {
|
||||
let config = create_test_config(true);
|
||||
let old_projects = vec![PathBuf::from("/old/project")];
|
||||
let new_projects = vec![
|
||||
PathBuf::from("/new/project1"),
|
||||
PathBuf::from("/new/project2"),
|
||||
];
|
||||
let new_fingerprints = HashMap::from([(PathBuf::from("/new"), 12345u64)]);
|
||||
|
||||
// Use RefCell<Option<>> to allow moving into closure multiple times
|
||||
let old_cache = RefCell::new(Some(ProjectCache::new(old_projects, HashMap::new())));
|
||||
|
||||
let loader_called = RefCell::new(false);
|
||||
let scanner_called = RefCell::new(false);
|
||||
let saver_count = RefCell::new(0);
|
||||
|
||||
let result = get_projects_internal(
|
||||
&config,
|
||||
false,
|
||||
&|| {
|
||||
*loader_called.borrow_mut() = true;
|
||||
// Take the cache out of the RefCell
|
||||
Ok(old_cache.borrow_mut().take())
|
||||
},
|
||||
&|_| {
|
||||
*saver_count.borrow_mut() += 1;
|
||||
Ok(())
|
||||
},
|
||||
&|_| {
|
||||
*scanner_called.borrow_mut() = true;
|
||||
Ok((new_projects.clone(), new_fingerprints.clone()))
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(loader_called.into_inner());
|
||||
assert!(scanner_called.into_inner());
|
||||
assert_eq!(*saver_count.borrow(), 1);
|
||||
assert_eq!(result.unwrap(), new_projects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_cached_projects_when_nothing_changed() {
|
||||
let config = create_test_config(true);
|
||||
let cached_projects = vec![
|
||||
PathBuf::from("/nonexistent/project1"),
|
||||
PathBuf::from("/nonexistent/project2"),
|
||||
];
|
||||
// Use a path that doesn't exist - validate_and_update will skip rescan
|
||||
// because it can't check mtime of non-existent directory
|
||||
let cached_fingerprints =
|
||||
HashMap::from([(PathBuf::from("/definitely_nonexistent_path_xyz"), 12345u64)]);
|
||||
|
||||
// Use RefCell<Option<>> to allow moving into closure multiple times
|
||||
let cache = RefCell::new(Some(ProjectCache::new(
|
||||
cached_projects.clone(),
|
||||
cached_fingerprints,
|
||||
)));
|
||||
|
||||
let loader_called = RefCell::new(false);
|
||||
let scanner_called = RefCell::new(false);
|
||||
let saver_count = RefCell::new(0);
|
||||
|
||||
let result = get_projects_internal(
|
||||
&config,
|
||||
false,
|
||||
&|| {
|
||||
*loader_called.borrow_mut() = true;
|
||||
// Take the cache out of the RefCell
|
||||
Ok(cache.borrow_mut().take())
|
||||
},
|
||||
&|_| {
|
||||
*saver_count.borrow_mut() += 1;
|
||||
Ok(())
|
||||
},
|
||||
&|_| {
|
||||
*scanner_called.borrow_mut() = true;
|
||||
panic!("should not do full scan when cache is valid")
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(loader_called.into_inner());
|
||||
// Note: When the directory in dir_mtimes doesn't exist, validate_and_update
|
||||
// treats it as "changed" and removes projects under that path.
|
||||
// This test verifies the flow completes - the specific behavior of
|
||||
// validate_and_update is tested separately in cache.rs
|
||||
let result_projects = result.unwrap();
|
||||
// Projects were removed because the tracked directory doesn't exist
|
||||
assert!(result_projects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_update_incrementally_when_cache_changed() {
|
||||
let config = create_test_config(true);
|
||||
let initial_projects = vec![PathBuf::from("/nonexistent/project1")];
|
||||
// Use a path that doesn't exist - validate_and_update will treat missing
|
||||
// directory as a change (unwrap_or(true) in the mtime check)
|
||||
let mut dir_mtimes = HashMap::new();
|
||||
dir_mtimes.insert(PathBuf::from("/definitely_nonexistent_path_abc"), 0u64);
|
||||
|
||||
// Use RefCell<Option<>> to allow moving into closure multiple times
|
||||
let cache = RefCell::new(Some(ProjectCache::new(initial_projects, dir_mtimes)));
|
||||
|
||||
let loader_called = RefCell::new(false);
|
||||
let saver_called = RefCell::new(false);
|
||||
|
||||
let result = get_projects_internal(
|
||||
&config,
|
||||
false,
|
||||
&|| {
|
||||
*loader_called.borrow_mut() = true;
|
||||
// Take the cache out of the RefCell
|
||||
Ok(cache.borrow_mut().take())
|
||||
},
|
||||
&|_| {
|
||||
*saver_called.borrow_mut() = true;
|
||||
Ok(())
|
||||
},
|
||||
&|_| panic!("full scan should not happen with incremental update"),
|
||||
);
|
||||
|
||||
// validate_and_update is called internally. Since the directory doesn't exist,
|
||||
// it treats it as "changed" and will try to rescan using scan_from_root.
|
||||
// We verify the flow completes without panicking.
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(loader_called.into_inner());
|
||||
// Note: The saver may or may not be called depending on whether
|
||||
// validate_and_update detects changes (missing dir = change)
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use tmuxido::config::Config;
|
||||
use tmuxido::deps::ensure_dependencies;
|
||||
use tmuxido::self_update;
|
||||
use tmuxido::update_check;
|
||||
use tmuxido::{get_projects, launch_tmux_session, show_cache_status};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "tmuxido",
|
||||
about = "Quickly find and open projects in tmux",
|
||||
version
|
||||
)]
|
||||
struct Args {
|
||||
/// Project path to open directly (skips selection)
|
||||
project_path: Option<PathBuf>,
|
||||
|
||||
/// Force refresh the project cache
|
||||
#[arg(short, long)]
|
||||
refresh: bool,
|
||||
|
||||
/// Show cache status and exit
|
||||
#[arg(long)]
|
||||
cache_status: bool,
|
||||
|
||||
/// Update tmuxido to the latest version
|
||||
#[arg(long)]
|
||||
update: bool,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
// Handle self-update before anything else
|
||||
if args.update {
|
||||
return self_update::self_update();
|
||||
}
|
||||
|
||||
// Check that fzf and tmux are installed; offer to install if missing
|
||||
ensure_dependencies()?;
|
||||
|
||||
// Ensure config exists
|
||||
Config::ensure_config_exists()?;
|
||||
|
||||
// Load config
|
||||
let config = Config::load()?;
|
||||
|
||||
// Periodic update check (silent on failure or no update)
|
||||
update_check::check_and_notify(&config);
|
||||
|
||||
// Handle cache status command
|
||||
if args.cache_status {
|
||||
show_cache_status(&config)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let selected = if let Some(path) = args.project_path {
|
||||
path
|
||||
} else {
|
||||
// Get projects (from cache or scan)
|
||||
let projects = get_projects(&config, args.refresh)?;
|
||||
|
||||
if projects.is_empty() {
|
||||
eprintln!("No projects found in configured paths");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Use fzf to select a project
|
||||
select_project_with_fzf(&projects)?
|
||||
};
|
||||
|
||||
if !selected.exists() {
|
||||
eprintln!("Selected path does not exist: {}", selected.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Launch tmux session
|
||||
launch_tmux_session(&selected, &config)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> {
|
||||
let mut child = Command::new("fzf")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.context("Failed to spawn fzf. Make sure fzf is installed.")?;
|
||||
|
||||
{
|
||||
let stdin = child.stdin.as_mut().context("Failed to open stdin")?;
|
||||
for project in projects {
|
||||
writeln!(stdin, "{}", project.display())?;
|
||||
}
|
||||
}
|
||||
|
||||
let output = child.wait_with_output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
let selected = String::from_utf8(output.stdout)?.trim().to_string();
|
||||
|
||||
if selected.is_empty() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
Ok(PathBuf::from(selected))
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use anyhow::{Context, Result};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
const REPO: &str = "cinco/Tmuxido";
|
||||
const BASE_URL: &str = "https://git.cincoeuzebio.com";
|
||||
|
||||
/// Check if running from cargo (development mode)
|
||||
fn is_dev_build() -> bool {
|
||||
option_env!("CARGO_PKG_NAME").is_none()
|
||||
}
|
||||
|
||||
/// Get current version from cargo
|
||||
pub fn current_version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
/// Detect system architecture
|
||||
fn detect_arch() -> Result<&'static str> {
|
||||
let arch = std::env::consts::ARCH;
|
||||
match arch {
|
||||
"x86_64" => Ok("tmuxido-x86_64-linux"),
|
||||
"aarch64" => Ok("tmuxido-aarch64-linux"),
|
||||
_ => Err(anyhow::anyhow!("Unsupported architecture: {}", arch)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch latest release tag from Gitea API
|
||||
pub(crate) fn fetch_latest_tag() -> Result<String> {
|
||||
let url = format!("{}/api/v1/repos/{}/releases?limit=1&page=1", BASE_URL, REPO);
|
||||
|
||||
let output = Command::new("curl")
|
||||
.args(["-fsSL", &url])
|
||||
.output()
|
||||
.context("Failed to execute curl. Make sure curl is installed.")?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to fetch latest release: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let response = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Parse JSON response to extract tag_name
|
||||
let tag: serde_json::Value =
|
||||
serde_json::from_str(&response).context("Failed to parse release API response")?;
|
||||
|
||||
tag.get(0)
|
||||
.and_then(|r| r.get("tag_name"))
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|t| t.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not extract tag_name from release"))
|
||||
}
|
||||
|
||||
/// Get path to current executable
|
||||
fn get_current_exe() -> Result<PathBuf> {
|
||||
std::env::current_exe().context("Failed to get current executable path")
|
||||
}
|
||||
|
||||
/// Download binary to a temporary location
|
||||
fn download_binary(tag: &str, arch: &str, temp_path: &std::path::Path) -> Result<()> {
|
||||
let url = format!("{}/{}/releases/download/{}/{}", BASE_URL, REPO, tag, arch);
|
||||
|
||||
println!("Downloading {}...", url);
|
||||
|
||||
let output = Command::new("curl")
|
||||
.args(["-fsSL", &url, "-o", &temp_path.to_string_lossy()])
|
||||
.output()
|
||||
.context("Failed to execute curl for download")?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to download binary: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
// Make executable
|
||||
let mut perms = std::fs::metadata(temp_path)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(temp_path, perms)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Perform self-update
|
||||
pub fn self_update() -> Result<()> {
|
||||
if is_dev_build() {
|
||||
println!("Development build detected. Skipping self-update.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current = current_version();
|
||||
println!("Current version: {}", current);
|
||||
|
||||
let latest = fetch_latest_tag()?;
|
||||
let latest_clean = latest.trim_start_matches('v');
|
||||
println!("Latest version: {}", latest);
|
||||
|
||||
// Compare versions (simple string comparison for semver without 'v' prefix)
|
||||
if latest_clean == current {
|
||||
println!("Already up to date!");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if latest is actually newer
|
||||
match version_compare(latest_clean, current) {
|
||||
std::cmp::Ordering::Less => {
|
||||
println!("Current version is newer than release. Skipping update.");
|
||||
return Ok(());
|
||||
}
|
||||
std::cmp::Ordering::Equal => {
|
||||
println!("Already up to date!");
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let arch = detect_arch()?;
|
||||
let exe_path = get_current_exe()?;
|
||||
|
||||
// Create temporary file in same directory as target (for atomic rename)
|
||||
let exe_dir = exe_path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not determine executable directory"))?;
|
||||
let temp_path = exe_dir.join(".tmuxido.new");
|
||||
|
||||
println!("Downloading update...");
|
||||
download_binary(&latest, arch, &temp_path)?;
|
||||
|
||||
// Verify the downloaded binary works
|
||||
let verify = Command::new(&temp_path).arg("--version").output();
|
||||
if let Err(e) = verify {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Downloaded binary verification failed: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
|
||||
// Atomic replace: rename old to .old, rename new to target
|
||||
let backup_path = exe_path.with_extension("old");
|
||||
|
||||
// Remove old backup if exists
|
||||
let _ = std::fs::remove_file(&backup_path);
|
||||
|
||||
// Rename current to backup
|
||||
std::fs::rename(&exe_path, &backup_path)
|
||||
.context("Failed to backup current binary (is tmuxido running?)")?;
|
||||
|
||||
// Move new to current location
|
||||
if let Err(e) = std::fs::rename(&temp_path, &exe_path) {
|
||||
// Restore backup on failure
|
||||
let _ = std::fs::rename(&backup_path, &exe_path);
|
||||
return Err(anyhow::anyhow!("Failed to install new binary: {}", e));
|
||||
}
|
||||
|
||||
// Remove backup on success
|
||||
let _ = std::fs::remove_file(&backup_path);
|
||||
|
||||
println!("Successfully updated to {}!", latest);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compare two semver versions
|
||||
pub(crate) fn version_compare(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
let parse = |s: &str| {
|
||||
s.split('.')
|
||||
.filter_map(|n| n.parse::<u32>().ok())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let a_parts = parse(a);
|
||||
let b_parts = parse(b);
|
||||
|
||||
for (a_part, b_part) in a_parts.iter().zip(b_parts.iter()) {
|
||||
match a_part.cmp(b_part) {
|
||||
std::cmp::Ordering::Equal => continue,
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
|
||||
a_parts.len().cmp(&b_parts.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_detect_current_version() {
|
||||
let version = current_version();
|
||||
// Version should be non-empty and contain dots
|
||||
assert!(!version.is_empty());
|
||||
assert!(version.contains('.'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_prefix_arch_asset_with_tmuxido() {
|
||||
let arch = detect_arch().expect("should detect supported arch");
|
||||
assert!(
|
||||
arch.starts_with("tmuxido-"),
|
||||
"asset name must start with 'tmuxido-', got: {arch}"
|
||||
);
|
||||
assert!(
|
||||
arch.ends_with("-linux"),
|
||||
"asset name must end with '-linux', got: {arch}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_compare_versions_correctly() {
|
||||
assert_eq!(
|
||||
version_compare("0.3.0", "0.2.4"),
|
||||
std::cmp::Ordering::Greater
|
||||
);
|
||||
assert_eq!(version_compare("0.2.4", "0.3.0"), std::cmp::Ordering::Less);
|
||||
assert_eq!(version_compare("0.3.0", "0.3.0"), std::cmp::Ordering::Equal);
|
||||
assert_eq!(
|
||||
version_compare("1.0.0", "0.9.9"),
|
||||
std::cmp::Ordering::Greater
|
||||
);
|
||||
assert_eq!(
|
||||
version_compare("0.10.0", "0.9.0"),
|
||||
std::cmp::Ordering::Greater
|
||||
);
|
||||
}
|
||||
}
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Window {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub panes: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub layout: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct SessionConfig {
|
||||
#[serde(default)]
|
||||
pub windows: Vec<Window>,
|
||||
}
|
||||
|
||||
impl SessionConfig {
|
||||
pub fn load_from_project(project_path: &Path) -> Result<Option<Self>> {
|
||||
let config_path = project_path.join(".tmuxido.toml");
|
||||
|
||||
if !config_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&config_path)
|
||||
.with_context(|| format!("Failed to read session config: {}", config_path.display()))?;
|
||||
|
||||
let config: SessionConfig = toml::from_str(&content).with_context(|| {
|
||||
format!("Failed to parse session config: {}", config_path.display())
|
||||
})?;
|
||||
|
||||
Ok(Some(config))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TmuxSession {
|
||||
pub(crate) session_name: String,
|
||||
project_path: String,
|
||||
}
|
||||
|
||||
impl TmuxSession {
|
||||
pub fn new(project_path: &Path) -> Self {
|
||||
let session_name = project_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("project")
|
||||
.replace('.', "_")
|
||||
.replace(' ', "-");
|
||||
|
||||
Self {
|
||||
session_name,
|
||||
project_path: project_path.display().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create(&self, config: &SessionConfig) -> Result<()> {
|
||||
// Check if we're already inside a tmux session
|
||||
let inside_tmux = std::env::var("TMUX").is_ok();
|
||||
|
||||
// Check if session already exists
|
||||
let session_exists = Command::new("tmux")
|
||||
.args(["has-session", "-t", &self.session_name])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if session_exists {
|
||||
// Session exists, just switch to it
|
||||
if inside_tmux {
|
||||
Command::new("tmux")
|
||||
.args(["switch-client", "-t", &self.session_name])
|
||||
.status()
|
||||
.context("Failed to switch to existing session")?;
|
||||
} else {
|
||||
Command::new("tmux")
|
||||
.args(["attach-session", "-t", &self.session_name])
|
||||
.status()
|
||||
.context("Failed to attach to existing session")?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Create new session
|
||||
if config.windows.is_empty() {
|
||||
// Create simple session with one window
|
||||
self.create_simple_session()?;
|
||||
} else {
|
||||
// Create session with custom windows
|
||||
self.create_custom_session(config)?;
|
||||
}
|
||||
|
||||
// Attach or switch to the session
|
||||
if inside_tmux {
|
||||
Command::new("tmux")
|
||||
.args(["switch-client", "-t", &self.session_name])
|
||||
.status()
|
||||
.context("Failed to switch to new session")?;
|
||||
} else {
|
||||
Command::new("tmux")
|
||||
.args(["attach-session", "-t", &self.session_name])
|
||||
.status()
|
||||
.context("Failed to attach to new session")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_simple_session(&self) -> Result<()> {
|
||||
// Create a detached session with one window
|
||||
Command::new("tmux")
|
||||
.args([
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
&self.session_name,
|
||||
"-c",
|
||||
&self.project_path,
|
||||
])
|
||||
.status()
|
||||
.context("Failed to create tmux session")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_custom_session(&self, config: &SessionConfig) -> Result<()> {
|
||||
// Create session with first window
|
||||
let first_window = &config.windows[0];
|
||||
Command::new("tmux")
|
||||
.args([
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
&self.session_name,
|
||||
"-n",
|
||||
&first_window.name,
|
||||
"-c",
|
||||
&self.project_path,
|
||||
])
|
||||
.status()
|
||||
.context("Failed to create tmux session")?;
|
||||
|
||||
let first_target = format!("{}:{}", self.session_name, first_window.name);
|
||||
|
||||
if !first_window.panes.is_empty() {
|
||||
self.create_panes(&first_target, &first_window.panes)?;
|
||||
}
|
||||
|
||||
if let Some(layout) = &first_window.layout {
|
||||
self.apply_layout(&first_target, layout)?;
|
||||
}
|
||||
|
||||
// Create additional windows, targeting by session name so tmux auto-assigns the index
|
||||
for window in config.windows.iter().skip(1) {
|
||||
Command::new("tmux")
|
||||
.args([
|
||||
"new-window",
|
||||
"-t",
|
||||
&self.session_name,
|
||||
"-n",
|
||||
&window.name,
|
||||
"-c",
|
||||
&self.project_path,
|
||||
])
|
||||
.status()
|
||||
.with_context(|| format!("Failed to create window: {}", window.name))?;
|
||||
|
||||
let target = format!("{}:{}", self.session_name, window.name);
|
||||
|
||||
if !window.panes.is_empty() {
|
||||
self.create_panes(&target, &window.panes)?;
|
||||
}
|
||||
|
||||
if let Some(layout) = &window.layout {
|
||||
self.apply_layout(&target, layout)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Select the first window by name
|
||||
Command::new("tmux")
|
||||
.args(["select-window", "-t", &first_target])
|
||||
.status()
|
||||
.context("Failed to select first window")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_panes(&self, window_target: &str, panes: &[String]) -> Result<()> {
|
||||
for (pane_index, command) in panes.iter().enumerate() {
|
||||
// First pane already exists (created with the window), skip split
|
||||
if pane_index > 0 {
|
||||
Command::new("tmux")
|
||||
.args([
|
||||
"split-window",
|
||||
"-t",
|
||||
window_target,
|
||||
"-c",
|
||||
&self.project_path,
|
||||
])
|
||||
.status()
|
||||
.context("Failed to split pane")?;
|
||||
}
|
||||
|
||||
if !command.is_empty() {
|
||||
let pane_target = format!("{}.{}", window_target, pane_index);
|
||||
Command::new("tmux")
|
||||
.args(["send-keys", "-t", &pane_target, command, "Enter"])
|
||||
.status()
|
||||
.context("Failed to send keys to pane")?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_layout(&self, window_target: &str, layout: &str) -> Result<()> {
|
||||
Command::new("tmux")
|
||||
.args(["select-layout", "-t", window_target, layout])
|
||||
.status()
|
||||
.with_context(|| format!("Failed to apply layout: {}", layout))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn should_replace_dots_with_underscores_in_session_name() {
|
||||
let session = TmuxSession::new(Path::new("/home/user/my.project"));
|
||||
assert_eq!(session.session_name, "my_project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_replace_spaces_with_dashes_in_session_name() {
|
||||
let session = TmuxSession::new(Path::new("/home/user/my project"));
|
||||
assert_eq!(session.session_name, "my-project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_project_fallback_when_path_has_no_filename() {
|
||||
let session = TmuxSession::new(Path::new("/"));
|
||||
assert_eq!(session.session_name, "project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_window_from_toml() {
|
||||
let toml_str = r#"
|
||||
[[windows]]
|
||||
name = "editor"
|
||||
panes = ["nvim ."]
|
||||
"#;
|
||||
let config: SessionConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.windows[0].name, "editor");
|
||||
assert_eq!(config.windows[0].panes, vec!["nvim ."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_session_config_with_layout() {
|
||||
let toml_str = r#"
|
||||
[[windows]]
|
||||
name = "main"
|
||||
layout = "tiled"
|
||||
panes = ["vim", "bash"]
|
||||
"#;
|
||||
let config: SessionConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.windows[0].layout, Some("tiled".to_string()));
|
||||
assert_eq!(config.windows[0].panes.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
use crate::session::Window;
|
||||
use anyhow::{Context, Result};
|
||||
use lipgloss::{Color, Style};
|
||||
use std::io::{self, Write};
|
||||
|
||||
// Tokyo Night theme colors (as RGB tuples)
|
||||
fn color_blue() -> Color {
|
||||
Color::from_rgb(122, 162, 247)
|
||||
} // #7AA2F7
|
||||
fn color_purple() -> Color {
|
||||
Color::from_rgb(187, 154, 247)
|
||||
} // #BB9AF7
|
||||
fn color_light_gray() -> Color {
|
||||
Color::from_rgb(169, 177, 214)
|
||||
} // #A9B1D6
|
||||
fn color_dark_gray() -> Color {
|
||||
Color::from_rgb(86, 95, 137)
|
||||
} // #565F89
|
||||
fn color_green() -> Color {
|
||||
Color::from_rgb(158, 206, 106)
|
||||
} // #9ECE6A
|
||||
fn color_orange() -> Color {
|
||||
Color::from_rgb(224, 175, 104)
|
||||
} // #E0AF68
|
||||
|
||||
/// Renders a styled welcome screen for first-time setup
|
||||
pub fn render_welcome_banner() {
|
||||
let title_style = Style::new().bold(true).foreground(color_blue());
|
||||
|
||||
let subtitle_style = Style::new().foreground(color_purple());
|
||||
|
||||
let text_style = Style::new().foreground(color_light_gray());
|
||||
|
||||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||
|
||||
println!();
|
||||
println!("{}", title_style.render(" 🚀 Welcome to tmuxido!"));
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
subtitle_style.render(" 📁 Let's set up your project directories")
|
||||
);
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
text_style.render(" Please specify where tmuxido should look for your projects.")
|
||||
);
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
text_style.render(" You can add multiple paths separated by commas:")
|
||||
);
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" 💡 Example: ~/Projects, ~/work, ~/personal/repos")
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Renders a prompt asking for paths
|
||||
pub fn render_paths_prompt() -> Result<String> {
|
||||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||
|
||||
print!(" {} ", prompt_style.render("❯ Paths:"));
|
||||
io::stdout().flush().context("Failed to flush stdout")?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read input")?;
|
||||
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
/// Renders a success message after config is created with all settings
|
||||
pub fn render_config_created(
|
||||
paths: &[String],
|
||||
max_depth: usize,
|
||||
cache_enabled: bool,
|
||||
cache_ttl_hours: u64,
|
||||
windows: &[Window],
|
||||
) {
|
||||
let success_style = Style::new().bold(true).foreground(color_green());
|
||||
let label_style = Style::new().foreground(color_light_gray());
|
||||
let value_style = Style::new().bold(true).foreground(color_blue());
|
||||
let path_style = Style::new().foreground(color_blue());
|
||||
let window_style = Style::new().foreground(color_purple());
|
||||
let info_style = Style::new().foreground(color_dark_gray());
|
||||
let bool_enabled_style = Style::new().bold(true).foreground(color_green());
|
||||
let bool_disabled_style = Style::new().bold(true).foreground(color_orange());
|
||||
|
||||
println!();
|
||||
println!("{}", success_style.render(" ✅ Configuration saved!"));
|
||||
println!();
|
||||
|
||||
// Project discovery section
|
||||
println!("{}", label_style.render(" 📁 Project Discovery:"));
|
||||
println!(
|
||||
" {} {} {}",
|
||||
label_style.render("Max scan depth:"),
|
||||
value_style.render(&max_depth.to_string()),
|
||||
label_style.render("levels")
|
||||
);
|
||||
println!();
|
||||
|
||||
// Paths
|
||||
println!("{}", label_style.render(" 📂 Directories:"));
|
||||
for path in paths {
|
||||
println!(" {}", path_style.render(&format!("• {}", path)));
|
||||
}
|
||||
println!();
|
||||
|
||||
// Cache settings
|
||||
println!("{}", label_style.render(" 💾 Cache Settings:"));
|
||||
let cache_status = if cache_enabled {
|
||||
bool_enabled_style.render("enabled")
|
||||
} else {
|
||||
bool_disabled_style.render("disabled")
|
||||
};
|
||||
println!(" {} {}", label_style.render("Status:"), cache_status);
|
||||
if cache_enabled {
|
||||
println!(
|
||||
" {} {} {}",
|
||||
label_style.render("TTL:"),
|
||||
value_style.render(&cache_ttl_hours.to_string()),
|
||||
label_style.render("hours")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
// Default session
|
||||
println!("{}", label_style.render(" 🪟 Default Windows:"));
|
||||
for window in windows {
|
||||
println!(" {}", window_style.render(&format!("◦ {}", window.name)));
|
||||
if !window.panes.is_empty() {
|
||||
for (i, pane) in window.panes.iter().enumerate() {
|
||||
let pane_display = if pane.is_empty() {
|
||||
format!(" └─ pane {} (shell)", i + 1)
|
||||
} else {
|
||||
format!(" └─ pane {}: {}", i + 1, pane)
|
||||
};
|
||||
println!("{}", info_style.render(&pane_display));
|
||||
}
|
||||
}
|
||||
}
|
||||
println!();
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
info_style.render(
|
||||
" ⚙️ You can edit ~/.config/tmuxido/tmuxido.toml anytime to change these settings."
|
||||
)
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
/// Renders a warning when user provides no input (fallback to default)
|
||||
pub fn render_fallback_message() {
|
||||
let warning_style = Style::new().italic(true).foreground(color_orange());
|
||||
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
warning_style.render(" ⚠️ No paths provided. Using default: ~/Projects")
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders a section header for grouping related settings
|
||||
pub fn render_section_header(title: &str) {
|
||||
let header_style = Style::new().bold(true).foreground(color_purple());
|
||||
|
||||
println!();
|
||||
println!("{}", header_style.render(&format!(" 📋 {}", title)));
|
||||
}
|
||||
|
||||
/// Renders a prompt for max_depth with instructions
|
||||
pub fn render_max_depth_prompt() -> Result<String> {
|
||||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" How many levels deep should tmuxido search for git repositories?")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" Higher values = deeper search, but slower. Default: 5")
|
||||
);
|
||||
print!(" {} ", prompt_style.render("❯ Max depth:"));
|
||||
io::stdout().flush().context("Failed to flush stdout")?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read input")?;
|
||||
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
/// Renders a prompt for cache_enabled with instructions
|
||||
pub fn render_cache_enabled_prompt() -> Result<String> {
|
||||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" Enable caching to speed up project discovery?")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" Cache avoids rescanning unchanged directories. Default: yes (y)")
|
||||
);
|
||||
print!(" {} ", prompt_style.render("❯ Enable cache? (y/n):"));
|
||||
io::stdout().flush().context("Failed to flush stdout")?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read input")?;
|
||||
|
||||
Ok(input.trim().to_lowercase())
|
||||
}
|
||||
|
||||
/// Renders a prompt for cache_ttl_hours with instructions
|
||||
pub fn render_cache_ttl_prompt() -> Result<String> {
|
||||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" How long should the cache remain valid (in hours)?")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" After this time, tmuxido will rescan your directories. Default: 24")
|
||||
);
|
||||
print!(" {} ", prompt_style.render("❯ Cache TTL (hours):"));
|
||||
io::stdout().flush().context("Failed to flush stdout")?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read input")?;
|
||||
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
/// Renders a prompt for default session windows with instructions
|
||||
pub fn render_windows_prompt() -> Result<String> {
|
||||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" What windows should be created by default in new tmux sessions?")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" Enter window names separated by commas. Default: editor, terminal")
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" 💡 Tip: Common choices are 'editor', 'terminal', 'server', 'logs'")
|
||||
);
|
||||
print!(" {} ", prompt_style.render("❯ Window names:"));
|
||||
io::stdout().flush().context("Failed to flush stdout")?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read input")?;
|
||||
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
/// Renders a prompt asking for panes in a specific window
|
||||
pub fn render_panes_prompt(window_name: &str) -> Result<String> {
|
||||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||
let window_style = Style::new().bold(true).foreground(color_purple());
|
||||
|
||||
println!();
|
||||
println!(" Configuring window: {}", window_style.render(window_name));
|
||||
println!(
|
||||
"{}",
|
||||
hint_style
|
||||
.render(" Enter pane names separated by commas, or leave empty for a single pane.")
|
||||
);
|
||||
println!("{}", hint_style.render(" 💡 Example: code, logs, tests"));
|
||||
print!(" {} ", prompt_style.render("❯ Pane names:"));
|
||||
io::stdout().flush().context("Failed to flush stdout")?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read input")?;
|
||||
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
/// Renders a prompt for a pane command
|
||||
pub fn render_pane_command_prompt(pane_name: &str) -> Result<String> {
|
||||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||
let pane_style = Style::new().foreground(color_blue());
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(&format!(
|
||||
" What command should run in pane '{}' on startup?",
|
||||
pane_style.render(pane_name)
|
||||
))
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
hint_style.render(" Leave empty to run the default shell, or enter a command like 'nvim', 'npm run dev'")
|
||||
);
|
||||
print!(" {} ", prompt_style.render("❯ Command:"));
|
||||
io::stdout().flush().context("Failed to flush stdout")?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("Failed to read input")?;
|
||||
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
/// Parse max_depth input, returning None for empty/invalid (use default)
|
||||
pub fn parse_max_depth_input(input: &str) -> Option<usize> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
trimmed.parse::<usize>().ok().filter(|&n| n > 0)
|
||||
}
|
||||
|
||||
/// Parse cache enabled input, returning None for empty (use default)
|
||||
pub fn parse_cache_enabled_input(input: &str) -> Option<bool> {
|
||||
let trimmed = input.trim().to_lowercase();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match trimmed.as_str() {
|
||||
"y" | "yes" => Some(true),
|
||||
"n" | "no" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse cache TTL input, returning None for empty/invalid (use default)
|
||||
pub fn parse_cache_ttl_input(input: &str) -> Option<u64> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
trimmed.parse::<u64>().ok().filter(|&n| n > 0)
|
||||
}
|
||||
|
||||
/// Parse comma-separated list into Vec<String>, filtering empty items
|
||||
pub fn parse_comma_separated_list(input: &str) -> Vec<String> {
|
||||
input
|
||||
.trim()
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_return_none_for_empty_max_depth() {
|
||||
assert_eq!(parse_max_depth_input(""), None);
|
||||
assert_eq!(parse_max_depth_input(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_valid_max_depth() {
|
||||
assert_eq!(parse_max_depth_input("5"), Some(5));
|
||||
assert_eq!(parse_max_depth_input("10"), Some(10));
|
||||
assert_eq!(parse_max_depth_input(" 3 "), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_none_for_invalid_max_depth() {
|
||||
assert_eq!(parse_max_depth_input("0"), None);
|
||||
assert_eq!(parse_max_depth_input("-1"), None);
|
||||
assert_eq!(parse_max_depth_input("abc"), None);
|
||||
assert_eq!(parse_max_depth_input("3.5"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_none_for_empty_cache_enabled() {
|
||||
assert_eq!(parse_cache_enabled_input(""), None);
|
||||
assert_eq!(parse_cache_enabled_input(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_yes_as_true() {
|
||||
assert_eq!(parse_cache_enabled_input("y"), Some(true));
|
||||
assert_eq!(parse_cache_enabled_input("Y"), Some(true));
|
||||
assert_eq!(parse_cache_enabled_input("yes"), Some(true));
|
||||
assert_eq!(parse_cache_enabled_input("YES"), Some(true));
|
||||
assert_eq!(parse_cache_enabled_input("Yes"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_no_as_false() {
|
||||
assert_eq!(parse_cache_enabled_input("n"), Some(false));
|
||||
assert_eq!(parse_cache_enabled_input("N"), Some(false));
|
||||
assert_eq!(parse_cache_enabled_input("no"), Some(false));
|
||||
assert_eq!(parse_cache_enabled_input("NO"), Some(false));
|
||||
assert_eq!(parse_cache_enabled_input("No"), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_none_for_invalid_cache_input() {
|
||||
assert_eq!(parse_cache_enabled_input("maybe"), None);
|
||||
assert_eq!(parse_cache_enabled_input("true"), None);
|
||||
assert_eq!(parse_cache_enabled_input("1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_none_for_empty_cache_ttl() {
|
||||
assert_eq!(parse_cache_ttl_input(""), None);
|
||||
assert_eq!(parse_cache_ttl_input(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_valid_cache_ttl() {
|
||||
assert_eq!(parse_cache_ttl_input("24"), Some(24));
|
||||
assert_eq!(parse_cache_ttl_input("12"), Some(12));
|
||||
assert_eq!(parse_cache_ttl_input(" 48 "), Some(48));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_none_for_invalid_cache_ttl() {
|
||||
assert_eq!(parse_cache_ttl_input("0"), None);
|
||||
assert_eq!(parse_cache_ttl_input("-1"), None);
|
||||
assert_eq!(parse_cache_ttl_input("abc"), None);
|
||||
assert_eq!(parse_cache_ttl_input("12.5"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_empty_comma_list() {
|
||||
let result = parse_comma_separated_list("");
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_single_item() {
|
||||
let result = parse_comma_separated_list("editor");
|
||||
assert_eq!(result, vec!["editor"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_multiple_items() {
|
||||
let result = parse_comma_separated_list("editor, terminal, server");
|
||||
assert_eq!(result, vec!["editor", "terminal", "server"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_trim_whitespace_in_comma_list() {
|
||||
let result = parse_comma_separated_list(" editor , terminal ");
|
||||
assert_eq!(result, vec!["editor", "terminal"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_filter_empty_parts_in_comma_list() {
|
||||
let result = parse_comma_separated_list("editor,,terminal");
|
||||
assert_eq!(result, vec!["editor", "terminal"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_blue_should_return_expected_rgb() {
|
||||
let color = color_blue();
|
||||
// We can't easily test the internal RGB values, but we can verify it doesn't panic
|
||||
let _ = color;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_functions_should_return_distinct_colors() {
|
||||
// Verify all color functions return valid Color objects
|
||||
let colors = vec![
|
||||
color_blue(),
|
||||
color_purple(),
|
||||
color_light_gray(),
|
||||
color_dark_gray(),
|
||||
color_green(),
|
||||
color_orange(),
|
||||
];
|
||||
// Just verify they don't panic and are distinct
|
||||
assert_eq!(colors.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_section_header_should_not_panic() {
|
||||
// This test verifies the function doesn't panic
|
||||
// We can't capture stdout easily in unit tests without additional setup
|
||||
render_section_header("Test Section");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_welcome_banner_should_not_panic() {
|
||||
render_welcome_banner();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_fallback_message_should_not_panic() {
|
||||
render_fallback_message();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_config_created_should_not_panic() {
|
||||
let windows = vec![
|
||||
Window {
|
||||
name: "editor".to_string(),
|
||||
panes: vec!["nvim .".to_string()],
|
||||
layout: None,
|
||||
},
|
||||
Window {
|
||||
name: "terminal".to_string(),
|
||||
panes: vec![],
|
||||
layout: None,
|
||||
},
|
||||
];
|
||||
render_config_created(&vec!["~/Projects".to_string()], 5, true, 24, &windows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_config_created_with_disabled_cache_should_not_panic() {
|
||||
let windows = vec![Window {
|
||||
name: "editor".to_string(),
|
||||
panes: vec![],
|
||||
layout: None,
|
||||
}];
|
||||
render_config_created(&vec!["~/work".to_string()], 3, false, 24, &windows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::self_update;
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct UpdateCheckCache {
|
||||
last_checked: u64,
|
||||
latest_version: String,
|
||||
}
|
||||
|
||||
pub fn check_and_notify(config: &Config) {
|
||||
let cache = load_cache();
|
||||
check_and_notify_internal(
|
||||
config.update_check_interval_hours,
|
||||
cache,
|
||||
&|| self_update::fetch_latest_tag(),
|
||||
&save_cache,
|
||||
);
|
||||
}
|
||||
|
||||
fn check_and_notify_internal(
|
||||
interval_hours: u64,
|
||||
mut cache: UpdateCheckCache,
|
||||
fetcher: &dyn Fn() -> Result<String>,
|
||||
saver: &dyn Fn(&UpdateCheckCache),
|
||||
) -> bool {
|
||||
if interval_hours == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let elapsed = elapsed_hours(cache.last_checked);
|
||||
|
||||
if elapsed >= interval_hours
|
||||
&& let Ok(latest) = fetcher()
|
||||
{
|
||||
let latest_clean = latest.trim_start_matches('v').to_string();
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
cache.last_checked = now;
|
||||
cache.latest_version = latest_clean;
|
||||
saver(&cache);
|
||||
}
|
||||
|
||||
let current = self_update::current_version();
|
||||
let latest_clean = cache.latest_version.trim_start_matches('v');
|
||||
if !latest_clean.is_empty()
|
||||
&& self_update::version_compare(latest_clean, current) == std::cmp::Ordering::Greater
|
||||
{
|
||||
print_update_notice(current, latest_clean);
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn print_update_notice(current: &str, latest: &str) {
|
||||
let msg1 = format!(" Update available: {} \u{2192} {} ", current, latest);
|
||||
let msg2 = " Run tmuxido --update to install. ";
|
||||
let w1 = msg1.chars().count();
|
||||
let w2 = msg2.chars().count();
|
||||
let width = w1.max(w2);
|
||||
let border = "\u{2500}".repeat(width);
|
||||
println!("\u{250c}{}\u{2510}", border);
|
||||
println!("\u{2502}{}\u{2502}", pad_to_chars(&msg1, width));
|
||||
println!("\u{2502}{}\u{2502}", pad_to_chars(msg2, width));
|
||||
println!("\u{2514}{}\u{2518}", border);
|
||||
}
|
||||
|
||||
fn pad_to_chars(s: &str, width: usize) -> String {
|
||||
let char_count = s.chars().count();
|
||||
if char_count >= width {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}{}", s, " ".repeat(width - char_count))
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_path() -> Result<PathBuf> {
|
||||
let cache_dir = dirs::cache_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not determine cache directory"))?
|
||||
.join("tmuxido");
|
||||
Ok(cache_dir.join("update_check.json"))
|
||||
}
|
||||
|
||||
fn load_cache() -> UpdateCheckCache {
|
||||
cache_path()
|
||||
.ok()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_cache(cache: &UpdateCheckCache) {
|
||||
if let Ok(path) = cache_path() {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string(cache) {
|
||||
let _ = std::fs::write(path, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_hours(ts: u64) -> u64 {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
now.saturating_sub(ts) / 3600
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
fn make_cache(last_checked: u64, latest_version: &str) -> UpdateCheckCache {
|
||||
UpdateCheckCache {
|
||||
last_checked,
|
||||
latest_version: latest_version.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ts() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_notify_when_interval_is_zero() {
|
||||
let cache = make_cache(0, "99.0.0");
|
||||
let fetcher_called = RefCell::new(false);
|
||||
|
||||
let result = check_and_notify_internal(
|
||||
0,
|
||||
cache,
|
||||
&|| {
|
||||
*fetcher_called.borrow_mut() = true;
|
||||
Ok("99.0.0".to_string())
|
||||
},
|
||||
&|_| {},
|
||||
);
|
||||
|
||||
assert!(!result);
|
||||
assert!(!fetcher_called.into_inner());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_check_when_interval_not_elapsed() {
|
||||
let cache = make_cache(now_ts(), "");
|
||||
let fetcher_called = RefCell::new(false);
|
||||
|
||||
check_and_notify_internal(
|
||||
24,
|
||||
cache,
|
||||
&|| {
|
||||
*fetcher_called.borrow_mut() = true;
|
||||
Ok("99.0.0".to_string())
|
||||
},
|
||||
&|_| {},
|
||||
);
|
||||
|
||||
assert!(!fetcher_called.into_inner());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_check_when_interval_elapsed() {
|
||||
let cache = make_cache(0, "");
|
||||
let fetcher_called = RefCell::new(false);
|
||||
|
||||
check_and_notify_internal(
|
||||
1,
|
||||
cache,
|
||||
&|| {
|
||||
*fetcher_called.borrow_mut() = true;
|
||||
Ok(self_update::current_version().to_string())
|
||||
},
|
||||
&|_| {},
|
||||
);
|
||||
|
||||
assert!(fetcher_called.into_inner());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_notify_when_versions_equal() {
|
||||
let current = self_update::current_version();
|
||||
let cache = make_cache(now_ts(), current);
|
||||
|
||||
let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {});
|
||||
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_detect_update_available() {
|
||||
let cache = make_cache(now_ts(), "99.0.0");
|
||||
|
||||
let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {});
|
||||
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_detect_update_when_current_is_newer() {
|
||||
let cache = make_cache(now_ts(), "0.0.1");
|
||||
|
||||
let result = check_and_notify_internal(24, cache, &|| unreachable!(), &|_| {});
|
||||
|
||||
assert!(!result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tmuxido::cache::ProjectCache;
|
||||
|
||||
#[test]
|
||||
fn should_save_and_reload_cache() {
|
||||
let projects = vec![PathBuf::from("/tmp/test_tmuxido_project")];
|
||||
let cache = ProjectCache::new(projects.clone(), HashMap::new());
|
||||
cache.save().unwrap();
|
||||
|
||||
let loaded = ProjectCache::load().unwrap().unwrap();
|
||||
assert_eq!(loaded.projects, projects);
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
use tmuxido::deps::{
|
||||
BinaryChecker, Dep, PackageManager, SystemBinaryChecker, check_missing, detect_package_manager,
|
||||
};
|
||||
|
||||
// --- SystemBinaryChecker (real system calls) ---
|
||||
|
||||
#[test]
|
||||
fn system_checker_finds_sh_binary() {
|
||||
let checker = SystemBinaryChecker;
|
||||
assert!(
|
||||
checker.is_available("sh"),
|
||||
"`sh` must be present on any Unix system"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_checker_returns_false_for_nonexistent_binary() {
|
||||
let checker = SystemBinaryChecker;
|
||||
assert!(!checker.is_available("tmuxido_nonexistent_xyz_42"));
|
||||
}
|
||||
|
||||
// --- detect_package_manager on real system ---
|
||||
|
||||
#[test]
|
||||
fn should_detect_some_package_manager_on_linux() {
|
||||
let checker = SystemBinaryChecker;
|
||||
let pm = detect_package_manager(&checker);
|
||||
assert!(
|
||||
pm.is_some(),
|
||||
"Expected to detect at least one package manager on this Linux system"
|
||||
);
|
||||
}
|
||||
|
||||
// --- PackageManager metadata completeness ---
|
||||
|
||||
#[test]
|
||||
fn all_package_managers_have_non_empty_detection_binary() {
|
||||
for pm in PackageManager::all_ordered() {
|
||||
assert!(
|
||||
!pm.detection_binary().is_empty(),
|
||||
"{:?} has empty detection binary",
|
||||
pm
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_package_managers_have_non_empty_display_name() {
|
||||
for pm in PackageManager::all_ordered() {
|
||||
assert!(
|
||||
!pm.display_name().is_empty(),
|
||||
"{:?} has empty display name",
|
||||
pm
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_command_always_starts_with_sudo() {
|
||||
let packages = &["fzf", "tmux"];
|
||||
for pm in PackageManager::all_ordered() {
|
||||
let cmd = pm.install_command(packages);
|
||||
assert_eq!(
|
||||
cmd.first().map(String::as_str),
|
||||
Some("sudo"),
|
||||
"{} install command should start with sudo",
|
||||
pm.display_name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_command_always_contains_requested_packages() {
|
||||
let packages = &["fzf", "tmux"];
|
||||
for pm in PackageManager::all_ordered() {
|
||||
let cmd = pm.install_command(packages);
|
||||
assert!(
|
||||
cmd.contains(&"fzf".to_string()),
|
||||
"{} command missing 'fzf'",
|
||||
pm.display_name()
|
||||
);
|
||||
assert!(
|
||||
cmd.contains(&"tmux".to_string()),
|
||||
"{} command missing 'tmux'",
|
||||
pm.display_name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Dep completeness ---
|
||||
|
||||
#[test]
|
||||
fn dep_package_names_are_standard() {
|
||||
assert_eq!(Dep::Fzf.package_name(), "fzf");
|
||||
assert_eq!(Dep::Tmux.package_name(), "tmux");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_deps_have_matching_binary_and_package_names() {
|
||||
for dep in Dep::all() {
|
||||
assert!(!dep.binary_name().is_empty());
|
||||
assert!(!dep.package_name().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
// --- check_missing on real system ---
|
||||
|
||||
#[test]
|
||||
fn check_missing_returns_only_actually_missing_tools() {
|
||||
let checker = SystemBinaryChecker;
|
||||
let missing = check_missing(&checker);
|
||||
// Every item reported as missing must NOT be findable via `which`
|
||||
for dep in &missing {
|
||||
assert!(
|
||||
!checker.is_available(dep.binary_name()),
|
||||
"{} reported as missing but `which` finds it",
|
||||
dep.binary_name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_missing_does_not_report_present_tools_as_missing() {
|
||||
let checker = SystemBinaryChecker;
|
||||
let missing = check_missing(&checker);
|
||||
// Every dep NOT in missing list must be available
|
||||
let missing_names: Vec<&str> = missing.iter().map(|d| d.binary_name()).collect();
|
||||
for dep in Dep::all() {
|
||||
if !missing_names.contains(&dep.binary_name()) {
|
||||
assert!(
|
||||
checker.is_available(dep.binary_name()),
|
||||
"{} not in missing list but `which` can't find it",
|
||||
dep.binary_name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---- Stage 1: Build (Rust stable on Debian slim) ----
|
||||
FROM rust:1-slim AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Copy manifests first so cargo can resolve deps (layer cache friendly)
|
||||
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
|
||||
|
||||
# Copy source and build release binary
|
||||
COPY src/ ./src/
|
||||
RUN cargo build --release --locked
|
||||
|
||||
# ---- Stage 2: Test environment (fresh Ubuntu, no fzf/tmux) ----
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install only what's needed to run the test suite itself
|
||||
# (git + sudo so Test 7 can install fzf/tmux via apt)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
sudo \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create an unprivileged user with passwordless sudo
|
||||
# (simulates a regular developer who can install packages)
|
||||
RUN useradd -m -s /bin/bash testuser \
|
||||
&& echo "testuser ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
|
||||
|
||||
# Install the tmuxido binary built in stage 1
|
||||
COPY --from=builder /src/target/release/tmuxido /usr/local/bin/tmuxido
|
||||
|
||||
# Copy and register the test entrypoint
|
||||
COPY tests/docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
USER testuser
|
||||
WORKDIR /home/testuser
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
Executable
+185
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test suite executed inside the Ubuntu container.
|
||||
# Simulates a brand-new user running tmuxido for the first time.
|
||||
set -uo pipefail
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { echo " ✓ $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " ✗ $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
section() {
|
||||
echo ""
|
||||
echo "┌─ $1"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — fzf and tmux are NOT installed yet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ tmuxido — Container Integration Tests (Ubuntu 24.04) ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
|
||||
section "Phase 1: binary basics"
|
||||
|
||||
# T1 — binary is in PATH and executable
|
||||
if command -v tmuxido &>/dev/null; then
|
||||
pass "tmuxido found in PATH ($(command -v tmuxido))"
|
||||
else
|
||||
fail "tmuxido not found in PATH"
|
||||
fi
|
||||
|
||||
# T2 — --help exits 0
|
||||
if tmuxido --help >/dev/null 2>&1; then
|
||||
pass "--help exits with code 0"
|
||||
else
|
||||
fail "--help returned non-zero"
|
||||
fi
|
||||
|
||||
# T3 — --version shows the package name
|
||||
VERSION_OUT=$(tmuxido --version 2>&1 || true)
|
||||
if echo "$VERSION_OUT" | grep -q "tmuxido"; then
|
||||
pass "--version output contains 'tmuxido' → $VERSION_OUT"
|
||||
else
|
||||
fail "--version output unexpected: $VERSION_OUT"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — dependency detection (fzf and tmux absent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
section "Phase 2: dependency detection (fzf and tmux not installed)"
|
||||
|
||||
# Pipe "n" so tmuxido declines to install and exits
|
||||
DEP_OUT=$(echo "n" | tmuxido 2>&1 || true)
|
||||
|
||||
# T4 — fzf reported as missing
|
||||
if echo "$DEP_OUT" | grep -q "fzf"; then
|
||||
pass "fzf detected as missing"
|
||||
else
|
||||
fail "fzf NOT detected as missing. Full output:\n$DEP_OUT"
|
||||
fi
|
||||
|
||||
# T5 — tmux reported as missing
|
||||
if echo "$DEP_OUT" | grep -q "tmux"; then
|
||||
pass "tmux detected as missing"
|
||||
else
|
||||
fail "tmux NOT detected as missing. Full output:\n$DEP_OUT"
|
||||
fi
|
||||
|
||||
# T6 — "not installed" heading appears
|
||||
if echo "$DEP_OUT" | grep -q "not installed"; then
|
||||
pass "User-facing 'not installed' message shown"
|
||||
else
|
||||
fail "'not installed' message missing. Full output:\n$DEP_OUT"
|
||||
fi
|
||||
|
||||
# T7 — apt detected as package manager (Ubuntu 24.04)
|
||||
if echo "$DEP_OUT" | grep -q "apt"; then
|
||||
pass "apt detected as the package manager"
|
||||
else
|
||||
fail "apt NOT detected. Full output:\n$DEP_OUT"
|
||||
fi
|
||||
|
||||
# T8 — install command includes sudo apt install
|
||||
if echo "$DEP_OUT" | grep -q "sudo apt install"; then
|
||||
pass "Install command 'sudo apt install' shown to user"
|
||||
else
|
||||
fail "Install command incorrect. Full output:\n$DEP_OUT"
|
||||
fi
|
||||
|
||||
# T9 — cancellation message when user answers "n"
|
||||
if echo "$DEP_OUT" | grep -q "cancelled\|Cancelled\|manually"; then
|
||||
pass "Graceful cancellation message shown"
|
||||
else
|
||||
fail "Cancellation message missing. Full output:\n$DEP_OUT"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3 — install deps and run full workflow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
section "Phase 3: full workflow (after installing fzf, tmux and git)"
|
||||
|
||||
echo " Installing fzf, tmux via apt (this may take a moment)..."
|
||||
sudo apt-get update -qq 2>/dev/null
|
||||
sudo apt-get install -y --no-install-recommends fzf tmux 2>/dev/null
|
||||
|
||||
# T10 — fzf now available
|
||||
if command -v fzf &>/dev/null; then
|
||||
pass "fzf installed successfully ($(fzf --version 2>&1 | head -1))"
|
||||
else
|
||||
fail "fzf still not available after installation"
|
||||
fi
|
||||
|
||||
# T11 — tmux now available
|
||||
if command -v tmux &>/dev/null; then
|
||||
pass "tmux installed successfully ($(tmux -V))"
|
||||
else
|
||||
fail "tmux still not available after installation"
|
||||
fi
|
||||
|
||||
# T12 — tmuxido no longer triggers dependency prompt
|
||||
NO_DEP_OUT=$(echo "" | tmuxido 2>&1 || true)
|
||||
if echo "$NO_DEP_OUT" | grep -q "not installed"; then
|
||||
fail "Dependency prompt still shown after installing deps"
|
||||
else
|
||||
pass "No dependency prompt after deps are installed"
|
||||
fi
|
||||
|
||||
# T13 — set up a minimal git project tree for scanning
|
||||
mkdir -p ~/Projects/demo-app
|
||||
git -C ~/Projects/demo-app init --quiet
|
||||
git -C ~/Projects/demo-app config user.email "test@test.com"
|
||||
git -C ~/Projects/demo-app config user.name "Test"
|
||||
|
||||
mkdir -p ~/.config/tmuxido
|
||||
cat > ~/.config/tmuxido/tmuxido.toml <<'EOF'
|
||||
paths = ["~/Projects"]
|
||||
max_depth = 3
|
||||
cache_enabled = true
|
||||
EOF
|
||||
|
||||
# T13 — --refresh scans and finds our demo project
|
||||
REFRESH_OUT=$(tmuxido --refresh 2>&1 || true)
|
||||
if echo "$REFRESH_OUT" | grep -q "projects\|Projects"; then
|
||||
pass "--refresh scanned and reported projects"
|
||||
else
|
||||
fail "--refresh output unexpected: $REFRESH_OUT"
|
||||
fi
|
||||
|
||||
# T14 — --cache-status reports the cache that was just built
|
||||
CACHE_OUT=$(tmuxido --cache-status 2>&1 || true)
|
||||
if echo "$CACHE_OUT" | grep -qi "cache"; then
|
||||
pass "--cache-status reports cache info"
|
||||
else
|
||||
fail "--cache-status output unexpected: $CACHE_OUT"
|
||||
fi
|
||||
|
||||
# T15 — cache contains our demo project
|
||||
if echo "$CACHE_OUT" | grep -q "Projects cached: [^0]"; then
|
||||
pass "Cache contains at least 1 project"
|
||||
else
|
||||
# Try alternate grep in case format differs
|
||||
if echo "$CACHE_OUT" | grep -q "cached:"; then
|
||||
pass "--cache-status shows cached projects (count check skipped)"
|
||||
else
|
||||
fail "Cache appears empty. Output: $CACHE_OUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
printf "║ Results: %-3d passed, %-3d failed%*s║\n" \
|
||||
"$PASS" "$FAIL" $((24 - ${#PASS} - ${#FAIL})) ""
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the tmuxido Docker test image and run the container integration tests.
|
||||
#
|
||||
# Usage:
|
||||
# ./tests/docker/run.sh # build + run
|
||||
# ./tests/docker/run.sh --no-cache # force rebuild from scratch
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
IMAGE_NAME="tmuxido-test"
|
||||
|
||||
# Propagate --no-cache if requested
|
||||
BUILD_FLAGS=()
|
||||
if [[ "${1:-}" == "--no-cache" ]]; then
|
||||
BUILD_FLAGS+=(--no-cache)
|
||||
fi
|
||||
|
||||
echo "╔══════════════════════════════════════════════════════════╗"
|
||||
echo "║ tmuxido — Docker Integration Test Runner ║"
|
||||
echo "╚══════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "Project root : $PROJECT_ROOT"
|
||||
echo "Dockerfile : $SCRIPT_DIR/Dockerfile"
|
||||
echo "Image name : $IMAGE_NAME"
|
||||
echo ""
|
||||
|
||||
# ---- Build ----------------------------------------------------------------
|
||||
echo "Building image (stage 1: rust compile, stage 2: ubuntu test env)..."
|
||||
docker build \
|
||||
"${BUILD_FLAGS[@]}" \
|
||||
--tag "$IMAGE_NAME" \
|
||||
--file "$SCRIPT_DIR/Dockerfile" \
|
||||
"$PROJECT_ROOT"
|
||||
|
||||
echo ""
|
||||
echo "Build complete. Running tests..."
|
||||
echo ""
|
||||
|
||||
# ---- Run ------------------------------------------------------------------
|
||||
docker run \
|
||||
--rm \
|
||||
--name "${IMAGE_NAME}-run" \
|
||||
"$IMAGE_NAME"
|
||||
|
||||
EXIT=$?
|
||||
|
||||
if [ "$EXIT" -eq 0 ]; then
|
||||
echo "All tests passed."
|
||||
else
|
||||
echo "Some tests FAILED (exit $EXIT)."
|
||||
fi
|
||||
|
||||
exit "$EXIT"
|
||||
@@ -0,0 +1,66 @@
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
use tmuxido::config::Config;
|
||||
use tmuxido::scan_from_root;
|
||||
use tmuxido::session::SessionConfig;
|
||||
|
||||
fn make_config(max_depth: usize) -> Config {
|
||||
Config {
|
||||
paths: vec![],
|
||||
max_depth,
|
||||
cache_enabled: true,
|
||||
cache_ttl_hours: 24,
|
||||
update_check_interval_hours: 24,
|
||||
default_session: SessionConfig { windows: vec![] },
|
||||
}
|
||||
}
|
||||
|
||||
/// `tempfile::tempdir()` creates hidden dirs (e.g. `/tmp/.tmpXXXXXX`) on this
|
||||
/// system, which `scan_from_root`'s `filter_entry` would skip. Create a
|
||||
/// visible subdirectory to use as the actual scan root.
|
||||
fn make_scan_root() -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let dir = tempdir().unwrap();
|
||||
let root = dir.path().join("scan_root");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
(dir, root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_find_git_repos_in_temp_dir() {
|
||||
let (_dir, root) = make_scan_root();
|
||||
fs::create_dir_all(root.join("foo/.git")).unwrap();
|
||||
fs::create_dir_all(root.join("bar/.git")).unwrap();
|
||||
|
||||
let config = make_config(5);
|
||||
let (projects, _) = scan_from_root(&root, &config).unwrap();
|
||||
|
||||
assert_eq!(projects.len(), 2);
|
||||
assert!(projects.iter().any(|p| p.ends_with("foo")));
|
||||
assert!(projects.iter().any(|p| p.ends_with("bar")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_descend_into_hidden_dirs() {
|
||||
let (_dir, root) = make_scan_root();
|
||||
fs::create_dir_all(root.join(".hidden/repo/.git")).unwrap();
|
||||
|
||||
let config = make_config(5);
|
||||
let (projects, _) = scan_from_root(&root, &config).unwrap();
|
||||
|
||||
assert!(projects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_respect_max_depth() {
|
||||
let (_dir, root) = make_scan_root();
|
||||
// Shallow: project/.git at depth 2 from root — found with max_depth=2
|
||||
fs::create_dir_all(root.join("project/.git")).unwrap();
|
||||
// Deep: nested/deep/project/.git at depth 4 — excluded with max_depth=2
|
||||
fs::create_dir_all(root.join("nested/deep/project/.git")).unwrap();
|
||||
|
||||
let config = make_config(2);
|
||||
let (projects, _) = scan_from_root(&root, &config).unwrap();
|
||||
|
||||
assert_eq!(projects.len(), 1);
|
||||
assert!(projects[0].ends_with("project"));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
use tmuxido::session::SessionConfig;
|
||||
|
||||
#[test]
|
||||
fn should_load_project_session_config() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config_content = r#"
|
||||
[[windows]]
|
||||
name = "editor"
|
||||
panes = ["nvim ."]
|
||||
"#;
|
||||
fs::write(dir.path().join(".tmuxido.toml"), config_content).unwrap();
|
||||
|
||||
let result = SessionConfig::load_from_project(dir.path()).unwrap();
|
||||
assert!(result.is_some());
|
||||
let config = result.unwrap();
|
||||
assert_eq!(config.windows[0].name, "editor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_return_none_when_no_project_config() {
|
||||
let dir = tempdir().unwrap();
|
||||
let result = SessionConfig::load_from_project(dir.path()).unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
# ============================================================================
|
||||
# tmuxido - Global Configuration
|
||||
# ============================================================================
|
||||
# Location: ~/.config/tmuxido/tmuxido.toml
|
||||
#
|
||||
# This is the main configuration file that controls:
|
||||
# 1. Where to search for projects
|
||||
# 2. Caching behavior
|
||||
# 3. Default session layout (used when projects don't have .tmuxido.toml)
|
||||
#
|
||||
# Compatible with any tmux base-index setting (0 or 1, auto-detected)
|
||||
# ============================================================================
|
||||
|
||||
# ============================================================================
|
||||
# PROJECT DISCOVERY
|
||||
# ============================================================================
|
||||
# Paths where tmuxido will search for git repositories
|
||||
# Supports ~ for home directory expansion
|
||||
|
||||
paths = [
|
||||
"~/Projects",
|
||||
# "~/work",
|
||||
# "~/opensource",
|
||||
# "~/clients/company-name",
|
||||
]
|
||||
|
||||
# Maximum directory depth to search for .git folders
|
||||
# Higher values = slower scan, but finds deeply nested projects
|
||||
# Lower values = faster scan, but might miss some projects
|
||||
# Default: 5
|
||||
max_depth = 5
|
||||
|
||||
# ============================================================================
|
||||
# CACHING CONFIGURATION
|
||||
# ============================================================================
|
||||
# Caching dramatically speeds up subsequent runs by storing discovered projects
|
||||
|
||||
# Enable/disable project caching
|
||||
# Default: true
|
||||
cache_enabled = true
|
||||
|
||||
# How long (in hours) before cache is considered stale and refreshed
|
||||
# Set lower if you frequently add new projects
|
||||
# Set higher if your projects are stable
|
||||
# Default: 24
|
||||
cache_ttl_hours = 24
|
||||
|
||||
# Cache location: ~/.cache/tmuxido/projects.json
|
||||
# Use --refresh flag to force cache update
|
||||
# Use --cache-status to see cache information
|
||||
|
||||
# ============================================================================
|
||||
# DEFAULT SESSION CONFIGURATION
|
||||
# ============================================================================
|
||||
# This configuration is used for projects that don't have their own
|
||||
# .tmuxido.toml file in the project root.
|
||||
#
|
||||
# You can customize this to match your preferred workflow!
|
||||
# ============================================================================
|
||||
|
||||
[default_session]
|
||||
|
||||
# --- OPTION 1: Simple two-window setup (CURRENT DEFAULT) ---
|
||||
[[default_session.windows]]
|
||||
name = "editor"
|
||||
panes = []
|
||||
|
||||
[[default_session.windows]]
|
||||
name = "terminal"
|
||||
panes = []
|
||||
|
||||
# --- OPTION 2: Single window with nvim and terminal split ---
|
||||
# Uncomment this and comment out Option 1 above
|
||||
# [[default_session.windows]]
|
||||
# name = "work"
|
||||
# layout = "main-horizontal"
|
||||
# panes = [
|
||||
# "nvim .", # Main pane: Editor
|
||||
# "clear", # Bottom left: Terminal
|
||||
# "clear" # Bottom right: Terminal
|
||||
# ]
|
||||
|
||||
# --- OPTION 3: Three-window workflow (code, run, git) ---
|
||||
# Uncomment this and comment out Option 1 above
|
||||
# [[default_session.windows]]
|
||||
# name = "code"
|
||||
# layout = "main-vertical"
|
||||
# panes = ["nvim .", "clear"]
|
||||
#
|
||||
# [[default_session.windows]]
|
||||
# name = "run"
|
||||
# panes = []
|
||||
#
|
||||
# [[default_session.windows]]
|
||||
# name = "git"
|
||||
# panes = []
|
||||
|
||||
# --- OPTION 4: Full development setup ---
|
||||
# Uncomment this and comment out Option 1 above
|
||||
# [[default_session.windows]]
|
||||
# name = "editor"
|
||||
# layout = "main-horizontal"
|
||||
# panes = ["nvim .", "clear", "clear"]
|
||||
#
|
||||
# [[default_session.windows]]
|
||||
# name = "server"
|
||||
# panes = []
|
||||
#
|
||||
# [[default_session.windows]]
|
||||
# name = "logs"
|
||||
# panes = []
|
||||
#
|
||||
# [[default_session.windows]]
|
||||
# name = "git"
|
||||
# panes = ["git status"]
|
||||
|
||||
# ============================================================================
|
||||
# AVAILABLE LAYOUTS
|
||||
# ============================================================================
|
||||
# Use these layout values in your windows:
|
||||
#
|
||||
# main-horizontal: Main pane on top, others below
|
||||
# ┌─────────────────────────────┐
|
||||
# │ Main Pane │
|
||||
# ├──────────────┬──────────────┤
|
||||
# │ Pane 2 │ Pane 3 │
|
||||
# └──────────────┴──────────────┘
|
||||
#
|
||||
# main-vertical: Main pane on left, others on right
|
||||
# ┌──────────┬──────────┐
|
||||
# │ │ Pane 2 │
|
||||
# │ Main ├──────────┤
|
||||
# │ Pane │ Pane 3 │
|
||||
# └──────────┴──────────┘
|
||||
#
|
||||
# tiled: All panes in a grid
|
||||
# ┌──────────┬──────────┐
|
||||
# │ Pane 1 │ Pane 2 │
|
||||
# ├──────────┼──────────┤
|
||||
# │ Pane 3 │ Pane 4 │
|
||||
# └──────────┴──────────┘
|
||||
#
|
||||
# even-horizontal: All panes in equal-width columns
|
||||
# ┌────┬────┬────┬────┐
|
||||
# │ P1 │ P2 │ P3 │ P4 │
|
||||
# └────┴────┴────┴────┘
|
||||
#
|
||||
# even-vertical: All panes in equal-height rows
|
||||
# ┌──────────────┐
|
||||
# │ Pane 1 │
|
||||
# ├──────────────┤
|
||||
# │ Pane 2 │
|
||||
# ├──────────────┤
|
||||
# │ Pane 3 │
|
||||
# └──────────────┘
|
||||
|
||||
# ============================================================================
|
||||
# USAGE EXAMPLES
|
||||
# ============================================================================
|
||||
# Run without arguments (uses fzf to select project):
|
||||
# $ tmuxido
|
||||
#
|
||||
# Open specific project directly:
|
||||
# $ tmuxido /path/to/project
|
||||
#
|
||||
# Force refresh cache (after adding new projects):
|
||||
# $ tmuxido --refresh
|
||||
# $ tmuxido -r
|
||||
#
|
||||
# Check cache status:
|
||||
# $ tmuxido --cache-status
|
||||
#
|
||||
# Show help:
|
||||
# $ tmuxido --help
|
||||
|
||||
# ============================================================================
|
||||
# PROJECT-SPECIFIC OVERRIDES
|
||||
# ============================================================================
|
||||
# To customize a specific project, create .tmuxido.toml in that
|
||||
# project's root directory. See .tmuxido.toml.example for details.
|
||||
#
|
||||
# Hierarchy:
|
||||
# 1. Project's .tmuxido.toml (highest priority)
|
||||
# 2. Global [default_session] from this file (fallback)
|
||||
|
||||
# ============================================================================
|
||||
# TIPS & BEST PRACTICES
|
||||
# ============================================================================
|
||||
# 1. Start with the simple Option 1 default, customize as you learn
|
||||
# 2. Use project-specific configs for special projects (web apps, etc)
|
||||
# 3. Set cache_ttl_hours lower (6-12) if you frequently add projects
|
||||
# 4. Add multiple paths to organize personal vs work vs open-source
|
||||
# 5. Use max_depth wisely - higher isn't always better (slower scans)
|
||||
# 6. Run --cache-status to verify your settings are working
|
||||
# 7. The tool auto-detects your tmux base-index (0 or 1), no config needed
|
||||
# 8. Empty panes = shell in project directory (fastest to open)
|
||||
# 9. Commands in panes run automatically when session is created
|
||||
# 10. Use "clear" in panes for clean shells without running commands
|
||||
|
||||
# ============================================================================
|
||||
# TROUBLESHOOTING
|
||||
# ============================================================================
|
||||
# Projects not showing up?
|
||||
# - Check that paths exist and contain .git directories
|
||||
# - Increase max_depth if projects are nested deeper
|
||||
# - Run with --refresh to force cache update
|
||||
#
|
||||
# Cache seems stale?
|
||||
# - Run tmuxido --refresh
|
||||
# - Lower cache_ttl_hours value
|
||||
#
|
||||
# Windows/panes not created correctly?
|
||||
# - Tool auto-detects base-index, but verify with: tmux show-options -g base-index
|
||||
# - Check TOML syntax in default_session or project config
|
||||
#
|
||||
# Want to reset to defaults?
|
||||
# - Delete this file, it will be recreated on next run
|
||||
# - Or copy from: /path/to/repo/tmuxido.toml.example
|
||||
Reference in New Issue
Block a user