From cf575daa69dcf510f6a4756596c9831517968031 Mon Sep 17 00:00:00 2001 From: cinco euzebio Date: Sat, 28 Feb 2026 20:15:42 -0300 Subject: [PATCH] test: add unit tests for cache, session and config modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/cache.rs | 126 ++++++++++++++++++++++++++++++++++++++++++++++--- src/config.rs | 52 +++++++++++++++++--- src/session.rs | 90 +++++++++++++++++++++++++---------- 3 files changed, 231 insertions(+), 37 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 7d8695e..63dc3f8 100644 --- a/src/cache.rs +++ b/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 { +pub(crate) fn minimal_roots(dirs: &[PathBuf]) -> Vec { 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, HashMap)>, @@ -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); + } +} diff --git a/src/config.rs b/src/config.rs index e188db2..941fc7a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 = toml::from_str("not valid toml ]][["); + assert!(result.is_err()); + } } diff --git a/src/session.rs b/src/session.rs index ba8c829..981e05d 100644 --- a/src/session.rs +++ b/src/session.rs @@ -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::() { - 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::() { + 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); + } +}