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:
Cinco Euzebio 2026-02-28 20:15:42 -03:00
parent 9422f01749
commit cf575daa69
3 changed files with 231 additions and 37 deletions

View File

@ -23,14 +23,20 @@ fn now_secs() -> u64 {
} }
fn mtime_secs(time: SystemTime) -> u64 { fn mtime_secs(time: SystemTime) -> u64 {
time.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() time.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
} }
/// Retorna o subconjunto mínimo de diretórios: aqueles que não têm nenhum /// Retorna o subconjunto mínimo de diretórios: aqueles que não têm nenhum
/// ancestral também na lista. Evita rescanear a mesma subárvore duas vezes. /// ancestral também na lista. Evita rescanear a mesma subárvore duas vezes.
fn minimal_roots(dirs: &[PathBuf]) -> Vec<PathBuf> { pub(crate) fn minimal_roots(dirs: &[PathBuf]) -> Vec<PathBuf> {
dirs.iter() dirs.iter()
.filter(|dir| !dirs.iter().any(|other| other != *dir && dir.starts_with(other))) .filter(|dir| {
!dirs
.iter()
.any(|other| other != *dir && dir.starts_with(other))
})
.cloned() .cloned()
.collect() .collect()
} }
@ -49,8 +55,9 @@ impl ProjectCache {
.context("Could not determine cache directory")? .context("Could not determine cache directory")?
.join("tmuxido"); .join("tmuxido");
fs::create_dir_all(&cache_dir) fs::create_dir_all(&cache_dir).with_context(|| {
.with_context(|| format!("Failed to create cache directory: {}", cache_dir.display()))?; format!("Failed to create cache directory: {}", cache_dir.display())
})?;
Ok(cache_dir.join("projects.json")) Ok(cache_dir.join("projects.json"))
} }
@ -74,8 +81,7 @@ impl ProjectCache {
pub fn save(&self) -> Result<()> { pub fn save(&self) -> Result<()> {
let cache_path = Self::cache_path()?; let cache_path = Self::cache_path()?;
let content = serde_json::to_string_pretty(self) let content = serde_json::to_string_pretty(self).context("Failed to serialize cache")?;
.context("Failed to serialize cache")?;
fs::write(&cache_path, content) fs::write(&cache_path, content)
.with_context(|| format!("Failed to write cache file: {}", cache_path.display()))?; .with_context(|| format!("Failed to write cache file: {}", cache_path.display()))?;
@ -91,6 +97,7 @@ impl ProjectCache {
/// ///
/// Retorna `true` se o cache foi modificado. /// Retorna `true` se o cache foi modificado.
/// Retorna `false` com `dir_mtimes` vazio (cache antigo) — chamador deve fazer rescan completo. /// Retorna `false` com `dir_mtimes` vazio (cache antigo) — chamador deve fazer rescan completo.
#[allow(clippy::type_complexity)]
pub fn validate_and_update( pub fn validate_and_update(
&mut self, &mut self,
scan_fn: &dyn Fn(&Path) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>, scan_fn: &dyn Fn(&Path) -> Result<(Vec<PathBuf>, HashMap<PathBuf, u64>)>,
@ -154,3 +161,108 @@ impl ProjectCache {
now_secs().saturating_sub(self.last_updated) now_secs().saturating_sub(self.last_updated)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn should_return_empty_when_input_is_empty() {
let result = minimal_roots(&[]);
assert!(result.is_empty());
}
#[test]
fn should_return_single_dir_as_root() {
let dirs = vec![PathBuf::from("/home/user/projects")];
let result = minimal_roots(&dirs);
assert_eq!(result, dirs);
}
#[test]
fn should_exclude_nested_dirs_when_parent_is_present() {
let dirs = vec![
PathBuf::from("/home/user"),
PathBuf::from("/home/user/projects"),
];
let result = minimal_roots(&dirs);
assert_eq!(result.len(), 1);
assert!(result.contains(&PathBuf::from("/home/user")));
}
#[test]
fn should_keep_sibling_dirs_that_are_not_nested() {
let dirs = vec![
PathBuf::from("/home/user/projects"),
PathBuf::from("/home/user/work"),
];
let result = minimal_roots(&dirs);
assert_eq!(result.len(), 2);
}
#[test]
fn should_remove_stale_projects_when_git_dir_missing() {
let dir = tempdir().unwrap();
let project = dir.path().join("myproject");
fs::create_dir_all(project.join(".git")).unwrap();
let mut cache = ProjectCache::new(vec![project.clone()], HashMap::new());
assert_eq!(cache.projects.len(), 1);
fs::remove_dir_all(project.join(".git")).unwrap();
let result = cache.validate_and_update(&|_| Ok((vec![], HashMap::new())));
assert_eq!(result.unwrap(), true);
assert!(cache.projects.is_empty());
}
#[test]
fn should_return_false_when_nothing_changed() {
let dir = tempdir().unwrap();
let actual_mtime = fs::metadata(dir.path())
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let mut dir_mtimes = HashMap::new();
dir_mtimes.insert(dir.path().to_path_buf(), actual_mtime);
let mut cache = ProjectCache::new(vec![], dir_mtimes);
let result = cache.validate_and_update(&|_| Ok((vec![], HashMap::new())));
assert_eq!(result.unwrap(), false);
}
#[test]
fn should_rescan_dirs_when_mtime_changed() {
let dir = tempdir().unwrap();
let tracked = dir.path().to_path_buf();
// Store mtime 0 — guaranteed to differ from the actual mtime
let mut dir_mtimes = HashMap::new();
dir_mtimes.insert(tracked, 0u64);
let mut cache = ProjectCache::new(vec![], dir_mtimes);
let new_project = dir.path().join("discovered");
let scan_called = std::cell::Cell::new(false);
let result = cache.validate_and_update(&|_root| {
scan_called.set(true);
Ok((vec![new_project.clone()], HashMap::new()))
});
assert_eq!(result.unwrap(), true);
assert!(scan_called.get());
assert!(cache.projects.contains(&new_project));
}
#[test]
fn should_return_false_when_dir_mtimes_empty() {
let mut cache = ProjectCache::new(vec![], HashMap::new());
let result = cache.validate_and_update(&|_| Ok((vec![], HashMap::new())));
assert_eq!(result.unwrap(), false);
}
}

View File

@ -78,18 +78,24 @@ impl Config {
let config_path = Self::config_path()?; let config_path = Self::config_path()?;
if !config_path.exists() { if !config_path.exists() {
let config_dir = config_path.parent() let config_dir = config_path
.parent()
.context("Could not get parent directory")?; .context("Could not get parent directory")?;
fs::create_dir_all(config_dir) fs::create_dir_all(config_dir).with_context(|| {
.with_context(|| format!("Failed to create config directory: {}", config_dir.display()))?; format!(
"Failed to create config directory: {}",
config_dir.display()
)
})?;
let default_config = Self::default_config(); let default_config = Self::default_config();
let toml_string = toml::to_string_pretty(&default_config) let toml_string = toml::to_string_pretty(&default_config)
.context("Failed to serialize default config")?; .context("Failed to serialize default config")?;
fs::write(&config_path, toml_string) fs::write(&config_path, toml_string).with_context(|| {
.with_context(|| format!("Failed to write config file: {}", config_path.display()))?; format!("Failed to write config file: {}", config_path.display())
})?;
eprintln!("Created default config at: {}", config_path.display()); eprintln!("Created default config at: {}", config_path.display());
} }
@ -112,5 +118,39 @@ impl Config {
default_session: default_session_config(), default_session: default_session_config(),
} }
} }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_use_defaults_when_optional_fields_missing() {
let toml_str = r#"paths = ["/home/user/projects"]"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.max_depth, 5);
assert!(config.cache_enabled);
assert_eq!(config.cache_ttl_hours, 24);
}
#[test]
fn should_parse_full_config_correctly() {
let toml_str = r#"
paths = ["/foo", "/bar"]
max_depth = 3
cache_enabled = false
cache_ttl_hours = 12
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.paths, vec!["/foo", "/bar"]);
assert_eq!(config.max_depth, 3);
assert!(!config.cache_enabled);
assert_eq!(config.cache_ttl_hours, 12);
}
#[test]
fn should_reject_invalid_toml() {
let result: Result<Config, _> = toml::from_str("not valid toml ]][[");
assert!(result.is_err());
}
} }

View File

@ -30,15 +30,16 @@ impl SessionConfig {
let content = fs::read_to_string(&config_path) let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read session config: {}", config_path.display()))?; .with_context(|| format!("Failed to read session config: {}", config_path.display()))?;
let config: SessionConfig = toml::from_str(&content) let config: SessionConfig = toml::from_str(&content).with_context(|| {
.with_context(|| format!("Failed to parse session config: {}", config_path.display()))?; format!("Failed to parse session config: {}", config_path.display())
})?;
Ok(Some(config)) Ok(Some(config))
} }
} }
pub struct TmuxSession { pub struct TmuxSession {
session_name: String, pub(crate) session_name: String,
project_path: String, project_path: String,
base_index: usize, base_index: usize,
} }
@ -67,12 +68,12 @@ impl TmuxSession {
.args(["show-options", "-gv", "base-index"]) .args(["show-options", "-gv", "base-index"])
.output(); .output();
if let Ok(output) = output { if let Ok(output) = output
if output.status.success() { && output.status.success()
let index_str = String::from_utf8_lossy(&output.stdout); {
if let Ok(index) = index_str.trim().parse::<usize>() { let index_str = String::from_utf8_lossy(&output.stdout);
return index; if let Ok(index) = index_str.trim().parse::<usize>() {
} return index;
} }
} }
@ -206,7 +207,11 @@ impl TmuxSession {
// Select the first window // Select the first window
Command::new("tmux") Command::new("tmux")
.args(["select-window", "-t", &format!("{}:{}", self.session_name, self.base_index)]) .args([
"select-window",
"-t",
&format!("{}:{}", self.session_name, self.base_index),
])
.status() .status()
.context("Failed to select first window")?; .context("Failed to select first window")?;
@ -221,13 +226,7 @@ impl TmuxSession {
if pane_index > 0 { if pane_index > 0 {
// Create new pane by splitting // Create new pane by splitting
Command::new("tmux") Command::new("tmux")
.args([ .args(["split-window", "-t", &target, "-c", &self.project_path])
"split-window",
"-t",
&target,
"-c",
&self.project_path,
])
.status() .status()
.context("Failed to split pane")?; .context("Failed to split pane")?;
} }
@ -236,13 +235,7 @@ impl TmuxSession {
if !command.is_empty() { if !command.is_empty() {
let pane_target = format!("{}:{}.{}", self.session_name, window_index, pane_index); let pane_target = format!("{}:{}.{}", self.session_name, window_index, pane_index);
Command::new("tmux") Command::new("tmux")
.args([ .args(["send-keys", "-t", &pane_target, command, "Enter"])
"send-keys",
"-t",
&pane_target,
command,
"Enter",
])
.status() .status()
.context("Failed to send keys to pane")?; .context("Failed to send keys to pane")?;
} }
@ -265,3 +258,52 @@ impl TmuxSession {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn should_replace_dots_with_underscores_in_session_name() {
let session = TmuxSession::new(Path::new("/home/user/my.project"));
assert_eq!(session.session_name, "my_project");
}
#[test]
fn should_replace_spaces_with_dashes_in_session_name() {
let session = TmuxSession::new(Path::new("/home/user/my project"));
assert_eq!(session.session_name, "my-project");
}
#[test]
fn should_use_project_fallback_when_path_has_no_filename() {
let session = TmuxSession::new(Path::new("/"));
assert_eq!(session.session_name, "project");
}
#[test]
fn should_parse_window_from_toml() {
let toml_str = r#"
[[windows]]
name = "editor"
panes = ["nvim ."]
"#;
let config: SessionConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.windows[0].name, "editor");
assert_eq!(config.windows[0].panes, vec!["nvim ."]);
}
#[test]
fn should_parse_session_config_with_layout() {
let toml_str = r#"
[[windows]]
name = "main"
layout = "tiled"
panes = ["vim", "bash"]
"#;
let config: SessionConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.windows[0].layout, Some("tiled".to_string()));
assert_eq!(config.windows[0].panes.len(), 2);
}
}