Skip to main content

claude_codex/
project.rs

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}