agentmux_srv/backend/work_facts/
paths.rs1use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12const CWD_STATE_FILE_OVERRIDE_ENV: &str = "AGENTMUX_BASHWRAP_CWD_STATE_FILE";
15
16pub fn normalize_path(raw: &str) -> String {
19 let mut s = raw.trim().replace('\\', "/");
20 let b = s.as_bytes();
22 if cfg!(windows) && b.len() >= 2 && b[0] == b'/' && b[1].is_ascii_alphabetic() && (b.len() == 2 || b[2] == b'/')
23 {
24 s = format!("{}:{}", (b[1] as char).to_ascii_uppercase(), &s[2..]);
25 }
26 let unc = s.starts_with("//");
27 let mut out = String::with_capacity(s.len());
28 let mut prev_slash = false;
29 for c in s.chars() {
30 if c == '/' && prev_slash {
31 continue;
32 }
33 prev_slash = c == '/';
34 out.push(c);
35 }
36 if unc {
37 out.insert(0, '/');
38 }
39 while out.len() > 1 && out.ends_with('/') && !out.ends_with(":/") {
41 out.pop();
42 }
43 out
44}
45
46pub fn is_absolute(raw: &str) -> bool {
48 let s = raw.trim().replace('\\', "/");
49 let b = s.as_bytes();
50 s.starts_with('/') || (b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':')
51}
52
53fn same_text(a: &str, b: &str) -> bool {
55 if cfg!(windows) {
56 a.eq_ignore_ascii_case(b)
57 } else {
58 a == b
59 }
60}
61
62pub fn relative_to(path: &str, root: &str) -> Option<String> {
65 let (path, root) = (normalize_path(path), normalize_path(root));
66 if root.is_empty() || path.len() < root.len() || !path.is_char_boundary(root.len()) {
67 return None;
68 }
69 let (head, rest) = path.split_at(root.len());
70 if !same_text(head, &root) {
71 return None;
72 }
73 if rest.is_empty() {
74 return Some(String::new());
75 }
76 if root.ends_with('/') {
77 return Some(rest.to_string());
78 }
79 rest.strip_prefix('/').map(str::to_string)
80}
81
82pub fn clean_relative(raw: &str) -> String {
85 let mut s = normalize_path(raw);
86 while let Some(rest) = s.strip_prefix("./") {
87 s = rest.to_string();
88 }
89 if s == "." {
90 s.clear();
91 }
92 s.trim_start_matches('/').to_string()
93}
94
95pub fn path_matches(file: &str, target: &str) -> bool {
99 if target.is_empty() {
100 return true;
101 }
102 let file_dir = file.strip_suffix('/');
103 let file = file_dir.unwrap_or(file);
104 if same_text(file, target) {
105 return true;
106 }
107 let inside = |outer: &str, inner: &str| {
108 inner.len() > outer.len()
109 && inner.is_char_boundary(outer.len())
110 && same_text(&inner[..outer.len()], outer)
111 && inner.as_bytes()[outer.len()] == b'/'
112 };
113 inside(target, file) || (file_dir.is_some() && inside(file, target))
114}
115
116pub fn normalize_repo(raw: &str) -> Option<String> {
121 let s = raw.trim().trim_end_matches('/');
122 let s = s.strip_suffix(".git").unwrap_or(s);
123 let path = if let Some((scheme, rest)) = s.split_once("://") {
126 if scheme.eq_ignore_ascii_case("file") {
127 return None;
128 }
129 rest.split_once('/').map(|(_, p)| p)?
130 } else if let Some((head, rest)) = s.split_once(':') {
131 if head.contains('/') || is_absolute(s) {
132 return None;
133 }
134 rest
135 } else {
136 s
137 };
138 let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
139 if parts.len() < 2 {
140 return None;
141 }
142 let (owner, repo) = (parts[parts.len() - 2], parts[parts.len() - 1]);
143 let ok = |p: &str| p.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
144 (ok(owner) && ok(repo)).then(|| format!("{owner}/{repo}").to_ascii_lowercase())
145}
146
147fn cwd_state_key(env: &HashMap<String, String>, agent_id: &str) -> String {
151 let non_empty = |k: &str| env.get(k).map(|v| v.trim()).filter(|v| !v.is_empty());
152 let key = non_empty("AGENTMUX_INSTANCE_SLUG")
153 .or_else(|| non_empty("AGENTMUX_AGENT_ID"))
154 .unwrap_or(agent_id);
155 key.chars()
156 .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
157 .collect()
158}
159
160pub fn cwd_state_file(home: &Path, env: &HashMap<String, String>, agent_id: &str) -> PathBuf {
163 if let Some(over) = env.get(CWD_STATE_FILE_OVERRIDE_ENV).filter(|v| !v.trim().is_empty()) {
164 return PathBuf::from(over.trim());
165 }
166 home.join(".agentmux")
167 .join("state")
168 .join("bashwrap-cwd")
169 .join(format!("{}.cwd", cwd_state_key(env, agent_id)))
170}
171
172pub fn working_folder(
175 home: Option<&Path>,
176 env: &HashMap<String, String>,
177 agent_id: &str,
178 start_folder: &str,
179) -> Option<String> {
180 let live = home
181 .map(|h| cwd_state_file(h, env, agent_id))
182 .and_then(|f| std::fs::read_to_string(f).ok())
183 .map(|s| s.trim().to_string())
184 .filter(|s| !s.is_empty() && Path::new(s).is_dir());
185 if let Some(live) = live {
186 return Some(normalize_path(&live));
187 }
188 let start = start_folder.trim();
189 if start.is_empty() {
190 return None;
191 }
192 let start = match (start.strip_prefix("~/"), home) {
193 (Some(rest), Some(h)) => h.join(rest).to_string_lossy().into_owned(),
194 _ => start.to_string(),
195 };
196 Some(normalize_path(&start))
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn normalizes_every_spelling_of_a_path() {
205 assert_eq!(normalize_path(r"C:\Users\a\repo\"), "C:/Users/a/repo");
206 if cfg!(windows) {
207 assert_eq!(normalize_path("/c/Users/a/repo"), "C:/Users/a/repo");
208 } else {
209 assert_eq!(normalize_path("/c/Users/a/repo"), "/c/Users/a/repo");
210 }
211 assert_eq!(normalize_path("C:/"), "C:/");
212 assert_eq!(normalize_path("/home//a/repo/"), "/home/a/repo");
213 assert_eq!(normalize_path(r"\\server\share\x"), "//server/share/x");
214 assert_eq!(normalize_path("/"), "/");
215 }
216
217 #[test]
218 fn relative_to_strips_the_root_and_rejects_outsiders() {
219 assert_eq!(relative_to(r"C:\w\repo\crates\a.rs", "C:/w/repo").as_deref(), Some("crates/a.rs"));
220 assert_eq!(relative_to("C:/w/repo", "C:/w/repo").as_deref(), Some(""));
221 assert_eq!(relative_to("C:/w/repo2/a.rs", "C:/w/repo"), None, "a sibling with a longer name is outside");
222 assert_eq!(relative_to("C:/w/other/a.rs", "C:/w/repo"), None);
223 assert_eq!(relative_to("/a.rs", "/").as_deref(), Some("a.rs"));
224 }
225
226 #[cfg(windows)]
227 #[test]
228 fn relative_to_ignores_case_on_windows() {
229 assert_eq!(relative_to("c:/W/Repo/a.rs", "C:/w/repo").as_deref(), Some("a.rs"));
230 }
231
232 #[test]
233 fn cleans_a_typed_relative_path() {
234 assert_eq!(clean_relative("./crates/srv/"), "crates/srv");
235 assert_eq!(clean_relative(r"crates\srv\a.rs"), "crates/srv/a.rs");
236 assert_eq!(clean_relative("."), "");
237 }
238
239 #[test]
240 fn a_folder_matches_what_is_inside_it_and_nothing_else() {
241 assert!(path_matches("crates/srv/a.rs", "crates/srv/a.rs"));
242 assert!(path_matches("crates/srv/a.rs", "crates/srv"));
243 assert!(path_matches("crates/srv/a.rs", ""));
244 assert!(!path_matches("crates/srv2/a.rs", "crates/srv"), "a prefix is not a folder");
245 assert!(!path_matches("crates", "crates/srv"));
246 assert!(path_matches("notes/new/", "notes/new/plan.txt"));
248 assert!(path_matches("notes/new/", "notes"));
249 assert!(!path_matches("notes/newer/", "notes/new"));
250 }
251
252 #[test]
253 fn repo_names_come_from_every_remote_spelling() {
254 for url in [
255 "https://github.com/AgentMuxAI/agentmux.git",
256 "https://x-access-token:secret@github.com/agentmuxai/agentmux",
257 "git@github.com:agentmuxai/agentmux.git",
258 "ssh://git@github.com/agentmuxai/agentmux.git",
259 "agentmuxai/agentmux",
260 "https://github.com/agentmuxai/agentmux/",
261 ] {
262 assert_eq!(normalize_repo(url).as_deref(), Some("agentmuxai/agentmux"), "{url}");
263 }
264 assert_eq!(normalize_repo("agentmux"), None);
265 assert_eq!(normalize_repo("C:/w/repo"), None, "a local path is not a repository name");
266 assert_eq!(normalize_repo(""), None);
267 }
268
269 #[test]
270 fn the_cwd_state_file_follows_bashwraps_naming() {
271 let home = Path::new("/h");
272 let mut env = HashMap::new();
273 let file = |env: &HashMap<String, String>| cwd_state_file(home, env, "AgentY");
274 assert_eq!(file(&env), Path::new("/h/.agentmux/state/bashwrap-cwd/AgentY.cwd"));
275 env.insert("AGENTMUX_AGENT_ID".to_string(), "Agent Y".to_string());
276 assert_eq!(file(&env), Path::new("/h/.agentmux/state/bashwrap-cwd/Agent_Y.cwd"));
277 env.insert("AGENTMUX_INSTANCE_SLUG".to_string(), "agenty-0629j".to_string());
278 assert_eq!(file(&env), Path::new("/h/.agentmux/state/bashwrap-cwd/agenty-0629j.cwd"));
279 env.insert(CWD_STATE_FILE_OVERRIDE_ENV.to_string(), "/tmp/x.cwd".to_string());
280 assert_eq!(file(&env), Path::new("/tmp/x.cwd"));
281 }
282
283 #[test]
284 fn the_live_folder_wins_over_the_start_folder_only_while_it_exists() {
285 let home = tempfile::tempdir().unwrap();
286 let live = tempfile::tempdir().unwrap();
287 let env = HashMap::new();
288 let state = cwd_state_file(home.path(), &env, "A");
289 std::fs::create_dir_all(state.parent().unwrap()).unwrap();
290
291 assert_eq!(working_folder(Some(home.path()), &env, "A", "/start").as_deref(), Some("/start"));
292 std::fs::write(&state, live.path().to_string_lossy().as_bytes()).unwrap();
293 assert_eq!(
294 working_folder(Some(home.path()), &env, "A", "/start"),
295 Some(normalize_path(&live.path().to_string_lossy()))
296 );
297 std::fs::write(&state, "/no/such/folder/anywhere").unwrap();
298 assert_eq!(working_folder(Some(home.path()), &env, "A", "/start").as_deref(), Some("/start"));
299 assert_eq!(working_folder(Some(home.path()), &env, "A", ""), None);
300 }
301}