Skip to main content

agent_shell_parser/
path.rs

1use std::path::PathBuf;
2
3use crate::parse::{base_command, tokenize, Operator, ParsedPipeline};
4
5/// Resolve a cd target path against a base (current working) directory.
6///
7/// Absolute paths are returned as-is. `~` and `~/...` expand via `$HOME`.
8/// Relative paths are joined onto `base`.
9pub fn resolve_path(target: &str, base: &str) -> String {
10    if target.starts_with('/') {
11        target.to_string()
12    } else if target == "~" || target.starts_with("~/") {
13        if let Some(home) = std::env::var_os("HOME") {
14            let rest = target.strip_prefix("~/").unwrap_or("");
15            if rest.is_empty() {
16                home.to_string_lossy().to_string()
17            } else {
18                PathBuf::from(home).join(rest).to_string_lossy().to_string()
19            }
20        } else {
21            target.to_string()
22        }
23    } else {
24        PathBuf::from(base)
25            .join(target)
26            .to_string_lossy()
27            .to_string()
28    }
29}
30
31/// Return the first non-flag, non-`cd` argument from a `cd` command's word list.
32pub fn extract_cd_target(words: &[String]) -> Option<&str> {
33    words
34        .iter()
35        .find(|w| !w.starts_with('-') && *w != "cd")
36        .map(String::as_str)
37}
38
39/// Extract the path from `git -C <path>` if present.
40pub fn extract_git_c_path(words: &[String]) -> Option<String> {
41    let git_idx = words.iter().position(|w| w == "git")?;
42    let mut i = git_idx + 1;
43    while i < words.len() {
44        if words[i] == "-C" {
45            return words.get(i + 1).cloned();
46        }
47        i += 1;
48    }
49    None
50}
51
52/// Determine the effective working directory for each git command in a pipeline.
53///
54/// Tracks `cd <path>` segments that propagate through `&&` or `;` operators.
55/// Pipe, pipe-err, or, and background operators reset to session cwd since
56/// cd in those contexts runs in a subshell or on the failure path.
57///
58/// Also handles `git -C <path>` by checking any git segment for a -C flag.
59///
60/// Returns a list of effective CWDs — one for each git segment encountered.
61/// If no git segments exist, returns a single-element vec with the final
62/// tracked CWD (preserving the previous behavior for non-git pipelines).
63pub fn effective_cwd(pipeline: &ParsedPipeline, session_cwd: &str) -> Vec<String> {
64    let mut cwd = session_cwd.to_string();
65    let mut git_cwds: Vec<String> = Vec::new();
66
67    for (i, seg) in pipeline.segments.iter().enumerate() {
68        let words = tokenize(&seg.command);
69        if words.is_empty() {
70            // Only propagate cwd through && and ;
71            if i < pipeline.operators.len() {
72                match pipeline.operators[i] {
73                    Operator::And | Operator::Semi => {}
74                    _ => cwd = session_cwd.to_string(),
75                }
76            }
77            continue;
78        }
79
80        let base = base_command(&seg.command);
81
82        if base == "cd" {
83            if let Some(target) = extract_cd_target(&words) {
84                cwd = resolve_path(target, &cwd);
85            }
86        }
87
88        if base == "git" {
89            let git_cwd = extract_git_c_path(&words);
90            let resolved = if let Some(path) = git_cwd {
91                if path.starts_with('/') {
92                    path
93                } else {
94                    PathBuf::from(&cwd)
95                        .join(&path)
96                        .to_string_lossy()
97                        .to_string()
98                }
99            } else {
100                cwd.clone()
101            };
102            git_cwds.push(resolved);
103        }
104
105        // Only propagate cwd through && and ;
106        if i < pipeline.operators.len() {
107            match pipeline.operators[i] {
108                Operator::And | Operator::Semi => {}
109                _ => cwd = session_cwd.to_string(),
110            }
111        }
112    }
113
114    if git_cwds.is_empty() {
115        vec![cwd]
116    } else {
117        git_cwds.dedup();
118        git_cwds
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    // --- resolve_path ---
127
128    #[test]
129    fn resolve_absolute() {
130        assert_eq!(resolve_path("/abs/path", "/base"), "/abs/path");
131    }
132
133    #[test]
134    fn resolve_relative() {
135        assert_eq!(resolve_path("subdir", "/base"), "/base/subdir");
136    }
137
138    #[test]
139    fn resolve_tilde_alone() {
140        let home = std::env::var("HOME").unwrap_or_default();
141        assert_eq!(resolve_path("~", "/base"), home);
142    }
143
144    #[test]
145    fn resolve_tilde_subdir() {
146        let home = std::env::var("HOME").unwrap_or_default();
147        let result = resolve_path("~/docs", "/base");
148        assert_eq!(result, format!("{home}/docs"));
149    }
150
151    // --- extract_cd_target ---
152
153    #[test]
154    fn cd_target_normal() {
155        let words: Vec<String> = ["cd", "/foo"].iter().map(|s| s.to_string()).collect();
156        assert_eq!(extract_cd_target(&words), Some("/foo"));
157    }
158
159    #[test]
160    fn cd_target_with_flags() {
161        let words: Vec<String> = ["cd", "-L", "/foo"].iter().map(|s| s.to_string()).collect();
162        assert_eq!(extract_cd_target(&words), Some("/foo"));
163    }
164
165    #[test]
166    fn cd_target_no_args() {
167        let words: Vec<String> = ["cd"].iter().map(|s| s.to_string()).collect();
168        assert_eq!(extract_cd_target(&words), None);
169    }
170
171    // --- extract_git_c_path ---
172
173    #[test]
174    fn git_c_path_present() {
175        let words: Vec<String> = ["git", "-C", "/repo", "status"]
176            .iter()
177            .map(|s| s.to_string())
178            .collect();
179        assert_eq!(extract_git_c_path(&words), Some("/repo".to_string()));
180    }
181
182    #[test]
183    fn git_c_path_absent() {
184        let words: Vec<String> = ["git", "status"].iter().map(|s| s.to_string()).collect();
185        assert_eq!(extract_git_c_path(&words), None);
186    }
187
188    #[test]
189    fn git_c_path_multiple_flags() {
190        let words: Vec<String> = ["git", "--no-pager", "-C", "/repo", "log"]
191            .iter()
192            .map(|s| s.to_string())
193            .collect();
194        assert_eq!(extract_git_c_path(&words), Some("/repo".to_string()));
195    }
196}