Compare commits

..

No commits in common. "daad6a84f31d77124d7adb1be68a27daf7410bba" and "8ab3c15983b72045f84e2abd6d395dfaff9feca7" have entirely different histories.

14 changed files with 200 additions and 929 deletions

View File

@ -1,12 +0,0 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"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
}
]
}
}

View File

@ -1,14 +0,0 @@
{
"mcpServers": {
"rust-mcp": {
"type": "stdio",
"command": "rust-mcp-server",
"args": []
},
"crates": {
"type": "stdio",
"command": "crates-mcp",
"args": []
}
}
}

121
CLAUDE.md
View File

@ -1,121 +0,0 @@
# 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.

279
Cargo.lock generated
View File

@ -164,28 +164,6 @@ 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"
@ -197,28 +175,6 @@ 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"
@ -231,12 +187,6 @@ 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"
@ -244,9 +194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.16.0", "hashbrown",
"serde",
"serde_core",
] ]
[[package]] [[package]]
@ -261,12 +209,6 @@ 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"
@ -283,30 +225,12 @@ 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"
@ -319,16 +243,6 @@ 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"
@ -347,19 +261,13 @@ 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 0.2.16", "getrandom",
"libredox", "libredox",
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
@ -370,24 +278,11 @@ 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 0.2.16", "getrandom",
"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"
@ -403,12 +298,6 @@ 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"
@ -487,19 +376,6 @@ 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"
@ -542,7 +418,7 @@ dependencies = [
[[package]] [[package]]
name = "tmuxido" name = "tmuxido"
version = "0.2.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@ -550,7 +426,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"shellexpand", "shellexpand",
"tempfile",
"toml", "toml",
"walkdir", "walkdir",
] ]
@ -602,12 +477,6 @@ 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"
@ -630,58 +499,6 @@ 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"
@ -854,91 +671,3 @@ 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",
]

View File

@ -1,11 +1,8 @@
[package] [package]
name = "tmuxido" name = "tmuxido"
version = "0.2.0" version = "0.1.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"

View File

@ -1,3 +0,0 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]

View File

@ -23,20 +23,14 @@ fn now_secs() -> u64 {
} }
fn mtime_secs(time: SystemTime) -> u64 { fn mtime_secs(time: SystemTime) -> u64 {
time.duration_since(UNIX_EPOCH) time.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
.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.
pub(crate) fn minimal_roots(dirs: &[PathBuf]) -> Vec<PathBuf> { fn minimal_roots(dirs: &[PathBuf]) -> Vec<PathBuf> {
dirs.iter() dirs.iter()
.filter(|dir| { .filter(|dir| !dirs.iter().any(|other| other != *dir && dir.starts_with(other)))
!dirs
.iter()
.any(|other| other != *dir && dir.starts_with(other))
})
.cloned() .cloned()
.collect() .collect()
} }
@ -55,9 +49,8 @@ impl ProjectCache {
.context("Could not determine cache directory")? .context("Could not determine cache directory")?
.join("tmuxido"); .join("tmuxido");
fs::create_dir_all(&cache_dir).with_context(|| { fs::create_dir_all(&cache_dir)
format!("Failed to create cache directory: {}", cache_dir.display()) .with_context(|| format!("Failed to create cache directory: {}", cache_dir.display()))?;
})?;
Ok(cache_dir.join("projects.json")) Ok(cache_dir.join("projects.json"))
} }
@ -81,7 +74,8 @@ 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).context("Failed to serialize cache")?; let content = serde_json::to_string_pretty(self)
.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()))?;
@ -97,7 +91,6 @@ 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>)>,
@ -161,108 +154,3 @@ 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);
}
}

View File

@ -78,24 +78,18 @@ 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 let config_dir = config_path.parent()
.parent()
.context("Could not get parent directory")?; .context("Could not get parent directory")?;
fs::create_dir_all(config_dir).with_context(|| { fs::create_dir_all(config_dir)
format!( .with_context(|| format!("Failed to create config directory: {}", config_dir.display()))?;
"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).with_context(|| { fs::write(&config_path, toml_string)
format!("Failed to write config file: {}", config_path.display()) .with_context(|| 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());
} }
@ -118,39 +112,5 @@ 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());
}
} }

View File

@ -1,162 +0,0 @@
pub mod cache;
pub mod config;
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(())
}

View File

@ -1,10 +1,18 @@
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::PathBuf; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use tmuxido::config::Config; use std::time::UNIX_EPOCH;
use tmuxido::{get_projects, launch_tmux_session, show_cache_status}; use walkdir::WalkDir;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command( #[command(
@ -66,6 +74,141 @@ 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())
@ -94,3 +237,15 @@ 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(())
}

View File

@ -30,16 +30,15 @@ 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).with_context(|| { let config: SessionConfig = toml::from_str(&content)
format!("Failed to parse session config: {}", config_path.display()) .with_context(|| format!("Failed to parse session config: {}", config_path.display()))?;
})?;
Ok(Some(config)) Ok(Some(config))
} }
} }
pub struct TmuxSession { pub struct TmuxSession {
pub(crate) session_name: String, session_name: String,
project_path: String, project_path: String,
base_index: usize, base_index: usize,
} }
@ -68,14 +67,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 {
&& output.status.success() if 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
@ -207,11 +206,7 @@ impl TmuxSession {
// Select the first window // Select the first window
Command::new("tmux") Command::new("tmux")
.args([ .args(["select-window", "-t", &format!("{}:{}", self.session_name, self.base_index)])
"select-window",
"-t",
&format!("{}:{}", self.session_name, self.base_index),
])
.status() .status()
.context("Failed to select first window")?; .context("Failed to select first window")?;
@ -226,7 +221,13 @@ 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(["split-window", "-t", &target, "-c", &self.project_path]) .args([
"split-window",
"-t",
&target,
"-c",
&self.project_path,
])
.status() .status()
.context("Failed to split pane")?; .context("Failed to split pane")?;
} }
@ -235,7 +236,13 @@ 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(["send-keys", "-t", &pane_target, command, "Enter"]) .args([
"send-keys",
"-t",
&pane_target,
command,
"Enter",
])
.status() .status()
.context("Failed to send keys to pane")?; .context("Failed to send keys to pane")?;
} }
@ -258,52 +265,3 @@ 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);
}
}

View File

@ -1,13 +0,0 @@
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);
}

View File

@ -1,65 +0,0 @@
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"));
}

View File

@ -1,26 +0,0 @@
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());
}