Skip to main content

claude_code_statusline_core/modules/
directory.rs

1//! Directory module for displaying the current working directory
2//!
3//! This module shows the current directory path with home directory
4//! abbreviation (~) and optional truncation for long paths.
5
6use super::{Module, ModuleConfig};
7use crate::types::context::Context;
8use std::path::Path;
9
10/// Module that displays the current working directory
11///
12/// Features:
13/// - Home directory abbreviation (e.g., `/home/user` → `~`)
14/// - Path truncation for long directories
15/// - Repository-relative paths (when in git repos)
16/// - ANSI color styling support
17///
18/// # Configuration
19///
20/// ```toml
21/// [directory]
22/// format = "[$path]($style)"
23/// style = "bold cyan"
24/// truncation_length = 3
25/// truncate_to_repo = true
26/// ```
27pub struct DirectoryModule;
28
29impl DirectoryModule {
30    /// Create a new DirectoryModule instance
31    pub fn new() -> Self {
32        Self
33    }
34
35    /// Create from Context (kept for compatibility)
36    pub fn from_context(_context: &Context) -> Self {
37        Self::new()
38    }
39
40    /// Resolve user's home directory, preferring HOME env var when present
41    fn resolve_home_dir(&self) -> Option<std::path::PathBuf> {
42        match std::env::var("HOME") {
43            Ok(home) if !home.is_empty() => Some(std::path::PathBuf::from(home)),
44            _ => dirs::home_dir(),
45        }
46    }
47
48    /// Abbreviate home directory to ~ (cross-platform)
49    fn abbreviate_home(&self, path: &Path) -> String {
50        if let Some(home) = self.resolve_home_dir() {
51            if let Ok(relative) = path.strip_prefix(&home) {
52                if relative.as_os_str().is_empty() {
53                    return "~".to_string();
54                }
55                return format!("~/{}", relative.display());
56            }
57        }
58        path.display().to_string()
59    }
60}
61
62impl Default for DirectoryModule {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl Module for DirectoryModule {
69    fn name(&self) -> &str {
70        "directory"
71    }
72
73    fn should_display(&self, _context: &Context, config: &dyn ModuleConfig) -> bool {
74        // Check if the module is disabled in config
75        if let Some(cfg) = config
76            .as_any()
77            .downcast_ref::<crate::types::config::DirectoryConfig>()
78        {
79            return !cfg.disabled;
80        }
81        true // Default to displaying if no config found
82    }
83
84    fn render(&self, context: &Context, config: &dyn ModuleConfig) -> String {
85        // Try to use module-specific formatting if available
86        if let Some(cfg) = config
87            .as_any()
88            .downcast_ref::<crate::types::config::DirectoryConfig>()
89        {
90            // If truncate_to_repo is enabled and we're inside a repo, construct
91            // a repository-relative path: `<repo-name>/<sub/dirs>`, truncated to
92            // at most `truncation_length` segments, always keeping the repo name.
93            let mut repo_root: Option<std::path::PathBuf> = None;
94
95            if cfg.truncate_to_repo {
96                #[cfg(feature = "git")]
97                {
98                    if let Ok(repo) = context.repo() {
99                        if let Some(wd) = repo.workdir() {
100                            if context.current_dir.starts_with(wd) {
101                                repo_root = Some(wd.to_path_buf());
102                            }
103                        }
104                    }
105                }
106                // Fallback discovery without git feature: look for a `.git` directory or file (worktrees)
107                if repo_root.is_none() {
108                    let mut p = context.current_dir.as_path();
109                    loop {
110                        let dot_git = p.join(".git");
111                        // In worktrees, `.git` can be a file; treat either as a repository marker
112                        if dot_git.is_dir() || dot_git.is_file() {
113                            repo_root = Some(p.to_path_buf());
114                            break;
115                        }
116                        match p.parent() {
117                            Some(parent) => p = parent,
118                            None => break,
119                        }
120                    }
121                }
122            }
123
124            let path_str = if let Some(root) = repo_root {
125                // repo name
126                let repo_name = root
127                    .file_name()
128                    .map(|s| s.to_string_lossy().to_string())
129                    .unwrap_or_else(|| root.display().to_string());
130
131                // relative components from repo root to current dir
132                let mut segments: Vec<String> = vec![repo_name];
133                if let Ok(rel) = context.current_dir.strip_prefix(&root) {
134                    use std::path::Component;
135                    for c in rel.components() {
136                        if let Component::Normal(os) = c {
137                            let s = os.to_string_lossy().to_string();
138                            if !s.is_empty() {
139                                segments.push(s);
140                            }
141                        }
142                    }
143                }
144
145                // Truncate to at most `truncation_length` segments, preserving repo name
146                let tl = std::cmp::max(1, cfg.truncation_length);
147                if segments.len() > tl {
148                    let keep_tail = tl.saturating_sub(1);
149                    if keep_tail == 0 {
150                        // Only show repo name when nothing else is kept
151                        segments[0].clone()
152                    } else {
153                        let start = segments.len() - keep_tail;
154                        let tail = &segments[start..];
155                        let mut out = String::with_capacity(segments[0].len() + 1 + 4 * keep_tail);
156                        out.push_str(&segments[0]); // repo name
157                        out.push('/');
158                        if !cfg.truncation_symbol.is_empty() {
159                            out.push_str(&cfg.truncation_symbol);
160                        }
161                        out.push_str(&tail.join("/"));
162                        out
163                    }
164                } else {
165                    segments.join("/")
166                }
167            } else {
168                // Fallback to home abbreviation (legacy behavior)
169                self.abbreviate_home(&context.current_dir)
170            };
171
172            use std::collections::HashMap;
173            let mut tokens: HashMap<&str, String> = HashMap::new();
174            tokens.insert("path", path_str.clone());
175            return crate::style::render_with_style_template(cfg.format(), &tokens, cfg.style());
176        }
177
178        // No config found: return plain abbreviated path
179        self.abbreviate_home(&context.current_dir)
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::config::Config;
187    use crate::types::claude::{ClaudeInput, ModelInfo, WorkspaceInfo};
188    use crate::types::context::Context;
189    use rstest::*;
190    use std::fs::create_dir_all;
191    use std::sync::{Mutex, OnceLock};
192
193    /// Fixture for creating test contexts
194    #[fixture]
195    fn test_context() -> Context {
196        let input = ClaudeInput {
197            hook_event_name: None,
198            session_id: "test-session".to_string(),
199            transcript_path: None,
200            cwd: "/Users/test/projects".to_string(),
201            model: ModelInfo {
202                id: "claude-opus".to_string(),
203                display_name: "Opus".to_string(),
204            },
205            workspace: Some(WorkspaceInfo {
206                current_dir: "/Users/test/projects".to_string(),
207                project_dir: Some("/Users/test".to_string()),
208            }),
209            version: Some("1.0.0".to_string()),
210            output_style: None,
211        };
212        Context::new(input, Config::default())
213    }
214
215    /// Helper to create context with specific cwd
216    fn context_with_cwd(cwd: &str) -> Context {
217        let input = ClaudeInput {
218            hook_event_name: None,
219            session_id: "test-session".to_string(),
220            transcript_path: None,
221            cwd: cwd.to_string(),
222            model: ModelInfo {
223                id: "claude-opus".to_string(),
224                display_name: "Opus".to_string(),
225            },
226            workspace: Some(WorkspaceInfo {
227                current_dir: cwd.to_string(),
228                project_dir: Some("/Users/test".to_string()),
229            }),
230            version: Some("1.0.0".to_string()),
231            output_style: None,
232        };
233        Context::new(input, Config::default())
234    }
235
236    #[rstest]
237    fn test_directory_module(test_context: Context) {
238        let module = DirectoryModule::new();
239        assert_eq!(module.name(), "directory");
240        assert!(module.should_display(&test_context, &test_context.config.directory));
241    }
242
243    #[rstest]
244    #[case("/Users/test", "~")]
245    #[case("/Users/test/projects", "~/projects")]
246    #[case("/Users/test/Documents/code", "~/Documents/code")]
247    fn test_home_directory_abbreviation(#[case] cwd: &str, #[case] expected: &str) {
248        let module = DirectoryModule::new();
249        // Serialize HOME mutation to avoid test flakiness in parallel runs
250        static HOME_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
251        let _guard = HOME_ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap();
252        // Save and set HOME environment variable
253        let original_home = std::env::var("HOME").ok();
254        unsafe {
255            std::env::set_var("HOME", "/Users/test");
256        }
257
258        let context = context_with_cwd(cwd);
259        let rendered = module.render(&context, &context.config.directory);
260        let plain = String::from_utf8(strip_ansi_escapes::strip(rendered)).unwrap();
261        assert_eq!(plain, expected);
262
263        // Restore original HOME
264        unsafe {
265            if let Some(home) = original_home {
266                std::env::set_var("HOME", home);
267            } else {
268                std::env::remove_var("HOME");
269            }
270        }
271    }
272
273    #[rstest]
274    #[case("/var/www/html", "/var/www/html")]
275    #[case("/tmp/test", "/tmp/test")]
276    #[case("/usr/local/bin", "/usr/local/bin")]
277    fn test_non_home_paths(#[case] cwd: &str, #[case] expected: &str) {
278        let module = DirectoryModule::new();
279        let context = context_with_cwd(cwd);
280        let rendered = module.render(&context, &context.config.directory);
281        let plain = String::from_utf8(strip_ansi_escapes::strip(rendered)).unwrap();
282        assert_eq!(plain, expected);
283    }
284
285    #[cfg(feature = "git")]
286    fn init_git_repo(root: &std::path::Path) -> git2::Repository {
287        use git2::Repository;
288        let repo = Repository::init(root).unwrap();
289        // initial commit for a valid repo
290        let sig = git2::Signature::now("Tester", "tester@example.com").unwrap();
291        std::fs::write(root.join("README.md"), b"init\n").unwrap();
292        let mut idx = repo.index().unwrap();
293        idx.add_path(std::path::Path::new("README.md")).unwrap();
294        let tree_id = idx.write_tree().unwrap();
295        let tree = repo.find_tree(tree_id).unwrap();
296        let head = repo
297            .commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
298            .unwrap();
299        drop(tree);
300        let c0 = repo.find_commit(head).unwrap();
301        let _ = repo.branch("main", &c0, true).ok();
302        drop(c0);
303        let _ = repo.set_head("refs/heads/main");
304        repo
305    }
306
307    #[cfg(feature = "git")]
308    #[rstest]
309    fn repo_root_displays_repo_name_only() {
310        let tmp = tempfile::tempdir().unwrap();
311        let root = tmp.path();
312        create_dir_all(root).unwrap();
313        let _repo = init_git_repo(root);
314
315        let input = crate::types::claude::ClaudeInput {
316            hook_event_name: None,
317            session_id: "test".into(),
318            transcript_path: None,
319            cwd: root.to_string_lossy().to_string(),
320            model: crate::types::claude::ModelInfo {
321                id: "id".into(),
322                display_name: "Opus".into(),
323            },
324            workspace: Some(crate::types::claude::WorkspaceInfo {
325                current_dir: root.to_string_lossy().to_string(),
326                project_dir: Some(root.to_string_lossy().to_string()),
327            }),
328            version: Some("1.0.0".into()),
329            output_style: None,
330        };
331        let mut cfg = crate::config::Config::default();
332        cfg.directory.truncate_to_repo = true;
333        cfg.directory.truncation_length = 3;
334        let ctx = crate::types::context::Context::new(input, cfg);
335
336        let module = DirectoryModule::new();
337        let rendered = module.render(&ctx, &ctx.config.directory);
338        let plain = String::from_utf8(strip_ansi_escapes::strip(rendered)).unwrap();
339        let repo_name = root.file_name().unwrap().to_string_lossy().to_string();
340        assert_eq!(plain, repo_name);
341    }
342
343    #[cfg(feature = "git")]
344    #[rstest]
345    fn repo_subdir_includes_repo_and_tail_segments() {
346        let tmp = tempfile::tempdir().unwrap();
347        let root = tmp.path();
348        let _repo = init_git_repo(root);
349        let sub = root.join("src").join("module");
350        create_dir_all(&sub).unwrap();
351
352        let input = crate::types::claude::ClaudeInput {
353            hook_event_name: None,
354            session_id: "test".into(),
355            transcript_path: None,
356            cwd: sub.to_string_lossy().to_string(),
357            model: crate::types::claude::ModelInfo {
358                id: "id".into(),
359                display_name: "Opus".into(),
360            },
361            workspace: Some(crate::types::claude::WorkspaceInfo {
362                current_dir: sub.to_string_lossy().to_string(),
363                project_dir: Some(root.to_string_lossy().to_string()),
364            }),
365            version: Some("1.0.0".into()),
366            output_style: None,
367        };
368        let mut cfg = crate::config::Config::default();
369        cfg.directory.truncate_to_repo = true;
370        cfg.directory.truncation_length = 3; // repo + 2
371        let ctx = crate::types::context::Context::new(input, cfg);
372
373        let module = DirectoryModule::new();
374        let rendered = module.render(&ctx, &ctx.config.directory);
375        let plain = String::from_utf8(strip_ansi_escapes::strip(rendered)).unwrap();
376        let repo_name = root.file_name().unwrap().to_string_lossy().to_string();
377        assert_eq!(
378            plain,
379            format!("{repo}/{a}/{b}", repo = repo_name, a = "src", b = "module")
380        );
381    }
382
383    #[cfg(feature = "git")]
384    #[rstest]
385    fn truncation_length_preserves_repo_and_tails() {
386        let tmp = tempfile::tempdir().unwrap();
387        let root = tmp.path();
388        let _repo = init_git_repo(root);
389        let deep = root.join("a").join("b").join("c").join("d");
390        create_dir_all(&deep).unwrap();
391
392        let input = crate::types::claude::ClaudeInput {
393            hook_event_name: None,
394            session_id: "test".into(),
395            transcript_path: None,
396            cwd: deep.to_string_lossy().to_string(),
397            model: crate::types::claude::ModelInfo {
398                id: "id".into(),
399                display_name: "Opus".into(),
400            },
401            workspace: Some(crate::types::claude::WorkspaceInfo {
402                current_dir: deep.to_string_lossy().to_string(),
403                project_dir: Some(root.to_string_lossy().to_string()),
404            }),
405            version: Some("1.0.0".into()),
406            output_style: None,
407        };
408        let mut cfg = crate::config::Config::default();
409        cfg.directory.truncate_to_repo = true;
410        cfg.directory.truncation_length = 2; // repo + last 1
411        let ctx = crate::types::context::Context::new(input, cfg);
412
413        let module = DirectoryModule::new();
414        let rendered = module.render(&ctx, &ctx.config.directory);
415        let plain = String::from_utf8(strip_ansi_escapes::strip(rendered)).unwrap();
416        let repo_name = root.file_name().unwrap().to_string_lossy().to_string();
417        assert_eq!(
418            plain,
419            format!("{repo}/{tail}", repo = repo_name, tail = "d")
420        );
421    }
422
423    #[cfg(feature = "git")]
424    #[rstest]
425    fn repo_truncation_inserts_symbol_between_repo_and_tail() {
426        let tmp = tempfile::tempdir().unwrap();
427        let root = tmp.path();
428        let _repo = init_git_repo(root);
429        let deep = root.join("a").join("b").join("c").join("d");
430        create_dir_all(&deep).unwrap();
431
432        let input = crate::types::claude::ClaudeInput {
433            hook_event_name: None,
434            session_id: "test".into(),
435            transcript_path: None,
436            cwd: deep.to_string_lossy().to_string(),
437            model: crate::types::claude::ModelInfo {
438                id: "id".into(),
439                display_name: "Opus".into(),
440            },
441            workspace: Some(crate::types::claude::WorkspaceInfo {
442                current_dir: deep.to_string_lossy().to_string(),
443                project_dir: Some(root.to_string_lossy().to_string()),
444            }),
445            version: Some("1.0.0".into()),
446            output_style: None,
447        };
448        let mut cfg = crate::config::Config::default();
449        cfg.directory.truncate_to_repo = true;
450        cfg.directory.truncation_length = 2; // repo + last 1
451        cfg.directory.truncation_symbol = "…/".to_string();
452        let ctx = crate::types::context::Context::new(input, cfg);
453
454        let module = DirectoryModule::new();
455        let rendered = module.render(&ctx, &ctx.config.directory);
456        let plain = String::from_utf8(strip_ansi_escapes::strip(rendered)).unwrap();
457        let repo_name = root.file_name().unwrap().to_string_lossy().to_string();
458        assert_eq!(plain, format!("{}/…/{}", repo_name, "d"));
459    }
460
461    #[cfg(feature = "git")]
462    #[rstest]
463    fn no_symbol_when_not_truncated_in_repo() {
464        let tmp = tempfile::tempdir().unwrap();
465        let root = tmp.path();
466        let _repo = init_git_repo(root);
467        let sub = root.join("src").join("module");
468        create_dir_all(&sub).unwrap();
469
470        let input = crate::types::claude::ClaudeInput {
471            hook_event_name: None,
472            session_id: "test".into(),
473            transcript_path: None,
474            cwd: sub.to_string_lossy().to_string(),
475            model: crate::types::claude::ModelInfo {
476                id: "id".into(),
477                display_name: "Opus".into(),
478            },
479            workspace: Some(crate::types::claude::WorkspaceInfo {
480                current_dir: sub.to_string_lossy().to_string(),
481                project_dir: Some(root.to_string_lossy().to_string()),
482            }),
483            version: Some("1.0.0".into()),
484            output_style: None,
485        };
486        let mut cfg = crate::config::Config::default();
487        cfg.directory.truncate_to_repo = true;
488        cfg.directory.truncation_length = 3; // repo + 2 -> exactly fits
489        cfg.directory.truncation_symbol = "…/".to_string();
490        let ctx = crate::types::context::Context::new(input, cfg);
491
492        let module = DirectoryModule::new();
493        let rendered = module.render(&ctx, &ctx.config.directory);
494        let plain = String::from_utf8(strip_ansi_escapes::strip(rendered)).unwrap();
495        let repo_name = root.file_name().unwrap().to_string_lossy().to_string();
496        assert_eq!(plain, format!("{}/{}/{}", repo_name, "src", "module"));
497    }
498
499    #[rstest]
500    fn fallback_detects_git_file_worktree() {
501        // Simulate a Git worktree-like layout where `.git` is a file, not a directory.
502        let tmp = tempfile::tempdir().unwrap();
503        let root = tmp.path();
504        let sub = root.join("src").join("module");
505        std::fs::create_dir_all(&sub).unwrap();
506        // Create a `.git` file at the root to emulate worktree behavior
507        std::fs::write(root.join(".git"), b"gitdir: /path/to/real/gitdir\n").unwrap();
508
509        let input = crate::types::claude::ClaudeInput {
510            hook_event_name: None,
511            session_id: "test".into(),
512            transcript_path: None,
513            cwd: sub.to_string_lossy().to_string(),
514            model: crate::types::claude::ModelInfo {
515                id: "id".into(),
516                display_name: "Opus".into(),
517            },
518            workspace: Some(crate::types::claude::WorkspaceInfo {
519                current_dir: sub.to_string_lossy().to_string(),
520                project_dir: Some(root.to_string_lossy().to_string()),
521            }),
522            version: Some("1.0.0".into()),
523            output_style: None,
524        };
525        let mut cfg = crate::config::Config::default();
526        cfg.directory.truncate_to_repo = true;
527        cfg.directory.truncation_length = 3; // repo + 2
528        let ctx = crate::types::context::Context::new(input, cfg);
529
530        let module = DirectoryModule::new();
531        let rendered = module.render(&ctx, &ctx.config.directory);
532        let plain = String::from_utf8(strip_ansi_escapes::strip(rendered)).unwrap();
533        let repo_name = root.file_name().unwrap().to_string_lossy().to_string();
534        assert_eq!(
535            plain,
536            format!("{repo}/{a}/{b}", repo = repo_name, a = "src", b = "module")
537        );
538    }
539}