Skip to main content

linesmith_core/segments/
workspace.rs

1//! Directory / worktree hybrid segment.
2//!
3//! - Inside a linked git worktree: `{repo}/{worktree_name}`
4//! - Any other repo kind or outside git: project-dir basename
5//! - Project dir has no usable basename: hidden
6//!
7//! Worktree name comes from [`DataContext::git`] (gix discovery) rather
8//! than the stdin passthrough, so every git-aware segment agrees on one
9//! source of truth.
10
11use super::{RenderContext, RenderResult, RenderedSegment, Segment, SegmentDefaults};
12use crate::data_context::{DataContext, DataDep, RepoKind};
13use crate::theme::Role;
14
15pub struct WorkspaceSegment;
16
17/// Lowest non-zero priority in the built-in set: orientation ("where am
18/// I?") survives nearly all width pressure.
19const PRIORITY: u8 = 16;
20
21impl Segment for WorkspaceSegment {
22    fn data_deps(&self) -> &'static [DataDep] {
23        &[DataDep::Git]
24    }
25
26    fn render(&self, ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
27        let Some(workspace) = ctx.status.workspace.as_ref() else {
28            crate::lsm_debug!("workspace: status.workspace absent; hiding");
29            return Ok(None);
30        };
31        let Some(repo_name) = workspace.project_dir.file_name().and_then(|s| s.to_str()) else {
32            crate::lsm_debug!("workspace: status.workspace.project_dir has no basename; hiding");
33            return Ok(None);
34        };
35
36        let text = match &*ctx.git() {
37            Ok(Some(gc)) => match &gc.repo_kind {
38                RepoKind::LinkedWorktree { name } => format!("{repo_name}/{name}"),
39                // Wildcard keeps parity with git_branch's SemVer posture
40                // on the shared #[non_exhaustive] enum: a new variant
41                // renders like a regular checkout until either segment
42                // opts it in.
43                _ => repo_name.to_string(),
44            },
45            // Data layer already logged any gix error to stderr; fall
46            // through so orientation survives.
47            Ok(None) | Err(_) => repo_name.to_string(),
48        };
49
50        Ok(Some(RenderedSegment::new(text).with_role(Role::Info)))
51    }
52
53    fn defaults(&self) -> SegmentDefaults {
54        SegmentDefaults::with_priority(PRIORITY).with_truncatable(true)
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use crate::data_context::{GitContext, GitError, Head, RepoKind};
62    use crate::input::{ModelInfo, StatusContext, Tool, WorkspaceInfo};
63    use std::path::PathBuf;
64    use std::sync::Arc;
65
66    fn status(project_dir: &str) -> StatusContext {
67        StatusContext {
68            tool: Tool::ClaudeCode,
69            model: Some(ModelInfo {
70                display_name: "Claude Test".into(),
71            }),
72            workspace: Some(WorkspaceInfo {
73                project_dir: PathBuf::from(project_dir),
74                git_worktree: None,
75            }),
76            context_window: None,
77            cost: None,
78            effort: None,
79            vim: None,
80            output_style: None,
81            agent_name: None,
82            version: None,
83            raw: Arc::new(serde_json::Value::Null),
84        }
85    }
86
87    fn rc() -> RenderContext {
88        RenderContext::new(80)
89    }
90
91    fn ctx_with_git(project_dir: &str, git: Result<Option<GitContext>, GitError>) -> DataContext {
92        let dc = DataContext::with_cwd(status(project_dir), None);
93        dc.preseed_git(git).expect("fresh onceCell");
94        dc
95    }
96
97    fn linked_worktree(name: &str) -> GitContext {
98        GitContext::new(
99            RepoKind::LinkedWorktree { name: name.into() },
100            PathBuf::from(format!("/repo/.git/worktrees/{name}")),
101            Head::Branch(name.into()),
102        )
103    }
104
105    #[test]
106    fn renders_directory_outside_repo() {
107        let dc = ctx_with_git("/home/dev/linesmith", Ok(None));
108        assert_eq!(
109            WorkspaceSegment.render(&dc, &rc()).unwrap(),
110            Some(RenderedSegment::new("linesmith").with_role(Role::Info))
111        );
112    }
113
114    #[test]
115    fn renders_basename_in_main_checkout() {
116        let gc = GitContext::new(
117            RepoKind::Main,
118            PathBuf::from("/home/dev/linesmith/.git"),
119            Head::Branch("main".into()),
120        );
121        let dc = ctx_with_git("/home/dev/linesmith", Ok(Some(gc)));
122        assert_eq!(
123            WorkspaceSegment.render(&dc, &rc()).unwrap(),
124            Some(RenderedSegment::new("linesmith").with_role(Role::Info))
125        );
126    }
127
128    #[test]
129    fn renders_hybrid_inside_linked_worktree() {
130        let dc = ctx_with_git(
131            "/home/dev/linesmith",
132            Ok(Some(linked_worktree("feat-segments"))),
133        );
134        assert_eq!(
135            WorkspaceSegment.render(&dc, &rc()).unwrap(),
136            Some(RenderedSegment::new("linesmith/feat-segments").with_role(Role::Info))
137        );
138    }
139
140    #[test]
141    fn renders_worktree_name_containing_slash_verbatim() {
142        // Branch-backed worktrees commonly have `/` in their names. We
143        // render verbatim (no escape, no truncation); downstream readers
144        // interpret "repo/path-with-slashes" unambiguously in practice.
145        let dc = ctx_with_git(
146            "/home/dev/linesmith",
147            Ok(Some(linked_worktree("feature/auth"))),
148        );
149        assert_eq!(
150            WorkspaceSegment.render(&dc, &rc()).unwrap(),
151            Some(RenderedSegment::new("linesmith/feature/auth").with_role(Role::Info))
152        );
153    }
154
155    #[test]
156    fn renders_basename_in_bare_repo() {
157        let gc = GitContext::new(
158            RepoKind::Bare,
159            PathBuf::from("/srv/repos/linesmith.git"),
160            Head::Unborn {
161                symbolic_ref: "main".into(),
162            },
163        );
164        let dc = ctx_with_git("/home/dev/linesmith", Ok(Some(gc)));
165        assert_eq!(
166            WorkspaceSegment.render(&dc, &rc()).unwrap(),
167            Some(RenderedSegment::new("linesmith").with_role(Role::Info))
168        );
169    }
170
171    #[test]
172    fn renders_basename_in_submodule() {
173        let gc = GitContext::new(
174            RepoKind::Submodule,
175            PathBuf::from("/home/dev/parent/.git/modules/linesmith"),
176            Head::Branch("main".into()),
177        );
178        let dc = ctx_with_git("/home/dev/linesmith", Ok(Some(gc)));
179        assert_eq!(
180            WorkspaceSegment.render(&dc, &rc()).unwrap(),
181            Some(RenderedSegment::new("linesmith").with_role(Role::Info))
182        );
183    }
184
185    #[test]
186    fn renders_basename_on_gix_corrupt_repo() {
187        // A gix failure (safe.directory rejection, corrupt index, etc.)
188        // must not blank the basename; orientation still matters.
189        let err = GitError::CorruptRepo {
190            path: PathBuf::from("/home/dev/linesmith"),
191            message: "synthetic".into(),
192        };
193        let dc = ctx_with_git("/home/dev/linesmith", Err(err));
194        assert_eq!(
195            WorkspaceSegment.render(&dc, &rc()).unwrap(),
196            Some(RenderedSegment::new("linesmith").with_role(Role::Info))
197        );
198    }
199
200    #[test]
201    fn renders_basename_on_gix_walk_failed() {
202        // Pin the second GitError variant too — wildcard `Err(_)` in
203        // render must treat every error the same, but the spec
204        // distinguishes CorruptRepo from WalkFailed and a future
205        // refactor could split the arms.
206        let err = GitError::WalkFailed {
207            path: PathBuf::from("/home/dev/linesmith"),
208            message: "synthetic".into(),
209        };
210        let dc = ctx_with_git("/home/dev/linesmith", Err(err));
211        assert_eq!(
212            WorkspaceSegment.render(&dc, &rc()).unwrap(),
213            Some(RenderedSegment::new("linesmith").with_role(Role::Info))
214        );
215    }
216
217    #[test]
218    fn hidden_when_project_dir_has_no_basename() {
219        let dc = ctx_with_git("/", Ok(None));
220        assert_eq!(WorkspaceSegment.render(&dc, &rc()).unwrap(), None);
221    }
222
223    #[test]
224    fn defaults_use_expected_priority() {
225        assert_eq!(WorkspaceSegment.defaults().priority, PRIORITY);
226    }
227
228    #[test]
229    fn declares_git_data_dep() {
230        assert_eq!(WorkspaceSegment.data_deps(), &[DataDep::Git]);
231    }
232
233    #[test]
234    fn hostile_worktree_name_is_stripped_of_control_chars() {
235        let dc = ctx_with_git(
236            "/home/dev/linesmith",
237            Ok(Some(linked_worktree("evil\x1b[2J"))),
238        );
239        let rendered = WorkspaceSegment
240            .render(&dc, &rc())
241            .unwrap()
242            .expect("renders");
243        assert_eq!(rendered.text(), "linesmith/evil[2J");
244        assert!(!rendered.text().contains('\x1b'));
245    }
246
247    #[test]
248    fn hostile_project_dir_basename_is_stripped_of_control_chars() {
249        // Separate code path from worktree: project-dir basename,
250        // payload varied to OSC-set-title + BEL so the two tests
251        // cover distinct escape families.
252        let dc = ctx_with_git("/tmp/\x1b]0;pwn\x07evil", Ok(None));
253        let rendered = WorkspaceSegment
254            .render(&dc, &rc())
255            .unwrap()
256            .expect("renders");
257        assert_eq!(rendered.text(), "]0;pwnevil");
258        assert!(!rendered.text().contains('\x1b'));
259        assert!(!rendered.text().contains('\x07'));
260    }
261}