Skip to main content

agentmux_srv/backend/work_facts/
paths.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Path and repository-name handling for work facts: one spelling for every
5//! path (forward slashes, no trailing slash), repository-relative paths so
6//! two clones of the same repository compare equal, `owner/repo` from a
7//! remote URL, and where bashwrap keeps an agent's live working folder.
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12/// bashwrap's override for its cwd state file (`crates/bashwrap/src/bash_wrap.rs`,
13/// `CWD_STATE_FILE_OVERRIDE_ENV`). When a block's env sets it, that is the file.
14const CWD_STATE_FILE_OVERRIDE_ENV: &str = "AGENTMUX_BASHWRAP_CWD_STATE_FILE";
15
16/// One spelling for a path: `\` becomes `/`, an MSYS `/c/...` becomes
17/// `C:/...`, repeated slashes collapse and a trailing slash goes.
18pub fn normalize_path(raw: &str) -> String {
19    let mut s = raw.trim().replace('\\', "/");
20    // On Windows, `/c/Users/...` (Git Bash) is `C:/Users/...`.
21    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    // `C:/` keeps its slash; `/` alone stays `/`.
40    while out.len() > 1 && out.ends_with('/') && !out.ends_with(":/") {
41        out.pop();
42    }
43    out
44}
45
46/// Whether `raw` names an absolute path in any of the spellings agents use.
47pub 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
53/// Paths compare without case on Windows, where the file system does.
54fn 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
62/// `path` relative to `root` (both normalized), or `None` when it isn't
63/// inside it. The root itself is `""`.
64pub 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
82/// A repository-relative path as a caller typed it: forward slashes, no
83/// leading `./` or `/`, no trailing slash. `.` is the whole repository (`""`).
84pub 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
95/// Whether the repository-relative `file` is `target` or inside it. An empty
96/// target is the whole repository. A file ending in `/` is an untracked
97/// folder git reported whole, which matches anything inside it too.
98pub 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
116/// `owner/repo`, lowercase, from a remote URL in any of git's spellings
117/// (`https://host/owner/repo.git`, `git@host:owner/repo`, `ssh://...`), or
118/// from an `owner/repo` a caller typed. Credentials in a URL never survive:
119/// only the last two path segments are kept.
120pub 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    // Everything after the host: the part after `://host/`, or after the `:`
124    // of an scp-style `user@host:path`.
125    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
147/// The key bashwrap names an agent's cwd state file by: the block's
148/// `AGENTMUX_INSTANCE_SLUG`, else its `AGENTMUX_AGENT_ID`, else the
149/// registered agent id; sanitized the way bashwrap does.
150fn 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
160/// Where bashwrap keeps this agent's live working folder:
161/// `~/.agentmux/state/bashwrap-cwd/<key>.cwd`, unless the env overrides it.
162pub 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
172/// The agent's working folder: the live one bashwrap recorded when it names
173/// a folder that still exists, else the folder it started in (`cmd:cwd`).
174pub 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        // An untracked folder git reported whole contains the target.
247        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}