tmuxido/src/lib.rs
cinco euzebio d7246298b1 feat: add desktop integration wizard and --create-desktop-shortcut
- install_desktop_integration_to() writes .desktop and downloads icon
- setup_desktop_integration_wizard() prompts and installs to XDG paths
- --create-desktop-shortcut flag to re-run at any time
- First-run wizard now also offers desktop integration after shortcut setup
- 164 tests passing
2026-03-01 19:40:15 -03:00

438 lines
14 KiB
Rust

pub mod cache;
pub mod config;
pub mod deps;
pub mod self_update;
pub mod session;
pub mod shortcut;
pub mod ui;
pub mod update_check;
use anyhow::Result;
use cache::ProjectCache;
use config::Config;
use session::{SessionConfig, TmuxSession};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use walkdir::WalkDir;
pub fn setup_shortcut_wizard() -> Result<()> {
shortcut::setup_shortcut_wizard()
}
pub fn setup_desktop_integration_wizard() -> Result<()> {
shortcut::setup_desktop_integration_wizard()
}
pub fn show_cache_status(config: &Config) -> Result<()> {
if !config.cache_enabled {
println!("Cache is disabled in configuration");
return Ok(());
}
if let Some(cache) = ProjectCache::load()? {
let age_seconds = cache.age_in_seconds();
let age_hours = age_seconds / 3600;
let age_minutes = (age_seconds % 3600) / 60;
println!("Cache status:");
println!(" Location: {}", ProjectCache::cache_path()?.display());
println!(" Projects cached: {}", cache.projects.len());
println!(" Directories tracked: {}", cache.dir_mtimes.len());
println!(" Last updated: {}h {}m ago", age_hours, age_minutes);
} else {
println!("No cache found");
println!(" Run without --cache-status to create it");
}
Ok(())
}
pub fn get_projects(config: &Config, force_refresh: bool) -> Result<Vec<PathBuf>> {
get_projects_internal(
config,
force_refresh,
&ProjectCache::load,
&|cache| cache.save(),
&scan_all_roots,
)
}
#[allow(clippy::type_complexity)]
fn get_projects_internal(
config: &Config,
force_refresh: bool,
cache_loader: &dyn Fn() -> Result<Option<ProjectCache>>,
cache_saver: &dyn Fn(&ProjectCache) -> Result<()>,
scanner: &dyn Fn(&Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
) -> Result<Vec<PathBuf>> {
if !config.cache_enabled || force_refresh {
let (projects, fingerprints) = scanner(config)?;
let cache = ProjectCache::new(projects.clone(), fingerprints);
cache_saver(&cache)?;
eprintln!("Cache updated with {} projects", projects.len());
return Ok(projects);
}
if let Some(mut cache) = cache_loader()? {
// Cache no formato antigo (sem dir_mtimes) → atualizar com rescan completo
if cache.dir_mtimes.is_empty() {
eprintln!("Upgrading cache, scanning for projects...");
let (projects, fingerprints) = scanner(config)?;
let new_cache = ProjectCache::new(projects.clone(), fingerprints);
cache_saver(&new_cache)?;
eprintln!("Cache updated with {} projects", projects.len());
return Ok(projects);
}
let changed = cache.validate_and_update(&|root| scan_from_root(root, config))?;
if changed {
cache_saver(&cache)?;
eprintln!(
"Cache updated incrementally ({} projects)",
cache.projects.len()
);
} else {
eprintln!("Using cached projects ({} projects)", cache.projects.len());
}
return Ok(cache.projects);
}
// Sem cache ainda — scan completo inicial
eprintln!("No cache found, scanning for projects...");
let (projects, fingerprints) = scanner(config)?;
let cache = ProjectCache::new(projects.clone(), fingerprints);
cache_saver(&cache)?;
eprintln!("Cache updated with {} projects", projects.len());
Ok(projects)
}
pub fn scan_all_roots(config: &Config) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)> {
let mut all_projects = Vec::new();
let mut all_fingerprints = HashMap::new();
for path_str in &config.paths {
let path = PathBuf::from(shellexpand::tilde(path_str).to_string());
if !path.exists() {
eprintln!("Warning: Path does not exist: {}", path.display());
continue;
}
eprintln!("Scanning: {}", path.display());
let (projects, fingerprints) = scan_from_root(&path, config)?;
all_projects.extend(projects);
all_fingerprints.extend(fingerprints);
}
all_projects.sort();
all_projects.dedup();
Ok((all_projects, all_fingerprints))
}
pub fn scan_from_root(
root: &Path,
config: &Config,
) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)> {
let mut projects = Vec::new();
let mut fingerprints = HashMap::new();
for entry in WalkDir::new(root)
.max_depth(config.max_depth)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
e.file_name()
.to_str()
.map(|s| !s.starts_with('.') || s == ".git")
.unwrap_or(false)
})
{
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if entry.file_type().is_dir() {
if entry.file_name() == ".git" {
// Projeto encontrado
if let Some(parent) = entry.path().parent() {
projects.push(parent.to_path_buf());
}
} else {
// Registrar mtime para detecção de mudanças futuras
if let Ok(metadata) = entry.metadata()
&& let Ok(modified) = metadata.modified()
{
let mtime = modified
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
fingerprints.insert(entry.path().to_path_buf(), mtime);
}
}
}
}
Ok((projects, fingerprints))
}
pub fn launch_tmux_session(selected: &Path, config: &Config) -> Result<()> {
// Try to load project-specific config, fallback to global default
let session_config = SessionConfig::load_from_project(selected)?
.unwrap_or_else(|| config.default_session.clone());
// Create tmux session
let tmux_session = TmuxSession::new(selected);
tmux_session.create(&session_config)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
fn create_test_config(cache_enabled: bool) -> Config {
Config {
paths: vec!["/tmp/test".to_string()],
max_depth: 3,
cache_enabled,
cache_ttl_hours: 24,
update_check_interval_hours: 24,
default_session: session::SessionConfig { windows: vec![] },
}
}
#[test]
fn should_scan_when_cache_disabled() {
let config = create_test_config(false);
let projects = vec![PathBuf::from("/tmp/test/project1")];
let fingerprints = HashMap::new();
let expected_projects = projects.clone();
let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false);
let result = get_projects_internal(
&config,
false,
&|| panic!("should not load cache when disabled"),
&|_| {
*saver_called.borrow_mut() = true;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
Ok((expected_projects.clone(), fingerprints.clone()))
},
);
assert!(result.is_ok());
assert!(scanner_called.into_inner());
assert!(saver_called.into_inner());
assert_eq!(result.unwrap(), projects);
}
#[test]
fn should_scan_when_force_refresh() {
let config = create_test_config(true);
let projects = vec![PathBuf::from("/tmp/test/project1")];
let fingerprints = HashMap::new();
let expected_projects = projects.clone();
let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false);
let result = get_projects_internal(
&config,
true,
&|| panic!("should not load cache when force refresh"),
&|_| {
*saver_called.borrow_mut() = true;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
Ok((expected_projects.clone(), fingerprints.clone()))
},
);
assert!(result.is_ok());
assert!(scanner_called.into_inner());
assert!(saver_called.into_inner());
assert_eq!(result.unwrap(), projects);
}
#[test]
fn should_do_initial_scan_when_no_cache_exists() {
let config = create_test_config(true);
let projects = vec![PathBuf::from("/tmp/test/project1")];
let fingerprints = HashMap::new();
let expected_projects = projects.clone();
let loader_called = RefCell::new(false);
let scanner_called = RefCell::new(false);
let saver_called = RefCell::new(false);
let result = get_projects_internal(
&config,
false,
&|| {
*loader_called.borrow_mut() = true;
Ok(None)
},
&|_| {
*saver_called.borrow_mut() = true;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
Ok((expected_projects.clone(), fingerprints.clone()))
},
);
assert!(result.is_ok());
assert!(loader_called.into_inner());
assert!(scanner_called.into_inner());
assert!(saver_called.into_inner());
assert_eq!(result.unwrap(), projects);
}
#[test]
fn should_upgrade_old_cache_format() {
let config = create_test_config(true);
let old_projects = vec![PathBuf::from("/old/project")];
let new_projects = vec![
PathBuf::from("/new/project1"),
PathBuf::from("/new/project2"),
];
let new_fingerprints = HashMap::from([(PathBuf::from("/new"), 12345u64)]);
// Use RefCell<Option<>> to allow moving into closure multiple times
let old_cache = RefCell::new(Some(ProjectCache::new(old_projects, HashMap::new())));
let loader_called = RefCell::new(false);
let scanner_called = RefCell::new(false);
let saver_count = RefCell::new(0);
let result = get_projects_internal(
&config,
false,
&|| {
*loader_called.borrow_mut() = true;
// Take the cache out of the RefCell
Ok(old_cache.borrow_mut().take())
},
&|_| {
*saver_count.borrow_mut() += 1;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
Ok((new_projects.clone(), new_fingerprints.clone()))
},
);
assert!(result.is_ok());
assert!(loader_called.into_inner());
assert!(scanner_called.into_inner());
assert_eq!(*saver_count.borrow(), 1);
assert_eq!(result.unwrap(), new_projects);
}
#[test]
fn should_use_cached_projects_when_nothing_changed() {
let config = create_test_config(true);
let cached_projects = vec![
PathBuf::from("/nonexistent/project1"),
PathBuf::from("/nonexistent/project2"),
];
// Use a path that doesn't exist - validate_and_update will skip rescan
// because it can't check mtime of non-existent directory
let cached_fingerprints =
HashMap::from([(PathBuf::from("/definitely_nonexistent_path_xyz"), 12345u64)]);
// Use RefCell<Option<>> to allow moving into closure multiple times
let cache = RefCell::new(Some(ProjectCache::new(
cached_projects.clone(),
cached_fingerprints,
)));
let loader_called = RefCell::new(false);
let scanner_called = RefCell::new(false);
let saver_count = RefCell::new(0);
let result = get_projects_internal(
&config,
false,
&|| {
*loader_called.borrow_mut() = true;
// Take the cache out of the RefCell
Ok(cache.borrow_mut().take())
},
&|_| {
*saver_count.borrow_mut() += 1;
Ok(())
},
&|_| {
*scanner_called.borrow_mut() = true;
panic!("should not do full scan when cache is valid")
},
);
assert!(result.is_ok());
assert!(loader_called.into_inner());
// Note: When the directory in dir_mtimes doesn't exist, validate_and_update
// treats it as "changed" and removes projects under that path.
// This test verifies the flow completes - the specific behavior of
// validate_and_update is tested separately in cache.rs
let result_projects = result.unwrap();
// Projects were removed because the tracked directory doesn't exist
assert!(result_projects.is_empty());
}
#[test]
fn should_update_incrementally_when_cache_changed() {
let config = create_test_config(true);
let initial_projects = vec![PathBuf::from("/nonexistent/project1")];
// Use a path that doesn't exist - validate_and_update will treat missing
// directory as a change (unwrap_or(true) in the mtime check)
let mut dir_mtimes = HashMap::new();
dir_mtimes.insert(PathBuf::from("/definitely_nonexistent_path_abc"), 0u64);
// Use RefCell<Option<>> to allow moving into closure multiple times
let cache = RefCell::new(Some(ProjectCache::new(initial_projects, dir_mtimes)));
let loader_called = RefCell::new(false);
let saver_called = RefCell::new(false);
let result = get_projects_internal(
&config,
false,
&|| {
*loader_called.borrow_mut() = true;
// Take the cache out of the RefCell
Ok(cache.borrow_mut().take())
},
&|_| {
*saver_called.borrow_mut() = true;
Ok(())
},
&|_| panic!("full scan should not happen with incremental update"),
);
// validate_and_update is called internally. Since the directory doesn't exist,
// it treats it as "changed" and will try to rescan using scan_from_root.
// We verify the flow completes without panicking.
assert!(result.is_ok());
assert!(loader_called.into_inner());
// Note: The saver may or may not be called depending on whether
// validate_and_update detects changes (missing dir = change)
}
}