forked from cinco/tmuxido
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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/
|
||||||
+58
-1
@@ -1,10 +1,59 @@
|
|||||||
kind: pipeline
|
kind: pipeline
|
||||||
type: docker
|
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
|
name: release
|
||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
event:
|
event:
|
||||||
- tag
|
- tag
|
||||||
|
ref:
|
||||||
|
- refs/tags/[0-9]*
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: build-x86_64
|
- name: build-x86_64
|
||||||
@@ -27,11 +76,19 @@ steps:
|
|||||||
commands:
|
commands:
|
||||||
- apk add --no-cache curl jq
|
- apk add --no-cache curl jq
|
||||||
- |
|
- |
|
||||||
|
# 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 \
|
RELEASE_ID=$(curl -fsSL -X POST \
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases" \
|
"https://git.cincoeuzebio.com/api/v1/repos/cinco/Tmuxido/releases" \
|
||||||
-d "{\"tag_name\":\"$DRONE_TAG\",\"name\":\"$DRONE_TAG\",\"body\":\"Release $DRONE_TAG\"}" \
|
-d "{\"tag_name\":\"$DRONE_TAG\",\"name\":\"$DRONE_TAG\",\"body\":$(echo "$BODY" | jq -Rs .)}" \
|
||||||
| jq -r .id)
|
| jq -r .id)
|
||||||
for ASSET in tmuxido-x86_64-linux tmuxido-aarch64-linux; do
|
for ASSET in tmuxido-x86_64-linux tmuxido-aarch64-linux; do
|
||||||
curl -fsSL -X POST \
|
curl -fsSL -X POST \
|
||||||
|
|||||||
@@ -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,63 @@
|
|||||||
|
# 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.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
+275
-4
@@ -164,6 +164,28 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.16"
|
version = "0.2.16"
|
||||||
@@ -175,6 +197,28 @@ dependencies = [
|
|||||||
"wasi",
|
"wasi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
"wasip2",
|
||||||
|
"wasip3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
|
dependencies = [
|
||||||
|
"foldhash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -187,6 +231,12 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "id-arena"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@@ -194,7 +244,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f"
|
checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown",
|
"hashbrown 0.16.0",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -209,6 +261,12 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "leb128fmt"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.177"
|
version = "0.2.177"
|
||||||
@@ -225,12 +283,30 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.6"
|
version = "2.7.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell_polyfill"
|
name = "once_cell_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -243,6 +319,16 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prettyplease"
|
||||||
|
version = "0.2.37"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.103"
|
version = "1.0.103"
|
||||||
@@ -261,13 +347,19 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom 0.2.16",
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
@@ -278,11 +370,24 @@ version = "0.5.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom 0.2.16",
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.20"
|
version = "1.0.20"
|
||||||
@@ -298,6 +403,12 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
@@ -376,6 +487,19 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.25.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.1",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -418,7 +542,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tmuxido"
|
name = "tmuxido"
|
||||||
version = "0.1.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -426,6 +550,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"shellexpand",
|
"shellexpand",
|
||||||
|
"tempfile",
|
||||||
"toml",
|
"toml",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
@@ -477,6 +602,12 @@ version = "1.0.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06"
|
checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-xid"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -499,6 +630,58 @@ version = "0.11.1+wasi-snapshot-preview1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasip2"
|
||||||
|
version = "1.0.2+wasi-0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasip3"
|
||||||
|
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-encoder"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||||
|
dependencies = [
|
||||||
|
"leb128fmt",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-metadata"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"indexmap",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasmparser"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
"indexmap",
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -671,3 +854,91 @@ checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen-rust-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-core"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rust"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"heck",
|
||||||
|
"indexmap",
|
||||||
|
"prettyplease",
|
||||||
|
"syn",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-component",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen-rust-macro"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"prettyplease",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wit-bindgen-core",
|
||||||
|
"wit-bindgen-rust",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-component"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"bitflags",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"wasm-encoder",
|
||||||
|
"wasm-metadata",
|
||||||
|
"wasmparser",
|
||||||
|
"wit-parser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-parser"
|
||||||
|
version = "0.244.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"id-arena",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"semver",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"serde_json",
|
||||||
|
"unicode-xid",
|
||||||
|
"wasmparser",
|
||||||
|
]
|
||||||
|
|||||||
+4
-1
@@ -1,8 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tmuxido"
|
name = "tmuxido"
|
||||||
version = "0.1.0"
|
version = "0.4.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img src="docs/assets/tmuxido-logo.png" alt="tmuxido logo" width="200"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
# tmuxido
|
# tmuxido
|
||||||
|
|
||||||
|
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
||||||
|
[](https://drone.cincoeuzebio.com/cinco/Tmuxido)
|
||||||
|
[](https://git.cincoeuzebio.com/cinco/Tmuxido/releases)
|
||||||
|

|
||||||
|
|
||||||
A Rust-based tool to quickly find and open projects in tmux using fzf. No external dependencies except tmux and fzf!
|
A Rust-based tool to quickly find and open projects in tmux using fzf. No external dependencies except tmux and fzf!
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -12,6 +21,7 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern
|
|||||||
- TOML-based configuration
|
- TOML-based configuration
|
||||||
- Smart caching system for fast subsequent runs
|
- Smart caching system for fast subsequent runs
|
||||||
- Configurable cache TTL
|
- Configurable cache TTL
|
||||||
|
- Self-update capability (`tmuxido --update`)
|
||||||
- Zero external dependencies (except tmux and fzf)
|
- Zero external dependencies (except tmux and fzf)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -40,6 +50,7 @@ Example configuration:
|
|||||||
# List of paths where to search for projects (git repositories)
|
# List of paths where to search for projects (git repositories)
|
||||||
paths = [
|
paths = [
|
||||||
"~/Projects",
|
"~/Projects",
|
||||||
|
# "~/work",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Maximum depth to search for .git directories
|
# Maximum depth to search for .git directories
|
||||||
@@ -87,6 +98,11 @@ Check cache status:
|
|||||||
tmuxido --cache-status
|
tmuxido --cache-status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Update tmuxido to the latest version:
|
||||||
|
```bash
|
||||||
|
tmuxido --update
|
||||||
|
```
|
||||||
|
|
||||||
View help:
|
View help:
|
||||||
```bash
|
```bash
|
||||||
tmuxido --help
|
tmuxido --help
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "stable"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
+119
-7
@@ -23,14 +23,20 @@ fn now_secs() -> u64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn mtime_secs(time: SystemTime) -> u64 {
|
fn mtime_secs(time: SystemTime) -> u64 {
|
||||||
time.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
|
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
|
/// 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.
|
/// ancestral também na lista. Evita rescanear a mesma subárvore duas vezes.
|
||||||
fn minimal_roots(dirs: &[PathBuf]) -> Vec<PathBuf> {
|
pub(crate) fn minimal_roots(dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||||
dirs.iter()
|
dirs.iter()
|
||||||
.filter(|dir| !dirs.iter().any(|other| other != *dir && dir.starts_with(other)))
|
.filter(|dir| {
|
||||||
|
!dirs
|
||||||
|
.iter()
|
||||||
|
.any(|other| other != *dir && dir.starts_with(other))
|
||||||
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -49,8 +55,9 @@ impl ProjectCache {
|
|||||||
.context("Could not determine cache directory")?
|
.context("Could not determine cache directory")?
|
||||||
.join("tmuxido");
|
.join("tmuxido");
|
||||||
|
|
||||||
fs::create_dir_all(&cache_dir)
|
fs::create_dir_all(&cache_dir).with_context(|| {
|
||||||
.with_context(|| format!("Failed to create cache directory: {}", cache_dir.display()))?;
|
format!("Failed to create cache directory: {}", cache_dir.display())
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(cache_dir.join("projects.json"))
|
Ok(cache_dir.join("projects.json"))
|
||||||
}
|
}
|
||||||
@@ -74,8 +81,7 @@ impl ProjectCache {
|
|||||||
pub fn save(&self) -> Result<()> {
|
pub fn save(&self) -> Result<()> {
|
||||||
let cache_path = Self::cache_path()?;
|
let cache_path = Self::cache_path()?;
|
||||||
|
|
||||||
let content = serde_json::to_string_pretty(self)
|
let content = serde_json::to_string_pretty(self).context("Failed to serialize cache")?;
|
||||||
.context("Failed to serialize cache")?;
|
|
||||||
|
|
||||||
fs::write(&cache_path, content)
|
fs::write(&cache_path, content)
|
||||||
.with_context(|| format!("Failed to write cache file: {}", cache_path.display()))?;
|
.with_context(|| format!("Failed to write cache file: {}", cache_path.display()))?;
|
||||||
@@ -91,6 +97,7 @@ impl ProjectCache {
|
|||||||
///
|
///
|
||||||
/// Retorna `true` se o cache foi modificado.
|
/// Retorna `true` se o cache foi modificado.
|
||||||
/// Retorna `false` com `dir_mtimes` vazio (cache antigo) — chamador deve fazer rescan completo.
|
/// Retorna `false` com `dir_mtimes` vazio (cache antigo) — chamador deve fazer rescan completo.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
pub fn validate_and_update(
|
pub fn validate_and_update(
|
||||||
&mut self,
|
&mut self,
|
||||||
scan_fn: &dyn Fn(&Path) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
|
scan_fn: &dyn Fn(&Path) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
|
||||||
@@ -154,3 +161,108 @@ impl ProjectCache {
|
|||||||
now_secs().saturating_sub(self.last_updated)
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+46
-6
@@ -78,18 +78,24 @@ impl Config {
|
|||||||
let config_path = Self::config_path()?;
|
let config_path = Self::config_path()?;
|
||||||
|
|
||||||
if !config_path.exists() {
|
if !config_path.exists() {
|
||||||
let config_dir = config_path.parent()
|
let config_dir = config_path
|
||||||
|
.parent()
|
||||||
.context("Could not get parent directory")?;
|
.context("Could not get parent directory")?;
|
||||||
|
|
||||||
fs::create_dir_all(config_dir)
|
fs::create_dir_all(config_dir).with_context(|| {
|
||||||
.with_context(|| format!("Failed to create config directory: {}", config_dir.display()))?;
|
format!(
|
||||||
|
"Failed to create config directory: {}",
|
||||||
|
config_dir.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
let default_config = Self::default_config();
|
let default_config = Self::default_config();
|
||||||
let toml_string = toml::to_string_pretty(&default_config)
|
let toml_string = toml::to_string_pretty(&default_config)
|
||||||
.context("Failed to serialize default config")?;
|
.context("Failed to serialize default config")?;
|
||||||
|
|
||||||
fs::write(&config_path, toml_string)
|
fs::write(&config_path, toml_string).with_context(|| {
|
||||||
.with_context(|| format!("Failed to write config file: {}", config_path.display()))?;
|
format!("Failed to write config file: {}", config_path.display())
|
||||||
|
})?;
|
||||||
|
|
||||||
eprintln!("Created default config at: {}", config_path.display());
|
eprintln!("Created default config at: {}", config_path.display());
|
||||||
}
|
}
|
||||||
@@ -102,7 +108,7 @@ impl Config {
|
|||||||
paths: vec![
|
paths: vec![
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.join("Work/Projects")
|
.join("Projects")
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string(),
|
.to_string(),
|
||||||
],
|
],
|
||||||
@@ -112,5 +118,39 @@ impl Config {
|
|||||||
default_session: default_session_config(),
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
pub mod cache;
|
||||||
|
pub mod config;
|
||||||
|
pub mod deps;
|
||||||
|
pub mod self_update;
|
||||||
|
pub mod session;
|
||||||
|
|
||||||
|
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>> {
|
||||||
|
if !config.cache_enabled || force_refresh {
|
||||||
|
let (projects, fingerprints) = scan_all_roots(config)?;
|
||||||
|
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||||
|
cache.save()?;
|
||||||
|
eprintln!("Cache updated with {} projects", projects.len());
|
||||||
|
return Ok(projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mut cache) = ProjectCache::load()? {
|
||||||
|
// 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) = scan_all_roots(config)?;
|
||||||
|
let new_cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||||
|
new_cache.save()?;
|
||||||
|
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.save()?;
|
||||||
|
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) = scan_all_roots(config)?;
|
||||||
|
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||||
|
cache.save()?;
|
||||||
|
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(())
|
||||||
|
}
|
||||||
+17
-158
@@ -1,18 +1,12 @@
|
|||||||
mod cache;
|
|
||||||
mod config;
|
|
||||||
mod session;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use cache::ProjectCache;
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use config::Config;
|
|
||||||
use session::{SessionConfig, TmuxSession};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::time::UNIX_EPOCH;
|
use tmuxido::config::Config;
|
||||||
use walkdir::WalkDir;
|
use tmuxido::deps::ensure_dependencies;
|
||||||
|
use tmuxido::self_update;
|
||||||
|
use tmuxido::{get_projects, launch_tmux_session, show_cache_status};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(
|
#[command(
|
||||||
@@ -31,11 +25,23 @@ struct Args {
|
|||||||
/// Show cache status and exit
|
/// Show cache status and exit
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
cache_status: bool,
|
cache_status: bool,
|
||||||
|
|
||||||
|
/// Update tmuxido to the latest version
|
||||||
|
#[arg(long)]
|
||||||
|
update: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let args = Args::parse();
|
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
|
// Ensure config exists
|
||||||
Config::ensure_config_exists()?;
|
Config::ensure_config_exists()?;
|
||||||
|
|
||||||
@@ -74,141 +80,6 @@ fn main() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>> {
|
|
||||||
if !config.cache_enabled || force_refresh {
|
|
||||||
let (projects, fingerprints) = scan_all_roots(config)?;
|
|
||||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
|
||||||
cache.save()?;
|
|
||||||
eprintln!("Cache updated with {} projects", projects.len());
|
|
||||||
return Ok(projects);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(mut cache) = ProjectCache::load()? {
|
|
||||||
// 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) = scan_all_roots(config)?;
|
|
||||||
let new_cache = ProjectCache::new(projects.clone(), fingerprints);
|
|
||||||
new_cache.save()?;
|
|
||||||
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.save()?;
|
|
||||||
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) = scan_all_roots(config)?;
|
|
||||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
|
||||||
cache.save()?;
|
|
||||||
eprintln!("Cache updated with {} projects", projects.len());
|
|
||||||
Ok(projects)
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
|
||||||
if 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> {
|
fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> {
|
||||||
let mut child = Command::new("fzf")
|
let mut child = Command::new("fzf")
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
@@ -237,15 +108,3 @@ fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> {
|
|||||||
|
|
||||||
Ok(PathBuf::from(selected))
|
Ok(PathBuf::from(selected))
|
||||||
}
|
}
|
||||||
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
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("x86_64-linux"),
|
||||||
|
"aarch64" => Ok("aarch64-linux"),
|
||||||
|
_ => Err(anyhow::anyhow!("Unsupported architecture: {}", arch)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch latest release tag from Gitea API
|
||||||
|
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
|
||||||
|
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_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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-21
@@ -30,15 +30,16 @@ impl SessionConfig {
|
|||||||
let content = fs::read_to_string(&config_path)
|
let content = fs::read_to_string(&config_path)
|
||||||
.with_context(|| format!("Failed to read session config: {}", config_path.display()))?;
|
.with_context(|| format!("Failed to read session config: {}", config_path.display()))?;
|
||||||
|
|
||||||
let config: SessionConfig = toml::from_str(&content)
|
let config: SessionConfig = toml::from_str(&content).with_context(|| {
|
||||||
.with_context(|| format!("Failed to parse session config: {}", config_path.display()))?;
|
format!("Failed to parse session config: {}", config_path.display())
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Some(config))
|
Ok(Some(config))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct TmuxSession {
|
pub struct TmuxSession {
|
||||||
session_name: String,
|
pub(crate) session_name: String,
|
||||||
project_path: String,
|
project_path: String,
|
||||||
base_index: usize,
|
base_index: usize,
|
||||||
}
|
}
|
||||||
@@ -67,14 +68,14 @@ impl TmuxSession {
|
|||||||
.args(["show-options", "-gv", "base-index"])
|
.args(["show-options", "-gv", "base-index"])
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
if let Ok(output) = output {
|
if let Ok(output) = output
|
||||||
if output.status.success() {
|
&& output.status.success()
|
||||||
|
{
|
||||||
let index_str = String::from_utf8_lossy(&output.stdout);
|
let index_str = String::from_utf8_lossy(&output.stdout);
|
||||||
if let Ok(index) = index_str.trim().parse::<usize>() {
|
if let Ok(index) = index_str.trim().parse::<usize>() {
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Default to 0 if we can't determine
|
// Default to 0 if we can't determine
|
||||||
0
|
0
|
||||||
@@ -206,7 +207,11 @@ impl TmuxSession {
|
|||||||
|
|
||||||
// Select the first window
|
// Select the first window
|
||||||
Command::new("tmux")
|
Command::new("tmux")
|
||||||
.args(["select-window", "-t", &format!("{}:{}", self.session_name, self.base_index)])
|
.args([
|
||||||
|
"select-window",
|
||||||
|
"-t",
|
||||||
|
&format!("{}:{}", self.session_name, self.base_index),
|
||||||
|
])
|
||||||
.status()
|
.status()
|
||||||
.context("Failed to select first window")?;
|
.context("Failed to select first window")?;
|
||||||
|
|
||||||
@@ -221,13 +226,7 @@ impl TmuxSession {
|
|||||||
if pane_index > 0 {
|
if pane_index > 0 {
|
||||||
// Create new pane by splitting
|
// Create new pane by splitting
|
||||||
Command::new("tmux")
|
Command::new("tmux")
|
||||||
.args([
|
.args(["split-window", "-t", &target, "-c", &self.project_path])
|
||||||
"split-window",
|
|
||||||
"-t",
|
|
||||||
&target,
|
|
||||||
"-c",
|
|
||||||
&self.project_path,
|
|
||||||
])
|
|
||||||
.status()
|
.status()
|
||||||
.context("Failed to split pane")?;
|
.context("Failed to split pane")?;
|
||||||
}
|
}
|
||||||
@@ -236,13 +235,7 @@ impl TmuxSession {
|
|||||||
if !command.is_empty() {
|
if !command.is_empty() {
|
||||||
let pane_target = format!("{}:{}.{}", self.session_name, window_index, pane_index);
|
let pane_target = format!("{}:{}.{}", self.session_name, window_index, pane_index);
|
||||||
Command::new("tmux")
|
Command::new("tmux")
|
||||||
.args([
|
.args(["send-keys", "-t", &pane_target, command, "Enter"])
|
||||||
"send-keys",
|
|
||||||
"-t",
|
|
||||||
&pane_target,
|
|
||||||
command,
|
|
||||||
"Enter",
|
|
||||||
])
|
|
||||||
.status()
|
.status()
|
||||||
.context("Failed to send keys to pane")?;
|
.context("Failed to send keys to pane")?;
|
||||||
}
|
}
|
||||||
@@ -265,3 +258,52 @@ impl TmuxSession {
|
|||||||
Ok(())
|
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,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,65 @@
|
|||||||
|
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,
|
||||||
|
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());
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
paths = [
|
paths = [
|
||||||
"~/Projects",
|
"~/Projects",
|
||||||
|
# "~/work",
|
||||||
# "~/opensource",
|
# "~/opensource",
|
||||||
# "~/clients/company-name",
|
# "~/clients/company-name",
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user