test: add unit tests for cache, session and config modules
cache.rs: make minimal_roots pub(crate); add 8 tests covering the minimal_roots helper (empty input, single root, nested vs sibling dirs) and validate_and_update (stale project removal, no-change short-circuit, mtime-triggered rescan, legacy empty dir_mtimes). session.rs: make session_name pub(crate); add 5 tests covering session name sanitisation (dots→underscores, spaces→dashes, fallback for root path) and TOML parsing for Window and SessionConfig with layout. config.rs: add 3 tests covering serde defaults when optional fields are absent, full config parsing and invalid TOML rejection. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9422f01749
commit
cf575daa69
126
src/cache.rs
126
src/cache.rs
@ -23,14 +23,20 @@ fn now_secs() -> u64 {
|
||||
}
|
||||
|
||||
fn mtime_secs(time: SystemTime) -> u64 {
|
||||
time.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
|
||||
time.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// Retorna o subconjunto mínimo de diretórios: aqueles que não têm nenhum
|
||||
/// ancestral também na lista. Evita rescanear a mesma subárvore duas vezes.
|
||||
fn minimal_roots(dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
pub(crate) fn minimal_roots(dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
dirs.iter()
|
||||
.filter(|dir| !dirs.iter().any(|other| other != *dir && dir.starts_with(other)))
|
||||
.filter(|dir| {
|
||||
!dirs
|
||||
.iter()
|
||||
.any(|other| other != *dir && dir.starts_with(other))
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
@ -49,8 +55,9 @@ impl ProjectCache {
|
||||
.context("Could not determine cache directory")?
|
||||
.join("tmuxido");
|
||||
|
||||
fs::create_dir_all(&cache_dir)
|
||||
.with_context(|| format!("Failed to create cache directory: {}", cache_dir.display()))?;
|
||||
fs::create_dir_all(&cache_dir).with_context(|| {
|
||||
format!("Failed to create cache directory: {}", cache_dir.display())
|
||||
})?;
|
||||
|
||||
Ok(cache_dir.join("projects.json"))
|
||||
}
|
||||
@ -74,8 +81,7 @@ impl ProjectCache {
|
||||
pub fn save(&self) -> Result<()> {
|
||||
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)
|
||||
.with_context(|| format!("Failed to write cache file: {}", cache_path.display()))?;
|
||||
@ -91,6 +97,7 @@ impl ProjectCache {
|
||||
///
|
||||
/// Retorna `true` se o cache foi modificado.
|
||||
/// Retorna `false` com `dir_mtimes` vazio (cache antigo) — chamador deve fazer rescan completo.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn validate_and_update(
|
||||
&mut self,
|
||||
scan_fn: &dyn Fn(&Path) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
|
||||
@ -154,3 +161,108 @@ impl ProjectCache {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,18 +78,24 @@ impl Config {
|
||||
let config_path = Self::config_path()?;
|
||||
|
||||
if !config_path.exists() {
|
||||
let config_dir = config_path.parent()
|
||||
let config_dir = config_path
|
||||
.parent()
|
||||
.context("Could not get parent directory")?;
|
||||
|
||||
fs::create_dir_all(config_dir)
|
||||
.with_context(|| format!("Failed to create config directory: {}", config_dir.display()))?;
|
||||
fs::create_dir_all(config_dir).with_context(|| {
|
||||
format!(
|
||||
"Failed to create config directory: {}",
|
||||
config_dir.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let default_config = Self::default_config();
|
||||
let toml_string = toml::to_string_pretty(&default_config)
|
||||
.context("Failed to serialize default config")?;
|
||||
|
||||
fs::write(&config_path, toml_string)
|
||||
.with_context(|| format!("Failed to write config file: {}", config_path.display()))?;
|
||||
fs::write(&config_path, toml_string).with_context(|| {
|
||||
format!("Failed to write config file: {}", config_path.display())
|
||||
})?;
|
||||
|
||||
eprintln!("Created default config at: {}", config_path.display());
|
||||
}
|
||||
@ -112,5 +118,39 @@ impl 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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,15 +30,16 @@ impl SessionConfig {
|
||||
let content = fs::read_to_string(&config_path)
|
||||
.with_context(|| format!("Failed to read session config: {}", config_path.display()))?;
|
||||
|
||||
let config: SessionConfig = toml::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse session config: {}", config_path.display()))?;
|
||||
let config: SessionConfig = toml::from_str(&content).with_context(|| {
|
||||
format!("Failed to parse session config: {}", config_path.display())
|
||||
})?;
|
||||
|
||||
Ok(Some(config))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TmuxSession {
|
||||
session_name: String,
|
||||
pub(crate) session_name: String,
|
||||
project_path: String,
|
||||
base_index: usize,
|
||||
}
|
||||
@ -67,12 +68,12 @@ impl TmuxSession {
|
||||
.args(["show-options", "-gv", "base-index"])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = output {
|
||||
if output.status.success() {
|
||||
let index_str = String::from_utf8_lossy(&output.stdout);
|
||||
if let Ok(index) = index_str.trim().parse::<usize>() {
|
||||
return index;
|
||||
}
|
||||
if let Ok(output) = output
|
||||
&& output.status.success()
|
||||
{
|
||||
let index_str = String::from_utf8_lossy(&output.stdout);
|
||||
if let Ok(index) = index_str.trim().parse::<usize>() {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
@ -206,7 +207,11 @@ impl TmuxSession {
|
||||
|
||||
// Select the first window
|
||||
Command::new("tmux")
|
||||
.args(["select-window", "-t", &format!("{}:{}", self.session_name, self.base_index)])
|
||||
.args([
|
||||
"select-window",
|
||||
"-t",
|
||||
&format!("{}:{}", self.session_name, self.base_index),
|
||||
])
|
||||
.status()
|
||||
.context("Failed to select first window")?;
|
||||
|
||||
@ -221,13 +226,7 @@ impl TmuxSession {
|
||||
if pane_index > 0 {
|
||||
// Create new pane by splitting
|
||||
Command::new("tmux")
|
||||
.args([
|
||||
"split-window",
|
||||
"-t",
|
||||
&target,
|
||||
"-c",
|
||||
&self.project_path,
|
||||
])
|
||||
.args(["split-window", "-t", &target, "-c", &self.project_path])
|
||||
.status()
|
||||
.context("Failed to split pane")?;
|
||||
}
|
||||
@ -236,13 +235,7 @@ impl TmuxSession {
|
||||
if !command.is_empty() {
|
||||
let pane_target = format!("{}:{}.{}", self.session_name, window_index, pane_index);
|
||||
Command::new("tmux")
|
||||
.args([
|
||||
"send-keys",
|
||||
"-t",
|
||||
&pane_target,
|
||||
command,
|
||||
"Enter",
|
||||
])
|
||||
.args(["send-keys", "-t", &pane_target, command, "Enter"])
|
||||
.status()
|
||||
.context("Failed to send keys to pane")?;
|
||||
}
|
||||
@ -265,3 +258,52 @@ impl TmuxSession {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user