1use std::path::{Path, PathBuf};
2
3use serde_json::Value;
4
5const WORKING_DIRECTORY_PREFIXES: [&str; 3] = [
6 "Primary working directory:",
7 "Working directory:",
8 "Current working directory:",
9];
10
11pub fn name_from_system(system: Option<&Value>) -> Option<String> {
12 system.and_then(name_from_value)
13}
14
15pub fn name_from_request<'a>(
16 system: Option<&Value>,
17 message_contents: impl IntoIterator<Item = &'a Value>,
18) -> Option<String> {
19 name_from_system(system).or_else(|| message_contents.into_iter().find_map(name_from_value))
20}
21
22fn name_from_value(value: &Value) -> Option<String> {
23 match value {
24 Value::String(text) => name_from_text(text),
25 Value::Array(values) => values.iter().find_map(name_from_value),
26 Value::Object(object) => object
27 .get("text")
28 .and_then(Value::as_str)
29 .and_then(name_from_text)
30 .or_else(|| object.get("content").and_then(name_from_value)),
31 _ => None,
32 }
33}
34
35fn name_from_text(text: &str) -> Option<String> {
36 text.lines()
37 .find_map(working_directory_from_line)
38 .and_then(name_from_working_directory)
39}
40
41fn working_directory_from_line(line: &str) -> Option<&str> {
42 let line = line.trim().strip_prefix("- ").unwrap_or(line.trim());
43 WORKING_DIRECTORY_PREFIXES.iter().find_map(|prefix| {
44 line.strip_prefix(prefix)
45 .map(str::trim)
46 .filter(|path| !path.is_empty())
47 })
48}
49
50fn name_from_working_directory(path: &str) -> Option<String> {
51 let working_directory = Path::new(path);
52 let repository_root = working_directory
53 .ancestors()
54 .find(|ancestor| ancestor.join(".git").exists());
55
56 repository_root
57 .and_then(repository_name)
58 .or_else(|| path_name(working_directory))
59}
60
61fn repository_name(root: &Path) -> Option<String> {
62 let git_marker = root.join(".git");
63 if git_marker.is_dir() {
64 return path_name(root);
65 }
66
67 let contents = std::fs::read_to_string(git_marker).ok()?;
68 let git_dir = contents.trim().strip_prefix("gitdir:")?.trim();
69 let git_dir = if Path::new(git_dir).is_absolute() {
70 PathBuf::from(git_dir)
71 } else {
72 root.join(git_dir)
73 };
74 git_dir
75 .ancestors()
76 .find(|ancestor| ancestor.file_name().is_some_and(|name| name == ".git"))
77 .and_then(Path::parent)
78 .and_then(path_name)
79 .or_else(|| path_name(root))
80}
81
82fn path_name(path: &Path) -> Option<String> {
83 path.file_name()
84 .and_then(|name| name.to_str())
85 .filter(|name| !name.is_empty())
86 .map(str::to_string)
87}
88
89#[cfg(test)]
90mod tests {
91 use std::fs;
92
93 use serde_json::json;
94 use tempfile::tempdir;
95
96 use super::*;
97
98 #[test]
99 fn reads_primary_working_directory_from_claude_code_system_blocks() {
100 let root = tempdir().unwrap();
101 fs::create_dir(root.path().join(".git")).unwrap();
102 let system = json!([
103 {
104 "type": "text",
105 "text": "x-anthropic-billing-header: cc_version=2.1.177.45c"
106 },
107 {
108 "type": "text",
109 "text": "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
110 "cache_control": {"type": "ephemeral"}
111 },
112 {
113 "type": "text",
114 "text": format!(
115 "\nYou are an interactive agent.\n\n# Environment\nYou have been invoked in the following environment: \n - Primary working directory: {}\n - Is a git repository: true",
116 root.path().display()
117 ),
118 "cache_control": {"type": "ephemeral"}
119 }
120 ]);
121
122 assert_eq!(
123 name_from_system(Some(&system)).as_deref(),
124 root.path().file_name().and_then(|name| name.to_str())
125 );
126 }
127
128 #[test]
129 fn reads_legacy_working_directory_from_string_system_prompt() {
130 let system = json!("<env>\nWorking directory: /home/user/example\n</env>");
131
132 assert_eq!(name_from_system(Some(&system)).as_deref(), Some("example"));
133 }
134
135 #[test]
136 fn reads_working_directory_from_message_system_reminder() {
137 let content = json!([
138 {"type": "text", "text": "hello"},
139 {"type": "text", "text": "<system-reminder>\n# Environment\n - Primary working directory: /home/user/example\n</system-reminder>"}
140 ]);
141
142 assert_eq!(
143 name_from_request(None, [&content]).as_deref(),
144 Some("example")
145 );
146 }
147
148 #[test]
149 fn resolves_linked_worktree_to_main_repository_name() {
150 let temp = tempdir().unwrap();
151 let main = temp.path().join("project");
152 let worktree = temp.path().join("worktrees").join("feature");
153 let git_dir = main.join(".git").join("worktrees").join("feature");
154 fs::create_dir_all(&git_dir).unwrap();
155 fs::create_dir_all(&worktree).unwrap();
156 fs::write(
157 worktree.join(".git"),
158 format!("gitdir: {}\n", git_dir.display()),
159 )
160 .unwrap();
161
162 assert_eq!(
163 name_from_working_directory(worktree.to_str().unwrap()).as_deref(),
164 Some("project")
165 );
166 }
167
168 #[test]
169 fn returns_none_without_working_directory_metadata() {
170 assert_eq!(name_from_system(Some(&json!("instructions"))), None);
171 assert_eq!(name_from_system(None), None);
172 }
173}