1use std::path::{Path, PathBuf};
2
3use tokio::process::Command;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct WorkspaceStatus {
7 pub display_dir: String,
8 pub git_ref: Option<String>,
9}
10
11impl WorkspaceStatus {
12 pub fn new(display_dir: impl Into<String>, git_ref: Option<String>) -> Self {
13 Self { display_dir: display_dir.into(), git_ref }
14 }
15
16 pub fn label(&self) -> String {
17 self.git_ref
18 .as_ref()
19 .map_or_else(|| self.display_dir.clone(), |git_ref| format!("{} · {git_ref}", self.display_dir))
20 }
21
22 pub async fn resolve(cwd: &Path) -> Self {
23 let display_dir = home_relative_path(cwd);
24 let git_ref = resolve_git_ref(cwd).await;
25 Self::new(display_dir, git_ref)
26 }
27}
28
29async fn resolve_git_ref(cwd: &Path) -> Option<String> {
30 if let Some(branch) = git_stdout(cwd, &["branch", "--show-current"]).await {
31 return Some(branch);
32 }
33 git_stdout(cwd, &["rev-parse", "--short", "HEAD"]).await
34}
35
36async fn git_stdout(cwd: &Path, args: &[&str]) -> Option<String> {
37 let output = Command::new("git").args(args).current_dir(cwd).output().await.ok()?;
38
39 if !output.status.success() {
40 return None;
41 }
42
43 let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
44 if text.is_empty() { None } else { Some(text) }
45}
46
47fn home_relative_path(path: &Path) -> String {
48 home_dir().map_or_else(|| path.display().to_string(), |home| home_relative_path_with_home(path, &home))
49}
50
51fn home_dir() -> Option<PathBuf> {
52 std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
53}
54
55fn home_relative_path_with_home(path: &Path, home: &Path) -> String {
56 if path == home {
57 return "~".to_string();
58 }
59
60 path.strip_prefix(home)
61 .ok()
62 .filter(|relative| !relative.as_os_str().is_empty())
63 .map_or_else(|| path.display().to_string(), |relative| format!("~/{}", relative.display()))
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69
70 #[test]
71 fn label_combines_dir_and_ref() {
72 let status = WorkspaceStatus::new("~/code/aether-2", Some("main".to_string()));
73 assert_eq!(status.label(), "~/code/aether-2 · main");
74 }
75
76 #[test]
77 fn label_omits_ref_when_absent() {
78 let status = WorkspaceStatus::new("~/scratch", None);
79 assert_eq!(status.label(), "~/scratch");
80 }
81
82 #[test]
83 fn home_relative_path_rewrites_home_child() {
84 let path = Path::new("/Users/josh/code/aether-2");
85 let home = Path::new("/Users/josh");
86 assert_eq!(home_relative_path_with_home(path, home), "~/code/aether-2");
87 }
88
89 #[test]
90 fn home_relative_path_handles_home_itself() {
91 let home = Path::new("/Users/josh");
92 assert_eq!(home_relative_path_with_home(home, home), "~");
93 }
94
95 #[test]
96 fn home_relative_path_leaves_external_path_absolute() {
97 let path = Path::new("/opt/work/aether-2");
98 let home = Path::new("/Users/josh");
99 assert_eq!(home_relative_path_with_home(path, home), "/opt/work/aether-2");
100 }
101}