1use super::{Module, ModuleConfig};
7use crate::types::context::Context;
8use std::path::Path;
9
10pub struct DirectoryModule;
28
29impl DirectoryModule {
30 pub fn new() -> Self {
32 Self
33 }
34
35 pub fn from_context(_context: &Context) -> Self {
37 Self::new()
38 }
39
40 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 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 if let Some(cfg) = config
76 .as_any()
77 .downcast_ref::<crate::types::config::DirectoryConfig>()
78 {
79 return !cfg.disabled;
80 }
81 true }
83
84 fn render(&self, context: &Context, config: &dyn ModuleConfig) -> String {
85 if let Some(cfg) = config
87 .as_any()
88 .downcast_ref::<crate::types::config::DirectoryConfig>()
89 {
90 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 if repo_root.is_none() {
108 let mut p = context.current_dir.as_path();
109 loop {
110 let dot_git = p.join(".git");
111 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 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 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 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 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]); 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 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 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]
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 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 static HOME_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
251 let _guard = HOME_ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap();
252 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 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 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; 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; 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; 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; 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 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 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; 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}