Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3380c668a | |||
| 8aa341080d | |||
| 2abf7e77b4 | |||
| 2d7d49d548 | |||
| ee2059986c |
28
CHANGELOG.md
28
CHANGELOG.md
@ -4,6 +4,34 @@ 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/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
|
## [0.10.0] - 2026-03-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- fzf preview panel: hovering over a project shows its `README.md` in the right 40% of the screen
|
||||||
|
- Uses `glow` for rendered markdown when available, falls back to `cat`
|
||||||
|
- `CLICOLOR_FORCE=1` ensures glow outputs full ANSI colors even in fzf's non-TTY preview pipe
|
||||||
|
- Preview command runs via `sh -c '...' -- {}` for compatibility with fish, zsh, and bash
|
||||||
|
|
||||||
|
## [0.9.2] - 2026-03-04
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Cache now uses stale-while-revalidate: cached projects are returned immediately and a background process (`--background-refresh`) rebuilds the cache when it is stale, eliminating blocking scans on every invocation
|
||||||
|
- `cache_ttl_hours` is now enforced: when the cache age exceeds the configured TTL, a background refresh is triggered automatically
|
||||||
|
|
||||||
|
## [0.9.1] - 2026-03-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Shortcut and desktop integration wizards are now offered regardless of whether the user chose the interactive wizard or the default config on first run; previously they were only offered in the wizard path
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- First-run setup choice prompt: when no configuration file exists, tmuxido now asks whether to run the interactive wizard or apply sensible defaults immediately
|
||||||
|
- `SetupChoice` enum and `parse_setup_choice_input` in `ui` module (pure, fully tested)
|
||||||
|
- `Config::write_default_config` helper for writing defaults without any prompts
|
||||||
|
- `Config::run_wizard` extracted from `ensure_config_exists` for clarity and testability
|
||||||
|
- `render_setup_choice_prompt` and `render_default_config_saved` render functions
|
||||||
|
|
||||||
## [0.8.3] - 2026-03-01
|
## [0.8.3] - 2026-03-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
20
Cargo.lock
generated
20
Cargo.lock
generated
@ -293,9 +293,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
|
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
@ -570,18 +570,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.44"
|
version = "1.0.45"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "5.3.0"
|
version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
@ -809,7 +809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.1",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@ -857,7 +857,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tmuxido"
|
name = "tmuxido"
|
||||||
version = "0.8.3"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@ -1133,9 +1133,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.7.14"
|
version = "0.7.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tmuxido"
|
name = "tmuxido"
|
||||||
version = "0.8.3"
|
version = "0.10.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@ -17,7 +17,7 @@ A Rust-based tool to quickly find and open projects in tmux using fzf. No extern
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Search for git repositories in configurable paths
|
- Search for git repositories in configurable paths
|
||||||
- Interactive selection using fzf
|
- Interactive selection using fzf with live `README.md` preview (rendered via `glow` when available)
|
||||||
- Native tmux session creation (no tmuxinator required!)
|
- Native tmux session creation (no tmuxinator required!)
|
||||||
- Support for project-specific `.tmuxido.toml` configs
|
- Support for project-specific `.tmuxido.toml` configs
|
||||||
- Smart session switching (reuses existing sessions)
|
- Smart session switching (reuses existing sessions)
|
||||||
|
|||||||
@ -96,7 +96,40 @@ impl Config {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Run interactive configuration wizard
|
// Ask whether to run the interactive wizard or apply sensible defaults
|
||||||
|
let raw = ui::render_setup_choice_prompt()?;
|
||||||
|
match ui::parse_setup_choice_input(&raw) {
|
||||||
|
ui::SetupChoice::Default => {
|
||||||
|
Self::write_default_config(&config_path)?;
|
||||||
|
ui::render_default_config_saved(&config_path.display().to_string());
|
||||||
|
}
|
||||||
|
ui::SetupChoice::Wizard => {
|
||||||
|
Self::run_wizard(&config_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offer shortcut and desktop integration regardless of setup mode
|
||||||
|
if let Err(e) = crate::shortcut::setup_shortcut_wizard() {
|
||||||
|
eprintln!("Warning: shortcut setup failed: {}", e);
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::shortcut::setup_desktop_integration_wizard() {
|
||||||
|
eprintln!("Warning: desktop integration failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(config_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the built-in default config to `config_path` without any prompts.
|
||||||
|
fn write_default_config(config_path: &std::path::Path) -> Result<()> {
|
||||||
|
let config = Self::default_config();
|
||||||
|
let toml_string = toml::to_string_pretty(&config).context("Failed to serialize config")?;
|
||||||
|
fs::write(config_path, toml_string)
|
||||||
|
.with_context(|| format!("Failed to write config file: {}", config_path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the full interactive configuration wizard and offer shortcut / desktop setup at the end.
|
||||||
|
fn run_wizard(config_path: &std::path::Path) -> Result<()> {
|
||||||
let paths = Self::prompt_for_paths()?;
|
let paths = Self::prompt_for_paths()?;
|
||||||
let max_depth = Self::prompt_for_max_depth()?;
|
let max_depth = Self::prompt_for_max_depth()?;
|
||||||
let cache_enabled = Self::prompt_for_cache_enabled()?;
|
let cache_enabled = Self::prompt_for_cache_enabled()?;
|
||||||
@ -111,7 +144,7 @@ impl Config {
|
|||||||
ui::render_config_created(&paths, max_depth, cache_enabled, cache_ttl_hours, &windows);
|
ui::render_config_created(&paths, max_depth, cache_enabled, cache_ttl_hours, &windows);
|
||||||
|
|
||||||
let config = Config {
|
let config = Config {
|
||||||
paths: paths.clone(),
|
paths,
|
||||||
max_depth,
|
max_depth,
|
||||||
cache_enabled,
|
cache_enabled,
|
||||||
cache_ttl_hours,
|
cache_ttl_hours,
|
||||||
@ -119,25 +152,10 @@ impl Config {
|
|||||||
default_session: SessionConfig { windows },
|
default_session: SessionConfig { windows },
|
||||||
};
|
};
|
||||||
|
|
||||||
let toml_string =
|
let toml_string = toml::to_string_pretty(&config).context("Failed to serialize config")?;
|
||||||
toml::to_string_pretty(&config).context("Failed to serialize 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()))
|
||||||
})?;
|
|
||||||
|
|
||||||
// Offer to set up a keyboard shortcut (best-effort, non-fatal)
|
|
||||||
if let Err(e) = crate::shortcut::setup_shortcut_wizard() {
|
|
||||||
eprintln!("Warning: shortcut setup failed: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offer to install .desktop entry + icon (best-effort, non-fatal)
|
|
||||||
if let Err(e) = crate::shortcut::setup_desktop_integration_wizard() {
|
|
||||||
eprintln!("Warning: desktop integration failed: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(config_path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prompt_for_paths() -> Result<Vec<String>> {
|
fn prompt_for_paths() -> Result<Vec<String>> {
|
||||||
@ -377,6 +395,35 @@ mod tests {
|
|||||||
assert_eq!(ui::parse_layout_input("invalid"), None);
|
assert_eq!(ui::parse_layout_input("invalid"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_write_default_config_to_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let config_path = dir.path().join("tmuxido.toml");
|
||||||
|
|
||||||
|
Config::write_default_config(&config_path).unwrap();
|
||||||
|
|
||||||
|
assert!(config_path.exists());
|
||||||
|
let content = std::fs::read_to_string(&config_path).unwrap();
|
||||||
|
let loaded: Config = toml::from_str(&content).unwrap();
|
||||||
|
assert!(!loaded.paths.is_empty());
|
||||||
|
assert_eq!(loaded.max_depth, 5);
|
||||||
|
assert!(loaded.cache_enabled);
|
||||||
|
assert_eq!(loaded.cache_ttl_hours, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_write_valid_toml_in_default_config() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let config_path = dir.path().join("tmuxido.toml");
|
||||||
|
|
||||||
|
Config::write_default_config(&config_path).unwrap();
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&config_path).unwrap();
|
||||||
|
// Must parse cleanly
|
||||||
|
let result: Result<Config, _> = toml::from_str(&content);
|
||||||
|
assert!(result.is_ok(), "Default config must be valid TOML");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_parse_config_with_windows_and_panes() {
|
fn should_parse_config_with_windows_and_panes() {
|
||||||
let toml_str = r#"
|
let toml_str = r#"
|
||||||
|
|||||||
318
src/lib.rs
318
src/lib.rs
@ -55,9 +55,47 @@ pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>
|
|||||||
&ProjectCache::load,
|
&ProjectCache::load,
|
||||||
&|cache| cache.save(),
|
&|cache| cache.save(),
|
||||||
&scan_all_roots,
|
&scan_all_roots,
|
||||||
|
&spawn_background_refresh,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rebuilds the project cache incrementally. Intended to be called from a
|
||||||
|
/// background process spawned by `get_projects` via stale-while-revalidate.
|
||||||
|
pub fn refresh_cache(config: &Config) -> Result<()> {
|
||||||
|
match ProjectCache::load()? {
|
||||||
|
None => {
|
||||||
|
let (projects, fingerprints) = scan_all_roots(config)?;
|
||||||
|
ProjectCache::new(projects, fingerprints).save()?;
|
||||||
|
}
|
||||||
|
Some(mut cache) => {
|
||||||
|
if cache.dir_mtimes.is_empty() {
|
||||||
|
// Old cache format — full rescan
|
||||||
|
let (projects, fingerprints) = scan_all_roots(config)?;
|
||||||
|
ProjectCache::new(projects, fingerprints).save()?;
|
||||||
|
} else {
|
||||||
|
// Incremental rescan based on directory mtimes
|
||||||
|
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
|
||||||
|
if changed {
|
||||||
|
cache.save()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_background_refresh() {
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
std::process::Command::new(exe)
|
||||||
|
.arg("--background-refresh")
|
||||||
|
.stdin(std::process::Stdio::null())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
fn get_projects_internal(
|
fn get_projects_internal(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
@ -65,45 +103,31 @@ fn get_projects_internal(
|
|||||||
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
|
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
|
||||||
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
|
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
|
||||||
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
|
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
|
||||||
|
refresh_spawner: &dyn Fn(),
|
||||||
) -> Result<Vec<PathBuf>> {
|
) -> Result<Vec<PathBuf>> {
|
||||||
if !config.cache_enabled || force_refresh {
|
if !config.cache_enabled || force_refresh {
|
||||||
let (projects, fingerprints) = scanner(config)?;
|
let (projects, fingerprints) = scanner(config)?;
|
||||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||||
cache_saver(&cache)?;
|
cache_saver(&cache)?;
|
||||||
eprintln!("Cache updated with {} projects", projects.len());
|
|
||||||
return Ok(projects);
|
return Ok(projects);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(mut cache) = cache_loader()? {
|
if let Some(cache) = cache_loader()? {
|
||||||
// Cache no formato antigo (sem dir_mtimes) → atualizar com rescan completo
|
// Cache exists — return immediately (stale-while-revalidate).
|
||||||
if cache.dir_mtimes.is_empty() {
|
// Spawn a background refresh if the cache is stale or in old format.
|
||||||
eprintln!("Upgrading cache, scanning for projects...");
|
let is_stale =
|
||||||
let (projects, fingerprints) = scanner(config)?;
|
cache.dir_mtimes.is_empty() || cache.age_in_seconds() > config.cache_ttl_hours * 3600;
|
||||||
let new_cache = ProjectCache::new(projects.clone(), fingerprints);
|
if is_stale {
|
||||||
cache_saver(&new_cache)?;
|
refresh_spawner();
|
||||||
eprintln!("Cache updated with {} projects", projects.len());
|
|
||||||
return Ok(projects);
|
|
||||||
}
|
|
||||||
|
|
||||||
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
|
|
||||||
if changed {
|
|
||||||
cache_saver(&cache)?;
|
|
||||||
eprintln!(
|
|
||||||
"Cache updated incrementally ({} projects)",
|
|
||||||
cache.projects.len()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
eprintln!("Using cached projects ({} projects)", cache.projects.len());
|
|
||||||
}
|
}
|
||||||
return Ok(cache.projects);
|
return Ok(cache.projects);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sem cache ainda — scan completo inicial
|
// No cache yet — first run, blocking scan is unavoidable.
|
||||||
eprintln!("No cache found, scanning for projects...");
|
eprintln!("No cache found, scanning for projects...");
|
||||||
let (projects, fingerprints) = scanner(config)?;
|
let (projects, fingerprints) = scanner(config)?;
|
||||||
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
let cache = ProjectCache::new(projects.clone(), fingerprints);
|
||||||
cache_saver(&cache)?;
|
cache_saver(&cache)?;
|
||||||
eprintln!("Cache updated with {} projects", projects.len());
|
|
||||||
Ok(projects)
|
Ok(projects)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,242 +220,208 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
|
||||||
fn create_test_config(cache_enabled: bool) -> Config {
|
fn make_config(cache_enabled: bool, cache_ttl_hours: u64) -> Config {
|
||||||
Config {
|
Config {
|
||||||
paths: vec!["/tmp/test".to_string()],
|
paths: vec!["/tmp/test".to_string()],
|
||||||
max_depth: 3,
|
max_depth: 3,
|
||||||
cache_enabled,
|
cache_enabled,
|
||||||
cache_ttl_hours: 24,
|
cache_ttl_hours,
|
||||||
update_check_interval_hours: 24,
|
update_check_interval_hours: 24,
|
||||||
default_session: session::SessionConfig { windows: vec![] },
|
default_session: session::SessionConfig { windows: vec![] },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fresh_cache(projects: Vec<PathBuf>) -> ProjectCache {
|
||||||
|
let fingerprints = HashMap::from([(PathBuf::from("/tracked"), 1u64)]);
|
||||||
|
ProjectCache::new(projects, fingerprints)
|
||||||
|
// last_updated = now_secs() — within any reasonable TTL
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stale_cache(projects: Vec<PathBuf>) -> ProjectCache {
|
||||||
|
let fingerprints = HashMap::from([(PathBuf::from("/tracked"), 1u64)]);
|
||||||
|
let mut c = ProjectCache::new(projects, fingerprints);
|
||||||
|
c.last_updated = 0; // epoch — always older than TTL
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_internal(
|
||||||
|
config: &Config,
|
||||||
|
force_refresh: bool,
|
||||||
|
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
|
||||||
|
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
|
||||||
|
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
|
||||||
|
refresh_spawner: &dyn Fn(),
|
||||||
|
) -> Result<Vec<PathBuf>> {
|
||||||
|
get_projects_internal(
|
||||||
|
config,
|
||||||
|
force_refresh,
|
||||||
|
cache_loader,
|
||||||
|
cache_saver,
|
||||||
|
scanner,
|
||||||
|
refresh_spawner,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_scan_when_cache_disabled() {
|
fn should_scan_when_cache_disabled() {
|
||||||
let config = create_test_config(false);
|
let config = make_config(false, 24);
|
||||||
let projects = vec![PathBuf::from("/tmp/test/project1")];
|
let expected = vec![PathBuf::from("/p1")];
|
||||||
let fingerprints = HashMap::new();
|
|
||||||
let expected_projects = projects.clone();
|
|
||||||
|
|
||||||
let scanner_called = RefCell::new(false);
|
let scanner_called = RefCell::new(false);
|
||||||
let saver_called = RefCell::new(false);
|
let saver_called = RefCell::new(false);
|
||||||
|
let spawner_called = RefCell::new(false);
|
||||||
|
|
||||||
let result = get_projects_internal(
|
let result = call_internal(
|
||||||
&config,
|
&config,
|
||||||
false,
|
false,
|
||||||
&|| panic!("should not load cache when disabled"),
|
&|| panic!("loader must not be called when cache disabled"),
|
||||||
&|_| {
|
&|_| {
|
||||||
*saver_called.borrow_mut() = true;
|
*saver_called.borrow_mut() = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
&|_| {
|
&|_| {
|
||||||
*scanner_called.borrow_mut() = true;
|
*scanner_called.borrow_mut() = true;
|
||||||
Ok((expected_projects.clone(), fingerprints.clone()))
|
Ok((expected.clone(), HashMap::new()))
|
||||||
},
|
},
|
||||||
|
&|| *spawner_called.borrow_mut() = true,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
assert!(scanner_called.into_inner());
|
assert!(scanner_called.into_inner());
|
||||||
assert!(saver_called.into_inner());
|
assert!(saver_called.into_inner());
|
||||||
assert_eq!(result.unwrap(), projects);
|
assert!(!spawner_called.into_inner());
|
||||||
|
assert_eq!(result.unwrap(), expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_scan_when_force_refresh() {
|
fn should_scan_when_force_refresh() {
|
||||||
let config = create_test_config(true);
|
let config = make_config(true, 24);
|
||||||
let projects = vec![PathBuf::from("/tmp/test/project1")];
|
let expected = vec![PathBuf::from("/p1")];
|
||||||
let fingerprints = HashMap::new();
|
|
||||||
let expected_projects = projects.clone();
|
|
||||||
|
|
||||||
let scanner_called = RefCell::new(false);
|
let scanner_called = RefCell::new(false);
|
||||||
let saver_called = RefCell::new(false);
|
let saver_called = RefCell::new(false);
|
||||||
|
let spawner_called = RefCell::new(false);
|
||||||
|
|
||||||
let result = get_projects_internal(
|
let result = call_internal(
|
||||||
&config,
|
&config,
|
||||||
true,
|
true,
|
||||||
&|| panic!("should not load cache when force refresh"),
|
&|| panic!("loader must not be called on force refresh"),
|
||||||
&|_| {
|
&|_| {
|
||||||
*saver_called.borrow_mut() = true;
|
*saver_called.borrow_mut() = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
&|_| {
|
&|_| {
|
||||||
*scanner_called.borrow_mut() = true;
|
*scanner_called.borrow_mut() = true;
|
||||||
Ok((expected_projects.clone(), fingerprints.clone()))
|
Ok((expected.clone(), HashMap::new()))
|
||||||
},
|
},
|
||||||
|
&|| *spawner_called.borrow_mut() = true,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
assert!(scanner_called.into_inner());
|
assert!(scanner_called.into_inner());
|
||||||
assert!(saver_called.into_inner());
|
assert!(saver_called.into_inner());
|
||||||
assert_eq!(result.unwrap(), projects);
|
assert!(!spawner_called.into_inner());
|
||||||
|
assert_eq!(result.unwrap(), expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_do_initial_scan_when_no_cache_exists() {
|
fn should_do_blocking_scan_when_no_cache_exists() {
|
||||||
let config = create_test_config(true);
|
let config = make_config(true, 24);
|
||||||
let projects = vec![PathBuf::from("/tmp/test/project1")];
|
let expected = vec![PathBuf::from("/p1")];
|
||||||
let fingerprints = HashMap::new();
|
|
||||||
let expected_projects = projects.clone();
|
|
||||||
|
|
||||||
let loader_called = RefCell::new(false);
|
|
||||||
let scanner_called = RefCell::new(false);
|
let scanner_called = RefCell::new(false);
|
||||||
let saver_called = RefCell::new(false);
|
let saver_called = RefCell::new(false);
|
||||||
|
let spawner_called = RefCell::new(false);
|
||||||
|
|
||||||
let result = get_projects_internal(
|
let result = call_internal(
|
||||||
&config,
|
&config,
|
||||||
false,
|
false,
|
||||||
&|| {
|
&|| Ok(None),
|
||||||
*loader_called.borrow_mut() = true;
|
|
||||||
Ok(None)
|
|
||||||
},
|
|
||||||
&|_| {
|
&|_| {
|
||||||
*saver_called.borrow_mut() = true;
|
*saver_called.borrow_mut() = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
&|_| {
|
&|_| {
|
||||||
*scanner_called.borrow_mut() = true;
|
*scanner_called.borrow_mut() = true;
|
||||||
Ok((expected_projects.clone(), fingerprints.clone()))
|
Ok((expected.clone(), HashMap::new()))
|
||||||
},
|
},
|
||||||
|
&|| *spawner_called.borrow_mut() = true,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
assert!(loader_called.into_inner());
|
|
||||||
assert!(scanner_called.into_inner());
|
assert!(scanner_called.into_inner());
|
||||||
assert!(saver_called.into_inner());
|
assert!(saver_called.into_inner());
|
||||||
assert_eq!(result.unwrap(), projects);
|
assert!(!spawner_called.into_inner());
|
||||||
|
assert_eq!(result.unwrap(), expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_upgrade_old_cache_format() {
|
fn should_return_cached_projects_immediately_when_cache_is_fresh() {
|
||||||
let config = create_test_config(true);
|
let config = make_config(true, 24);
|
||||||
let old_projects = vec![PathBuf::from("/old/project")];
|
let cached = vec![PathBuf::from("/cached/project")];
|
||||||
let new_projects = vec![
|
let cache = RefCell::new(Some(fresh_cache(cached.clone())));
|
||||||
PathBuf::from("/new/project1"),
|
let spawner_called = RefCell::new(false);
|
||||||
PathBuf::from("/new/project2"),
|
|
||||||
];
|
|
||||||
let new_fingerprints = HashMap::from([(PathBuf::from("/new"), 12345u64)]);
|
|
||||||
|
|
||||||
// Use RefCell<Option<>> to allow moving into closure multiple times
|
let result = call_internal(
|
||||||
let old_cache = RefCell::new(Some(ProjectCache::new(old_projects, HashMap::new())));
|
|
||||||
|
|
||||||
let loader_called = RefCell::new(false);
|
|
||||||
let scanner_called = RefCell::new(false);
|
|
||||||
let saver_count = RefCell::new(0);
|
|
||||||
|
|
||||||
let result = get_projects_internal(
|
|
||||||
&config,
|
&config,
|
||||||
false,
|
false,
|
||||||
&|| {
|
&|| Ok(cache.borrow_mut().take()),
|
||||||
*loader_called.borrow_mut() = true;
|
&|_| panic!("saver must not be called in foreground"),
|
||||||
// Take the cache out of the RefCell
|
&|_| panic!("scanner must not be called when cache is fresh"),
|
||||||
Ok(old_cache.borrow_mut().take())
|
&|| *spawner_called.borrow_mut() = true,
|
||||||
},
|
|
||||||
&|_| {
|
|
||||||
*saver_count.borrow_mut() += 1;
|
|
||||||
Ok(())
|
|
||||||
},
|
|
||||||
&|_| {
|
|
||||||
*scanner_called.borrow_mut() = true;
|
|
||||||
Ok((new_projects.clone(), new_fingerprints.clone()))
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
assert!(loader_called.into_inner());
|
assert_eq!(result.unwrap(), cached);
|
||||||
assert!(scanner_called.into_inner());
|
assert!(
|
||||||
assert_eq!(*saver_count.borrow(), 1);
|
!spawner_called.into_inner(),
|
||||||
assert_eq!(result.unwrap(), new_projects);
|
"fresh cache should not trigger background refresh"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_use_cached_projects_when_nothing_changed() {
|
fn should_return_stale_cache_immediately_and_spawn_background_refresh() {
|
||||||
let config = create_test_config(true);
|
let config = make_config(true, 24);
|
||||||
let cached_projects = vec![
|
let cached = vec![PathBuf::from("/cached/project")];
|
||||||
PathBuf::from("/nonexistent/project1"),
|
let cache = RefCell::new(Some(stale_cache(cached.clone())));
|
||||||
PathBuf::from("/nonexistent/project2"),
|
let spawner_called = RefCell::new(false);
|
||||||
];
|
|
||||||
// Use a path that doesn't exist - validate_and_update will skip rescan
|
|
||||||
// because it can't check mtime of non-existent directory
|
|
||||||
let cached_fingerprints =
|
|
||||||
HashMap::from([(PathBuf::from("/definitely_nonexistent_path_xyz"), 12345u64)]);
|
|
||||||
|
|
||||||
// Use RefCell<Option<>> to allow moving into closure multiple times
|
let result = call_internal(
|
||||||
let cache = RefCell::new(Some(ProjectCache::new(
|
|
||||||
cached_projects.clone(),
|
|
||||||
cached_fingerprints,
|
|
||||||
)));
|
|
||||||
|
|
||||||
let loader_called = RefCell::new(false);
|
|
||||||
let scanner_called = RefCell::new(false);
|
|
||||||
let saver_count = RefCell::new(0);
|
|
||||||
|
|
||||||
let result = get_projects_internal(
|
|
||||||
&config,
|
&config,
|
||||||
false,
|
false,
|
||||||
&|| {
|
&|| Ok(cache.borrow_mut().take()),
|
||||||
*loader_called.borrow_mut() = true;
|
&|_| panic!("saver must not be called in foreground"),
|
||||||
// Take the cache out of the RefCell
|
&|_| panic!("scanner must not be called in foreground"),
|
||||||
Ok(cache.borrow_mut().take())
|
&|| *spawner_called.borrow_mut() = true,
|
||||||
},
|
|
||||||
&|_| {
|
|
||||||
*saver_count.borrow_mut() += 1;
|
|
||||||
Ok(())
|
|
||||||
},
|
|
||||||
&|_| {
|
|
||||||
*scanner_called.borrow_mut() = true;
|
|
||||||
panic!("should not do full scan when cache is valid")
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
assert!(loader_called.into_inner());
|
assert_eq!(result.unwrap(), cached);
|
||||||
// Note: When the directory in dir_mtimes doesn't exist, validate_and_update
|
assert!(
|
||||||
// treats it as "changed" and removes projects under that path.
|
spawner_called.into_inner(),
|
||||||
// This test verifies the flow completes - the specific behavior of
|
"stale cache must trigger background refresh"
|
||||||
// validate_and_update is tested separately in cache.rs
|
);
|
||||||
let result_projects = result.unwrap();
|
|
||||||
// Projects were removed because the tracked directory doesn't exist
|
|
||||||
assert!(result_projects.is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_update_incrementally_when_cache_changed() {
|
fn should_spawn_background_refresh_when_cache_has_no_fingerprints() {
|
||||||
let config = create_test_config(true);
|
let config = make_config(true, 24);
|
||||||
let initial_projects = vec![PathBuf::from("/nonexistent/project1")];
|
let cached = vec![PathBuf::from("/old/project")];
|
||||||
// Use a path that doesn't exist - validate_and_update will treat missing
|
// Old cache format: no dir_mtimes
|
||||||
// directory as a change (unwrap_or(true) in the mtime check)
|
let old_cache = RefCell::new(Some(ProjectCache::new(cached.clone(), HashMap::new())));
|
||||||
let mut dir_mtimes = HashMap::new();
|
let spawner_called = RefCell::new(false);
|
||||||
dir_mtimes.insert(PathBuf::from("/definitely_nonexistent_path_abc"), 0u64);
|
|
||||||
|
|
||||||
// Use RefCell<Option<>> to allow moving into closure multiple times
|
let result = call_internal(
|
||||||
let cache = RefCell::new(Some(ProjectCache::new(initial_projects, dir_mtimes)));
|
|
||||||
|
|
||||||
let loader_called = RefCell::new(false);
|
|
||||||
let saver_called = RefCell::new(false);
|
|
||||||
|
|
||||||
let result = get_projects_internal(
|
|
||||||
&config,
|
&config,
|
||||||
false,
|
false,
|
||||||
&|| {
|
&|| Ok(old_cache.borrow_mut().take()),
|
||||||
*loader_called.borrow_mut() = true;
|
&|_| panic!("saver must not be called in foreground"),
|
||||||
// Take the cache out of the RefCell
|
&|_| panic!("scanner must not be called in foreground"),
|
||||||
Ok(cache.borrow_mut().take())
|
&|| *spawner_called.borrow_mut() = true,
|
||||||
},
|
|
||||||
&|_| {
|
|
||||||
*saver_called.borrow_mut() = true;
|
|
||||||
Ok(())
|
|
||||||
},
|
|
||||||
&|_| panic!("full scan should not happen with incremental update"),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// validate_and_update is called internally. Since the directory doesn't exist,
|
|
||||||
// it treats it as "changed" and will try to rescan using scan_from_root.
|
|
||||||
// We verify the flow completes without panicking.
|
|
||||||
|
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
assert!(loader_called.into_inner());
|
assert_eq!(result.unwrap(), cached);
|
||||||
// Note: The saver may or may not be called depending on whether
|
assert!(
|
||||||
// validate_and_update detects changes (missing dir = change)
|
spawner_called.into_inner(),
|
||||||
|
"old cache format must trigger background refresh"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/main.rs
41
src/main.rs
@ -8,8 +8,8 @@ use tmuxido::deps::ensure_dependencies;
|
|||||||
use tmuxido::self_update;
|
use tmuxido::self_update;
|
||||||
use tmuxido::update_check;
|
use tmuxido::update_check;
|
||||||
use tmuxido::{
|
use tmuxido::{
|
||||||
get_projects, launch_tmux_session, setup_desktop_integration_wizard, setup_shortcut_wizard,
|
get_projects, launch_tmux_session, refresh_cache, setup_desktop_integration_wizard,
|
||||||
show_cache_status,
|
setup_shortcut_wizard, show_cache_status,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@ -41,6 +41,10 @@ struct Args {
|
|||||||
/// Install the .desktop entry and icon for app launcher integration
|
/// Install the .desktop entry and icon for app launcher integration
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
setup_desktop_shortcut: bool,
|
setup_desktop_shortcut: bool,
|
||||||
|
|
||||||
|
/// Internal: rebuild cache in background (used by stale-while-revalidate)
|
||||||
|
#[arg(long, hide = true)]
|
||||||
|
background_refresh: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@ -51,6 +55,13 @@ fn main() -> Result<()> {
|
|||||||
return self_update::self_update();
|
return self_update::self_update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle background cache refresh (spawned internally by stale-while-revalidate).
|
||||||
|
// Runs early to avoid unnecessary dependency checks and config prompts.
|
||||||
|
if args.background_refresh {
|
||||||
|
let config = Config::load()?;
|
||||||
|
return refresh_cache(&config);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle standalone shortcut setup
|
// Handle standalone shortcut setup
|
||||||
if args.setup_shortcut {
|
if args.setup_shortcut {
|
||||||
return setup_shortcut_wizard();
|
return setup_shortcut_wizard();
|
||||||
@ -105,8 +116,34 @@ fn main() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn readme_preview_command() -> String {
|
||||||
|
let glow_available = Command::new("sh")
|
||||||
|
.args(["-c", "command -v glow"])
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
// CLICOLOR_FORCE=1 tells termenv (used by glow/glamour) to enable ANSI
|
||||||
|
// colors even when stdout is not a TTY (fzf preview runs in a pipe).
|
||||||
|
// Without it, glow falls back to bold-only "notty" style with no colors.
|
||||||
|
// Use `sh -c '...' -- {}` so the command runs in POSIX sh regardless of
|
||||||
|
// the user's $SHELL (fish, zsh, bash, etc.).
|
||||||
|
let viewer_cmd = if glow_available {
|
||||||
|
"CLICOLOR_FORCE=1 glow -s dark"
|
||||||
|
} else {
|
||||||
|
"cat"
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
r#"sh -c 'readme="$1/README.md"; [ -f "$readme" ] && {viewer_cmd} "$readme" || echo "No README.md"' -- {{}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> {
|
fn select_project_with_fzf(projects: &[PathBuf]) -> Result<PathBuf> {
|
||||||
|
let preview_cmd = readme_preview_command();
|
||||||
let mut child = Command::new("fzf")
|
let mut child = Command::new("fzf")
|
||||||
|
.arg("--preview")
|
||||||
|
.arg(&preview_cmd)
|
||||||
|
.arg("--preview-window")
|
||||||
|
.arg("right:40%")
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
|
|||||||
131
src/ui.rs
131
src/ui.rs
@ -613,6 +613,91 @@ pub fn render_desktop_integration_success(result: &crate::shortcut::DesktopInsta
|
|||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Choices offered when no configuration file is found on first run
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub enum SetupChoice {
|
||||||
|
Wizard,
|
||||||
|
Default,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the first-run setup choice input.
|
||||||
|
///
|
||||||
|
/// Accepts:
|
||||||
|
/// - `""`, `" "`, `"1"`, `"w"`, `"wizard"` → `Wizard` (default)
|
||||||
|
/// - `"2"`, `"d"`, `"default"` → `Default`
|
||||||
|
/// - anything else falls back to `Wizard`
|
||||||
|
pub fn parse_setup_choice_input(input: &str) -> SetupChoice {
|
||||||
|
match input.trim().to_lowercase().as_str() {
|
||||||
|
"2" | "d" | "default" => SetupChoice::Default,
|
||||||
|
_ => SetupChoice::Wizard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the first-run prompt asking whether to run the wizard or use defaults.
|
||||||
|
/// Returns the raw user input.
|
||||||
|
pub fn render_setup_choice_prompt() -> Result<String> {
|
||||||
|
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||||||
|
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||||||
|
let title_style = Style::new().bold(true).foreground(color_blue());
|
||||||
|
let option_style = Style::new().foreground(color_purple());
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("{}", title_style.render(" 🚀 Welcome to tmuxido!"));
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
hint_style.render(" No configuration found. How would you like to get started?")
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
option_style
|
||||||
|
.render("1. Run setup wizard — configure paths, cache, and windows interactively")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
option_style.render("2. Use default config — start immediately with sensible defaults")
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
print!(" {} ", prompt_style.render("❯ Choose (1/2):"));
|
||||||
|
io::stdout().flush().context("Failed to flush stdout")?;
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
io::stdin()
|
||||||
|
.read_line(&mut input)
|
||||||
|
.context("Failed to read input")?;
|
||||||
|
|
||||||
|
Ok(input.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a confirmation message after the default config is written.
|
||||||
|
pub fn render_default_config_saved(config_path: &str) {
|
||||||
|
let success_style = Style::new().bold(true).foreground(color_green());
|
||||||
|
let info_style = Style::new().foreground(color_dark_gray());
|
||||||
|
let path_style = Style::new().bold(true).foreground(color_blue());
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
success_style.render(" ✅ Default configuration saved!")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
info_style.render("Config:"),
|
||||||
|
path_style.render(config_path)
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
info_style.render(
|
||||||
|
" ⚙️ Edit it anytime to customise your setup, or run 'tmuxido --setup-shortcut'."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/// Parse comma-separated list into Vec<String>, filtering empty items
|
/// Parse comma-separated list into Vec<String>, filtering empty items
|
||||||
pub fn parse_comma_separated_list(input: &str) -> Vec<String> {
|
pub fn parse_comma_separated_list(input: &str) -> Vec<String> {
|
||||||
input
|
input
|
||||||
@ -882,4 +967,50 @@ mod tests {
|
|||||||
};
|
};
|
||||||
render_desktop_integration_success(&result);
|
render_desktop_integration_success(&result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- SetupChoice / parse_setup_choice_input ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_wizard_for_empty_input() {
|
||||||
|
assert_eq!(parse_setup_choice_input(""), SetupChoice::Wizard);
|
||||||
|
assert_eq!(parse_setup_choice_input(" "), SetupChoice::Wizard);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_wizard_for_option_1() {
|
||||||
|
assert_eq!(parse_setup_choice_input("1"), SetupChoice::Wizard);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_wizard_for_w_aliases() {
|
||||||
|
assert_eq!(parse_setup_choice_input("w"), SetupChoice::Wizard);
|
||||||
|
assert_eq!(parse_setup_choice_input("wizard"), SetupChoice::Wizard);
|
||||||
|
assert_eq!(parse_setup_choice_input("W"), SetupChoice::Wizard);
|
||||||
|
assert_eq!(parse_setup_choice_input("WIZARD"), SetupChoice::Wizard);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_default_for_option_2() {
|
||||||
|
assert_eq!(parse_setup_choice_input("2"), SetupChoice::Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_default_for_d_aliases() {
|
||||||
|
assert_eq!(parse_setup_choice_input("d"), SetupChoice::Default);
|
||||||
|
assert_eq!(parse_setup_choice_input("default"), SetupChoice::Default);
|
||||||
|
assert_eq!(parse_setup_choice_input("D"), SetupChoice::Default);
|
||||||
|
assert_eq!(parse_setup_choice_input("DEFAULT"), SetupChoice::Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_return_wizard_for_unknown_input() {
|
||||||
|
assert_eq!(parse_setup_choice_input("banana"), SetupChoice::Wizard);
|
||||||
|
assert_eq!(parse_setup_choice_input("3"), SetupChoice::Wizard);
|
||||||
|
assert_eq!(parse_setup_choice_input("yes"), SetupChoice::Wizard);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_default_config_saved_should_not_panic() {
|
||||||
|
render_default_config_saved("/home/user/.config/tmuxido/tmuxido.toml");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user