1use std::collections::HashSet;
10
11use serde::{Deserialize, Serialize};
12
13use super::paths::{clean_relative, is_absolute, normalize_path, normalize_repo, path_matches, relative_to};
14use super::WorkFacts;
15
16#[derive(Debug, Clone, Default, Deserialize)]
19pub struct WhoQuery {
20 pub path: Option<String>,
21 pub repo: Option<String>,
22 pub branch: Option<String>,
23 pub query: Option<String>,
24}
25
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
28pub struct Target {
29 pub repo: Option<String>,
31 pub path: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub absolute_path: Option<String>,
36 pub branch: Option<String>,
37 pub query: Option<String>,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
42#[serde(rename_all = "lowercase")]
43pub enum MatchKind {
44 Claim,
47 Dirty,
49 Edited,
51 Branch,
53 Repo,
55 Goal,
56 Todo,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
60pub struct WhoMatch {
61 pub kind: MatchKind,
62 pub detail: String,
63 pub since_ms: Option<u64>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68pub struct WhoResult {
69 pub agent: String,
70 pub channel: String,
71 pub status: String,
72 pub goal: Option<String>,
73 pub repo: Option<String>,
74 pub branch: Option<String>,
75 pub git_checked_ms: Option<u64>,
78 pub matches: Vec<WhoMatch>,
79}
80
81const DETAIL_FILES: usize = 5;
83
84pub fn resolve(q: &WhoQuery, caller: Option<&WorkFacts>, all: &[WorkFacts]) -> Result<Target, String> {
88 let nonempty = |s: &Option<String>| s.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
89 let mut t = Target { branch: nonempty(&q.branch), query: nonempty(&q.query), ..Default::default() };
90 let named_repo = match nonempty(&q.repo) {
91 Some(r) => Some(normalize_repo(&r).ok_or_else(|| format!("repo {r:?} is not owner/repo or a remote URL"))?),
92 None => None,
93 };
94 let caller_repo = caller.and_then(WorkFacts::repo_key);
95
96 if let Some(raw) = nonempty(&q.path) {
97 if is_absolute(&raw) {
98 let abs = normalize_path(&raw);
99 let owner = caller.into_iter().chain(all.iter()).find_map(|f| {
101 let rel = relative_to(&abs, f.repo_root.as_deref()?)?;
102 Some((f.repo_key()?, rel))
103 });
104 match owner {
105 Some((repo, rel)) => {
106 t.repo = named_repo.clone().or(Some(repo));
107 t.path = Some(rel);
108 }
109 None => t.absolute_path = Some(abs),
110 }
111 } else {
112 t.path = Some(clean_relative(&raw));
113 }
114 }
115 if t.repo.is_none() {
116 t.repo = named_repo.or(caller_repo);
117 }
118 if t.path.is_some() && t.repo.is_none() {
119 return Err("path is relative to a repository, but no repository is known: pass `repo`, \
120 an absolute path, or call this from inside a repository"
121 .to_string());
122 }
123 if t.path.is_none() && t.absolute_path.is_none() && t.branch.is_none() && t.query.is_none() && t.repo.is_none() {
124 return Err("nothing to look for: pass path, repo, branch or query (or call this from inside a \
125 repository to ask about it)"
126 .to_string());
127 }
128 Ok(t)
129}
130
131pub(super) fn words(s: &str) -> HashSet<String> {
133 s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_lowercase).collect()
134}
135
136pub(super) fn mentions(query_words: &HashSet<String>, text: &str) -> bool {
138 !query_words.is_empty() && query_words.is_subset(&words(text))
139}
140
141fn list_files(files: &[String], total: usize) -> String {
143 let mut s = files.iter().take(DETAIL_FILES).cloned().collect::<Vec<_>>().join(", ");
144 let shown = files.len().min(DETAIL_FILES);
145 if total > shown {
146 s.push_str(&format!(" (+{} more)", total - shown));
147 }
148 s
149}
150
151fn matches_for(t: &Target, other: &WorkFacts) -> Vec<WhoMatch> {
153 let mut out = Vec::new();
154 let same_repo = t.repo.is_some() && other.repo_key() == t.repo;
155 let repo_name = other.repo.clone().or_else(|| other.repo_root.clone()).unwrap_or_default();
156
157 let file_target: Option<(&str, bool)> = match (&t.path, &t.absolute_path) {
160 (Some(rel), _) if same_repo => Some((rel.as_str(), false)),
161 (None, Some(abs)) => Some((abs.as_str(), true)),
162 _ => None,
163 };
164 let repo_only = t.path.is_none() && t.absolute_path.is_none() && t.branch.is_none() && t.query.is_none();
165 if let Some((target, absolute)) = file_target.or(if repo_only && same_repo { Some(("", false)) } else { None }) {
166 let dirty: Vec<String> = other
167 .dirty_files
168 .iter()
169 .filter(|f| {
170 if absolute {
171 let abs = format!("{}/{}", other.repo_root.as_deref().unwrap_or(""), f);
172 path_matches(&normalize_path(&abs), target)
173 } else {
174 path_matches(f, target)
175 }
176 })
177 .cloned()
178 .collect();
179 let edited: Vec<&super::FactFile> = other
180 .recent_files
181 .iter()
182 .filter(|r| {
183 if absolute {
184 path_matches(&r.path, target)
185 } else {
186 same_repo && r.rel.as_deref().is_some_and(|rel| path_matches(rel, target))
187 }
188 })
189 .collect();
190 let last_edit = |file: &str| {
191 edited.iter().filter(|r| r.rel.as_deref() == Some(file) || r.path == file).map(|r| r.ts_ms).max()
192 };
193 if !dirty.is_empty() {
194 let total = if target.is_empty() && !absolute { other.dirty_count.max(dirty.len()) } else { dirty.len() };
197 out.push(WhoMatch {
198 kind: MatchKind::Dirty,
199 detail: format!("uncommitted in {repo_name}: {}", list_files(&dirty, total)),
200 since_ms: dirty.iter().filter_map(|f| last_edit(f)).max(),
201 });
202 }
203 if !edited.is_empty() {
204 let names: Vec<String> =
205 edited.iter().map(|r| format!("{} ({})", r.rel.as_deref().unwrap_or(&r.path), r.tool)).collect();
206 out.push(WhoMatch {
207 kind: MatchKind::Edited,
208 detail: format!("recently edited: {}", list_files(&names, names.len())),
209 since_ms: edited.iter().map(|r| r.ts_ms).max(),
210 });
211 }
212 if repo_only && out.is_empty() {
213 out.push(WhoMatch {
214 kind: MatchKind::Repo,
215 detail: match &other.branch {
216 Some(b) => format!("working in {repo_name} on branch {b}"),
217 None => format!("working in {repo_name}"),
218 },
219 since_ms: None,
220 });
221 }
222 }
223
224 if let Some(branch) = &t.branch {
225 let repo_ok = t.repo.is_none() || same_repo;
226 if repo_ok && other.branch.as_deref() == Some(branch.as_str()) {
227 out.push(WhoMatch {
228 kind: MatchKind::Branch,
229 detail: format!("on branch {branch} in {repo_name}"),
230 since_ms: None,
231 });
232 }
233 }
234
235 if let Some(q) = &t.query {
236 let qw = words(q);
237 if let Some(goal) = other.goal.as_deref().filter(|g| mentions(&qw, g)) {
238 out.push(WhoMatch { kind: MatchKind::Goal, detail: goal.to_string(), since_ms: None });
239 }
240 for todo in other.todos.iter().filter(|td| mentions(&qw, &td.text)).take(3) {
241 out.push(WhoMatch {
242 kind: MatchKind::Todo,
243 detail: format!("[{}] {}", todo.status, todo.text),
244 since_ms: None,
245 });
246 }
247 }
248
249 out.sort_by_key(|m| m.kind);
250 out
251}
252
253pub fn who_is_working_on(t: &Target, others: &[WorkFacts]) -> Vec<WhoResult> {
256 let mut results: Vec<WhoResult> = others
257 .iter()
258 .filter_map(|f| {
259 let matches = matches_for(t, f);
260 (!matches.is_empty()).then(|| WhoResult {
261 agent: f.agent.clone(),
262 channel: f.channel.clone(),
263 status: f.status.clone(),
264 goal: f.goal.clone(),
265 repo: f.repo.clone(),
266 branch: f.branch.clone(),
267 git_checked_ms: f.git_checked_ms,
268 matches,
269 })
270 })
271 .collect();
272 sort_results(&mut results);
273 results
274}
275
276pub(super) fn sort_results(results: &mut [WhoResult]) {
278 results.sort_by(|a, b| {
279 let strongest = |r: &WhoResult| r.matches.first().map(|m| m.kind);
280 let newest = |r: &WhoResult| r.matches.iter().filter_map(|m| m.since_ms).max();
281 strongest(a)
282 .cmp(&strongest(b))
283 .then_with(|| newest(b).cmp(&newest(a)))
284 .then_with(|| a.agent.cmp(&b.agent))
285 });
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::backend::reactive::progress_watcher::TodoItem;
292 use crate::backend::work_facts::FactFile;
293
294 fn agent(name: &str, repo: &str, root: &str, branch: &str) -> WorkFacts {
296 WorkFacts {
297 agent: name.into(),
298 block_id: format!("block-{name}"),
299 channel: "stable".into(),
300 status: "busy".into(),
301 repo: Some(repo.into()),
302 repo_root: Some(root.into()),
303 branch: Some(branch.into()),
304 ..Default::default()
305 }
306 }
307
308 fn edited(root: &str, rel: &str, ts: u64) -> FactFile {
309 FactFile { path: format!("{root}/{rel}"), rel: Some(rel.into()), tool: "Edit".into(), ts_ms: ts }
310 }
311
312 fn q(path: Option<&str>, repo: Option<&str>, branch: Option<&str>, query: Option<&str>) -> WhoQuery {
313 WhoQuery {
314 path: path.map(Into::into),
315 repo: repo.map(Into::into),
316 branch: branch.map(Into::into),
317 query: query.map(Into::into),
318 }
319 }
320
321 fn ask(query: WhoQuery, caller: &WorkFacts, others: &[WorkFacts]) -> Vec<WhoResult> {
322 let mut all = vec![caller.clone()];
323 all.extend_from_slice(others);
324 let t = resolve(&query, Some(caller), &all).unwrap();
325 who_is_working_on(&t, others)
326 }
327
328 #[test]
331 fn clones_in_different_folders_match_on_repository_relative_paths() {
332 let me = agent("Me", "o/r", "C:/a/me/r", "main");
333 let mut other = agent("Other", "o/r", "C:/a/other/clone", "feat");
334 other.dirty_files = vec!["crates/srv/lib.rs".into()];
335 other.dirty_count = 1;
336 other.recent_files = vec![edited("C:/a/other/clone", "crates/srv/lib.rs", 50)];
337
338 for path in ["C:/a/me/r/crates/srv/lib.rs", r"C:\a\me\r\crates\srv\lib.rs", "crates/srv/lib.rs"] {
339 let r = ask(q(Some(path), None, None, None), &me, std::slice::from_ref(&other));
340 assert_eq!(r.len(), 1, "{path}");
341 assert_eq!(r[0].agent, "Other");
342 let kinds: Vec<MatchKind> = r[0].matches.iter().map(|m| m.kind).collect();
343 assert_eq!(kinds, vec![MatchKind::Dirty, MatchKind::Edited]);
344 assert_eq!(r[0].matches[0].since_ms, Some(50));
345 assert!(r[0].matches[0].detail.contains("crates/srv/lib.rs"));
346 }
347
348 let stranger = WorkFacts { dirty_files: vec!["crates/srv/lib.rs".into()], ..agent("S", "o/other", "C:/s", "main") };
350 assert!(ask(q(Some("crates/srv/lib.rs"), None, None, None), &me, &[stranger]).is_empty());
351 }
352
353 #[test]
354 fn a_folder_matches_files_inside_it_by_prefix() {
355 let me = agent("Me", "o/r", "/me", "main");
356 let mut other = agent("Other", "o/r", "/other", "feat");
357 other.dirty_files = vec!["crates/srv/src/a.rs".into(), "docs/x.md".into()];
358 other.recent_files = vec![edited("/other", "crates/srv/src/b.rs", 9)];
359
360 let r = ask(q(Some("crates/srv"), None, None, None), &me, std::slice::from_ref(&other));
361 assert_eq!(r.len(), 1);
362 assert!(r[0].matches[0].detail.contains("crates/srv/src/a.rs"));
363 assert!(!r[0].matches[0].detail.contains("docs/x.md"));
364 assert!(r[0].matches[1].detail.contains("crates/srv/src/b.rs"));
365
366 assert!(ask(q(Some("crates/sr"), None, None, None), &me, &[other]).is_empty(), "a name prefix isn't a folder");
367 }
368
369 #[test]
370 fn query_matches_words_in_goals_and_todos_case_insensitively() {
371 let me = agent("Me", "o/r", "/me", "main");
372 let mut a = agent("A", "o/x", "/a", "main");
373 a.goal = Some("Add the MuxBus allowlist for presence".into());
374 let mut b = agent("B", "o/y", "/b", "main");
375 b.todos = vec![TodoItem { text: "Wire the muxbus Allowlist check".into(), status: "in_progress".into() }];
376 let mut c = agent("C", "o/z", "/c", "main");
377 c.goal = Some("Allowlists everywhere".into());
378
379 let r = ask(q(None, None, None, Some("muxbus ALLOWLIST")), &me, &[a, b, c]);
380 let got: Vec<(&str, MatchKind)> = r.iter().map(|r| (r.agent.as_str(), r.matches[0].kind)).collect();
381 assert_eq!(got, vec![("A", MatchKind::Goal), ("B", MatchKind::Todo)], "whole words, any order, any case");
382 assert_eq!(r[1].matches[0].detail, "[in_progress] Wire the muxbus Allowlist check");
383 }
384
385 #[test]
386 fn branch_matches_within_the_repository() {
387 let me = agent("Me", "o/r", "/me", "main");
388 let a = agent("A", "o/r", "/a", "agenty/x");
389 let b = agent("B", "o/other", "/b", "agenty/x");
390 let r = ask(q(None, None, Some("agenty/x"), None), &me, &[a, b]);
391 assert_eq!(r.len(), 1);
392 assert_eq!(r[0].agent, "A");
393 assert_eq!(r[0].matches[0].kind, MatchKind::Branch);
394 }
395
396 #[test]
397 fn with_nothing_named_the_callers_repository_is_the_question() {
398 let me = agent("Me", "o/r", "/me", "main");
399 let mut a = agent("A", "o/r", "/a", "feat");
400 a.dirty_files = vec!["x.rs".into()];
401 a.dirty_count = 12;
402 let b = agent("B", "o/r", "/b", "fix");
403 let c = agent("C", "o/else", "/c", "main");
404 let r = ask(WhoQuery::default(), &me, &[b, a, c]);
405 let got: Vec<(&str, MatchKind)> = r.iter().map(|r| (r.agent.as_str(), r.matches[0].kind)).collect();
406 assert_eq!(got, vec![("A", MatchKind::Dirty), ("B", MatchKind::Repo)], "ordered by strength");
407 assert!(r[0].matches[0].detail.contains("x.rs (+11 more)"), "{}", r[0].matches[0].detail);
408 assert_eq!(r[1].matches[0].detail, "working in o/r on branch fix");
409 }
410
411 #[test]
412 fn an_explicit_repo_wins_and_bad_input_is_explained() {
413 let me = agent("Me", "o/r", "/me", "main");
414 let mut a = agent("A", "agentmuxai/agentmux", "/a", "feat");
415 a.dirty_files = vec!["src/x.rs".into()];
416 let r = ask(q(Some("src/x.rs"), Some("https://github.com/AgentMuxAI/agentmux.git"), None, None), &me, &[a]);
417 assert_eq!(r.len(), 1);
418
419 assert!(resolve(&q(Some("a.rs"), None, None, None), None, &[]).is_err(), "relative path, no repository");
420 assert!(resolve(&WhoQuery::default(), None, &[]).is_err(), "nothing asked");
421 assert!(resolve(&q(None, Some("not a repo"), None, None), None, &[]).is_err());
422 }
423
424 #[test]
425 fn an_absolute_path_in_another_agents_clone_resolves_through_it() {
426 let me = WorkFacts { agent: "Me".into(), ..Default::default() };
427 let mut a = agent("A", "o/r", "/a/clone", "feat");
428 a.dirty_files = vec!["lib.rs".into()];
429 let b = WorkFacts { dirty_files: vec!["lib.rs".into()], ..agent("B", "o/r", "/b/clone", "main") };
430 let r = ask(q(Some("/a/clone/lib.rs"), None, None, None), &me, &[a, b]);
431 let names: Vec<&str> = r.iter().map(|r| r.agent.as_str()).collect();
432 assert_eq!(names, vec!["A", "B"], "both clones of o/r have lib.rs uncommitted");
433 }
434
435 #[test]
436 fn a_path_in_no_known_clone_is_compared_as_written() {
437 let me = WorkFacts { agent: "Me".into(), ..Default::default() };
438 let other = WorkFacts {
439 agent: "O".into(),
440 recent_files: vec![FactFile { path: "/tmp/notes/a.md".into(), rel: None, tool: "Write".into(), ts_ms: 3 }],
441 ..Default::default()
442 };
443 let r = ask(q(Some("/tmp/notes"), None, None, None), &me, &[other]);
444 assert_eq!(r.len(), 1);
445 assert_eq!(r[0].matches[0].kind, MatchKind::Edited);
446 }
447
448 #[test]
449 fn results_order_by_strength_then_recency() {
450 let me = agent("Me", "o/r", "/me", "main");
451 let mut old = agent("Old", "o/r", "/old", "x");
452 old.recent_files = vec![edited("/old", "a.rs", 10)];
453 let mut new = agent("New", "o/r", "/new", "y");
454 new.recent_files = vec![edited("/new", "a.rs", 20)];
455 let mut dirty = agent("Dirty", "o/r", "/d", "z");
456 dirty.dirty_files = vec!["a.rs".into()];
457 let r = ask(q(Some("a.rs"), None, None, None), &me, &[old, new, dirty]);
458 let names: Vec<&str> = r.iter().map(|r| r.agent.as_str()).collect();
459 assert_eq!(names, vec!["Dirty", "New", "Old"]);
460 }
461}