use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::Path;
use serde::Serialize;
use crate::config::{Config, Project};
use crate::model::types::Bead;
use crate::model::types::{Pane, PaneKey, PaneStatus};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum JoinSource {
AgentPane,
DisplayAgent,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct BeadKey {
pub project: String,
pub id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AgentRef {
pub pane: PaneKey,
pub pane_status: PaneStatus,
pub title: Option<String>,
pub source: JoinSource,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(tag = "conflict", rename_all = "kebab-case")]
pub enum Conflict {
BeadAndPaneDisagree {
bead: BeadKey,
named_by_bead: PaneKey,
named_by_pane: PaneKey,
},
SeveralPanesNameOneBead { bead: BeadKey, panes: Vec<PaneKey> },
SeveralBeadsNameOnePane {
pane: PaneKey,
caption: Option<String>,
beads: Vec<BeadKey>,
},
PaneInAnotherProject {
bead: BeadKey,
pane: PaneKey,
pane_project: Option<String>,
},
PaneIdInSeveralSessions {
bead: BeadKey,
pane_id: String,
sessions: Vec<String>,
},
}
impl Conflict {
pub fn refused_panes(&self) -> Vec<PaneKey> {
match self {
Conflict::BeadAndPaneDisagree { named_by_pane, .. } => vec![named_by_pane.clone()],
Conflict::SeveralPanesNameOneBead { panes, .. } => panes.clone(),
Conflict::SeveralBeadsNameOnePane { pane, .. } => vec![pane.clone()],
Conflict::PaneInAnotherProject { pane, .. } => vec![pane.clone()],
Conflict::PaneIdInSeveralSessions {
pane_id, sessions, ..
} => sessions
.iter()
.map(|session| PaneKey {
session: session.clone(),
id: pane_id.clone(),
})
.collect(),
}
}
}
pub struct ProjectRows<'a> {
pub project: &'a str,
pub rows: &'a [Bead],
}
pub struct Listed<'a> {
pub panes: &'a [Pane],
pub out_of_reach: &'a BTreeSet<String>,
}
#[cfg(feature = "testing")]
static NOTHING_MISSED: BTreeSet<String> = BTreeSet::new();
#[cfg(feature = "testing")]
impl<'a> Listed<'a> {
pub fn all(panes: &'a [Pane]) -> Self {
Self {
panes,
out_of_reach: &NOTHING_MISSED,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Joined {
pub agents: BTreeMap<BeadKey, AgentRef>,
pub refused: BTreeMap<BeadKey, Conflict>,
pub out_of_reach: BTreeSet<BeadKey>,
pub conflicts: Vec<Conflict>,
}
pub fn project_of<'a>(pane: &Pane, projects: &'a [Project]) -> Option<&'a Project> {
project_holding(&pane.cwd, projects)
.or_else(|| project_holding(pane.cwd_in_the_main_working_tree()?, projects))
}
fn project_holding<'a>(path: &Path, projects: &'a [Project]) -> Option<&'a Project> {
projects
.iter()
.filter_map(|p| Some((p.holds(path)?, p)))
.max_by_key(|(depth, _)| *depth)
.map(|(_, project)| project)
}
pub fn resolve(trees: &[ProjectRows<'_>], listed: Listed<'_>, cfg: &Config) -> Joined {
let Listed {
panes,
out_of_reach: panes_out_of_reach,
} = listed;
let mut live_under: BTreeMap<&str, Vec<&Pane>> = BTreeMap::new();
for pane in panes {
live_under
.entry(pane.pane_id.as_str())
.or_default()
.push(pane);
}
let live: BTreeMap<PaneKey, &Pane> = panes.iter().map(|p| (p.key(), p)).collect();
let pane_project: BTreeMap<PaneKey, Option<&str>> = panes
.iter()
.map(|p| {
(
p.key(),
project_of(p, &cfg.projects).map(|q| q.name.as_str()),
)
})
.collect();
let mut projects_holding: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for tree in trees {
for row in tree.rows {
projects_holding
.entry(row.id.as_str())
.or_default()
.insert(tree.project);
}
}
let mut conflicts: Vec<Conflict> = Vec::new();
let mut refused: BTreeMap<BeadKey, Conflict> = BTreeMap::new();
let mut out_of_reach: BTreeSet<BeadKey> = BTreeSet::new();
let mut claims: BTreeMap<PaneKey, BTreeSet<BeadKey>> = BTreeMap::new();
let mut claimed_pane: BTreeMap<BeadKey, PaneKey> = BTreeMap::new();
for tree in trees {
for row in tree.rows {
let Some(named) = row.metadata.get(&cfg.join.pane_key) else {
continue;
};
let bead = BeadKey {
project: tree.project.to_string(),
id: row.id.clone(),
};
if panes_out_of_reach.contains(named.as_str()) {
out_of_reach.insert(bead);
continue;
}
let Some(holding) = live_under.get(named.as_str()) else {
continue;
};
let pane = match holding.as_slice() {
[pane] => *pane,
several => {
let ambiguous = Conflict::PaneIdInSeveralSessions {
bead: bead.clone(),
pane_id: named.clone(),
sessions: several.iter().map(|p| p.session.clone()).collect(),
};
conflicts.push(ambiguous.clone());
refused.insert(bead, ambiguous);
continue;
}
};
let key = pane.key();
let holds = pane_project[&key];
if holds != Some(tree.project) {
let elsewhere = Conflict::PaneInAnotherProject {
bead: bead.clone(),
pane: key,
pane_project: holds.map(str::to_string),
};
conflicts.push(elsewhere.clone());
refused.insert(bead, elsewhere);
continue;
}
claimed_pane.insert(bead.clone(), key.clone());
claims.entry(key).or_default().insert(bead);
}
}
let mut agents: BTreeMap<BeadKey, AgentRef> = BTreeMap::new();
for (key, beads) in claims {
let pane = live[&key];
let mut named: Vec<BeadKey> = beads.into_iter().collect();
if named.len() == 1 {
agents.insert(named.remove(0), agent_ref(pane, JoinSource::AgentPane));
} else {
let shared = Conflict::SeveralBeadsNameOnePane {
pane: key,
caption: pane.caption().map(str::to_string),
beads: named.clone(),
};
conflicts.push(shared.clone());
for bead in named {
refused.insert(bead, shared.clone());
}
}
}
let mut named_by: BTreeMap<BeadKey, BTreeSet<PaneKey>> = BTreeMap::new();
for pane in panes {
let Some(id) = &pane.display_agent else {
continue;
};
let Some(project) = pane_project[&pane.key()] else {
continue;
};
if !cfg.reads(project) {
continue;
}
let Some(holders) = projects_holding.get(id.as_str()) else {
continue;
};
if holders.contains(project) {
named_by
.entry(BeadKey {
project: project.to_string(),
id: id.clone(),
})
.or_default()
.insert(pane.key());
} else {
for holder in holders {
conflicts.push(Conflict::PaneInAnotherProject {
bead: BeadKey {
project: holder.to_string(),
id: id.clone(),
},
pane: pane.key(),
pane_project: Some(project.to_string()),
});
}
}
}
for (bead, from_panes) in named_by {
let mut inferred: Vec<PaneKey> = from_panes.into_iter().collect();
if inferred.len() > 1 {
conflicts.push(Conflict::SeveralPanesNameOneBead {
bead,
panes: inferred,
});
continue;
}
let only = inferred.remove(0);
if let Some(claimed) = claimed_pane.get(&bead).filter(|p| **p != only) {
conflicts.push(Conflict::BeadAndPaneDisagree {
bead: bead.clone(),
named_by_bead: claimed.clone(),
named_by_pane: only.clone(),
});
}
agents
.entry(bead)
.or_insert_with(|| agent_ref(live[&only], JoinSource::DisplayAgent));
}
conflicts.sort();
conflicts.dedup();
Joined {
agents,
refused,
out_of_reach,
conflicts,
}
}
fn agent_ref(pane: &Pane, source: JoinSource) -> AgentRef {
AgentRef {
pane: pane.key(),
pane_status: pane.agent_status.clone(),
title: pane.caption().map(str::to_string),
source,
}
}
pub fn unattributed<'a>(panes: &'a [Pane], joined: &Joined) -> Vec<&'a Pane> {
let taken: HashSet<&PaneKey> = joined.agents.values().map(|a| &a.pane).collect();
panes.iter().filter(|p| !taken.contains(&p.key())).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::collect::bd::parse_beads;
use crate::collect::herdr::parse_agent_list;
use crate::config::Join;
use crate::model::tree::Nesting;
use crate::model::types::testing::{key as pane_key, A_SESSION};
use crate::model::types::Bead;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
const BEADS: &str = include_str!("../../tests/fixtures/display_agent_bd_list.json");
const PANES: &str = include_str!("../../tests/fixtures/herdr_agent_list.json");
const JOINED_BEADS: &str = include_str!("../../tests/fixtures/joined_bd_list.json");
const JOINED_PANES: &str = include_str!("../../tests/fixtures/joined_herdr_agent_list.json");
const FIXTURE_PROJECT_PATH: &str = "/tmp/bdi-ground/orbital";
fn project(name: &str, path: &str) -> Project {
Project {
name: name.to_string(),
path: path.into(),
environment_command: None,
credential_command: None,
poll: true,
badges: Vec::new(),
worktrees: Vec::new(),
}
}
fn project_working_in(name: &str, path: &str, worktrees: &[&str]) -> Project {
Project {
worktrees: worktrees.iter().map(Into::into).collect(),
..project(name, path)
}
}
fn root_row(beads: &[Bead]) -> String {
beads
.iter()
.find(|b| b.dependencies.is_empty())
.expect("a root row")
.id
.clone()
}
fn rows(json: &str) -> Vec<Bead> {
let beads = parse_beads(json).expect("the rows parse");
let root = root_row(&beads);
Nesting::of(&beads)
.assemble(&root)
.expect("the rows assemble")
.beads
}
fn panes(agents: &str) -> Vec<Pane> {
parse_agent_list(
A_SESSION,
&format!(r#"{{"id":"cli:agent:list","result":{{"agents":[{agents}]}}}}"#),
)
.expect("the panes parse")
}
fn panes_in(session: &str, agents: &str) -> Vec<Pane> {
parse_agent_list(
session,
&format!(r#"{{"id":"cli:agent:list","result":{{"agents":[{agents}]}}}}"#),
)
.expect("the panes parse")
}
fn key(project: &str, id: &str) -> BeadKey {
BeadKey {
project: project.to_string(),
id: id.to_string(),
}
}
#[test]
fn a_disagreement_names_the_panes_it_left_holding_nothing() {
assert_eq!(
Conflict::SeveralBeadsNameOnePane {
pane: pane_key("w:p1"),
caption: None,
beads: vec![key("proj", "p-1"), key("proj", "p-2")],
}
.refused_panes(),
vec![pane_key("w:p1")]
);
assert_eq!(
Conflict::PaneInAnotherProject {
bead: key("proj", "p-1"),
pane: pane_key("w:p2"),
pane_project: Some("ferry".to_string()),
}
.refused_panes(),
vec![pane_key("w:p2")]
);
assert_eq!(
Conflict::SeveralPanesNameOneBead {
bead: key("proj", "p-1"),
panes: vec![pane_key("w:p3"), pane_key("w:p4")],
}
.refused_panes(),
vec![pane_key("w:p3"), pane_key("w:p4")]
);
}
#[test]
fn the_pane_a_bead_named_keeps_its_bead_when_another_pane_names_it_too() {
assert_eq!(
Conflict::BeadAndPaneDisagree {
bead: key("proj", "p-1"),
named_by_bead: pane_key("w:p1"),
named_by_pane: pane_key("w:p2"),
}
.refused_panes(),
vec![pane_key("w:p2")]
);
}
#[test]
fn an_id_several_sessions_hold_names_a_refused_pane_in_each_of_them() {
assert_eq!(
Conflict::PaneIdInSeveralSessions {
bead: key("proj", "p-1"),
pane_id: "w:p1".to_string(),
sessions: vec![A_SESSION.to_string(), "beacon".to_string()],
}
.refused_panes(),
vec![
pane_key("w:p1"),
PaneKey {
session: "beacon".to_string(),
id: "w:p1".to_string(),
},
]
);
}
const A_BEAD_NAMING_W_P1: &str = r#"[
{"id":"p-1","title":"one","status":"in_progress",
"metadata":{"agent_pane":"w:p1"}}
]"#;
#[test]
fn a_pane_id_held_by_two_sessions_is_refused_and_names_the_sessions() {
let beads = rows(A_BEAD_NAMING_W_P1);
let mut live = panes(r#"{"pane_id":"w:p1","cwd":"/srv/proj","agent_status":"idle"}"#);
live.extend(panes_in(
"beacon",
r#"{"pane_id":"w:p1","cwd":"/srv/proj","agent_status":"working"}"#,
));
let cfg = vec![project("proj", "/srv/proj")];
let joined = resolve(
&[ProjectRows {
project: "proj",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
let ambiguous = Conflict::PaneIdInSeveralSessions {
bead: key("proj", "p-1"),
pane_id: "w:p1".to_string(),
sessions: vec![A_SESSION.to_string(), "beacon".to_string()],
};
assert_eq!(joined.agents, BTreeMap::new());
assert_eq!(joined.conflicts, vec![ambiguous.clone()]);
assert_eq!(
joined.refused,
BTreeMap::from([(key("proj", "p-1"), ambiguous)])
);
assert_eq!(
unattributed(&live, &joined).len(),
2,
"both panes are still live, and neither is anybody's"
);
}
#[test]
fn a_pane_naming_the_bead_resolves_it_where_its_id_alone_could_not() {
let beads = rows(A_BEAD_NAMING_W_P1);
let mut live = panes(r#"{"pane_id":"w:p1","cwd":"/srv/proj","agent_status":"idle"}"#);
live.extend(panes_in(
"beacon",
r#"{"pane_id":"w:p1","cwd":"/srv/proj","agent_status":"working","display_agent":"p-1"}"#,
));
let cfg = vec![project("proj", "/srv/proj")];
let joined = resolve(
&[ProjectRows {
project: "proj",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
let a = pane_of(&joined, "proj", "p-1");
assert_eq!(
a.pane,
PaneKey {
session: "beacon".to_string(),
id: "w:p1".to_string(),
}
);
assert_eq!(a.source, JoinSource::DisplayAgent);
assert_eq!(
joined.conflicts.len(),
1,
"the key's ambiguity still stands"
);
assert!(matches!(
joined.conflicts[0],
Conflict::PaneIdInSeveralSessions { .. }
));
}
fn pane_of<'a>(joined: &'a Joined, project: &str, id: &str) -> &'a AgentRef {
joined
.agents
.get(&key(project, id))
.unwrap_or_else(|| panic!("{project}/{id} resolved to a pane"))
}
fn loose(panes: &[Pane], joined: &Joined) -> Vec<String> {
let mut ids: Vec<String> = unattributed(panes, joined)
.into_iter()
.map(|p| p.pane_id.clone())
.collect();
ids.sort();
ids
}
#[test]
fn a_captured_bead_takes_the_captured_pane_it_names() {
let beads = rows(JOINED_BEADS);
let live = parse_agent_list(A_SESSION, JOINED_PANES).expect("the fixture parses");
let cfg = vec![project("orbital", FIXTURE_PROJECT_PATH)];
let joined = resolve(
&[ProjectRows {
project: "orbital",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
let a = pane_of(&joined, "orbital", "orb-9fw.22");
assert_eq!(a.pane, pane_key("wM:pK"));
assert_eq!(a.source, JoinSource::AgentPane);
assert_eq!(joined.conflicts, vec![]);
assert_eq!(joined.refused, BTreeMap::new());
}
#[test]
fn the_bead_naming_its_pane_resolves_exactly() {
let beads = rows(
r#"[
{"id":"p-1","title":"root","status":"open"},
{"id":"p-1.1","title":"work","status":"in_progress",
"dependencies":[{"depends_on_id":"p-1","type":"parent-child"}],
"metadata":{"agent_pane":"w:p1"}}
]"#,
);
let live = panes(
r#"{"pane_id":"w:p1","cwd":"/home/user/proj","agent_status":"working",
"title":"doing the work"}"#,
);
let cfg = vec![project("proj", "/home/user/proj")];
let joined = resolve(
&[ProjectRows {
project: "proj",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
let a = pane_of(&joined, "proj", "p-1.1");
assert_eq!(a.pane, pane_key("w:p1"));
assert_eq!(a.source, JoinSource::AgentPane);
assert_eq!(a.pane_status, PaneStatus::Working);
assert_eq!(a.title.as_deref(), Some("doing the work"));
assert_eq!(joined.conflicts, vec![]);
}
#[test]
fn an_agent_carries_the_panes_caption() {
let beads = rows(
r#"[{"id":"p-1","title":"root","status":"in_progress",
"metadata":{"agent_pane":"w:p1"}}]"#,
);
let live = panes(
r#"{"pane_id":"w:p1","cwd":"/home/user/proj","agent_status":"working",
"title":"the title",
"state_labels":{"idle":"the idle line","working":"the working line"}}"#,
);
let cfg = vec![project("proj", "/home/user/proj")];
let joined = resolve(
&[ProjectRows {
project: "proj",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
assert_eq!(
pane_of(&joined, "proj", "p-1").title.as_deref(),
Some("the working line")
);
}
#[test]
fn the_exact_direction_reads_the_configured_key() {
let beads = rows(
r#"[
{"id":"p-1","title":"root","status":"open",
"metadata":{"herdr_pane":"w:p1","agent_pane":"w:p2"}}
]"#,
);
let live = panes(
r#"{"pane_id":"w:p1","cwd":"/home/user/proj","agent_status":"idle"},
{"pane_id":"w:p2","cwd":"/home/user/proj","agent_status":"idle"}"#,
);
let cfg = vec![project("proj", "/home/user/proj")];
let join = Join {
pane_key: "herdr_pane".to_string(),
};
let joined = resolve(
&[ProjectRows {
project: "proj",
rows: &beads,
}],
Listed::all(&live),
&Config {
join,
..Config::naming(cfg)
},
);
assert_eq!(pane_of(&joined, "proj", "p-1").pane, pane_key("w:p1"));
}
#[test]
fn a_pane_naming_its_bead_resolves_as_inferred() {
let beads = rows(BEADS);
let live = parse_agent_list(A_SESSION, PANES).expect("the fixture parses");
let cfg = vec![project("orbital", FIXTURE_PROJECT_PATH)];
let joined = resolve(
&[ProjectRows {
project: "orbital",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
let a = pane_of(&joined, "orbital", "orb-2kd.3");
assert_eq!(a.pane, pane_key("wG:p5"));
assert_eq!(a.source, JoinSource::DisplayAgent);
assert_eq!(joined.agents.len(), 1);
assert_eq!(joined.conflicts, vec![]);
}
#[test]
fn a_bead_naming_a_dead_pane_resolves_to_nothing() {
let beads = rows(
r#"[{"id":"p-1","title":"root","status":"in_progress",
"metadata":{"agent_pane":"w:pGONE"}}]"#,
);
let live = panes(r#"{"pane_id":"w:p1","cwd":"/home/user/proj","agent_status":"idle"}"#);
let cfg = vec![project("proj", "/home/user/proj")];
let joined = resolve(
&[ProjectRows {
project: "proj",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
assert_eq!(joined.agents, BTreeMap::new());
assert_eq!(joined.conflicts, vec![]);
}
#[test]
fn a_bead_naming_a_pane_this_run_is_missing_is_told_from_one_naming_a_dead_pane() {
let beads = rows(
r#"[{"id":"p-1","title":"the seat that went quiet","status":"in_progress",
"metadata":{"agent_pane":"w:pQUIET"}},
{"id":"p-2","title":"the seat that died","status":"in_progress",
"metadata":{"agent_pane":"w:pGONE"}}]"#,
);
let live = panes(r#"{"pane_id":"w:p1","cwd":"/home/user/proj","agent_status":"idle"}"#);
let cfg = vec![project("proj", "/home/user/proj")];
let missing = BTreeSet::from(["w:pQUIET".to_string()]);
let joined = resolve(
&[ProjectRows {
project: "proj",
rows: &beads,
}],
Listed {
panes: &live,
out_of_reach: &missing,
},
&Config::naming(cfg),
);
assert_eq!(joined.agents, BTreeMap::new(), "neither pane is live");
assert_eq!(
joined.out_of_reach,
BTreeSet::from([key("proj", "p-1")]),
"only the claim naming the pane this run is missing"
);
assert_eq!(joined.conflicts, vec![]);
}
#[test]
fn a_live_pane_carrying_an_id_a_quiet_session_also_held_is_awarded_to_nobody() {
let beads = rows(
r#"[{"id":"p-1","title":"root","status":"in_progress",
"metadata":{"agent_pane":"w:p1"}}]"#,
);
let live = panes(r#"{"pane_id":"w:p1","cwd":"/home/user/proj","agent_status":"working"}"#);
let cfg = vec![project("proj", "/home/user/proj")];
let missing = BTreeSet::from(["w:p1".to_string()]);
let joined = resolve(
&[ProjectRows {
project: "proj",
rows: &beads,
}],
Listed {
panes: &live,
out_of_reach: &missing,
},
&Config::naming(cfg),
);
assert_eq!(
joined.agents,
BTreeMap::new(),
"the live pane of that id may be the other session's"
);
assert_eq!(joined.out_of_reach, BTreeSet::from([key("proj", "p-1")]));
assert_eq!(joined.conflicts, vec![]);
}
#[test]
fn a_pane_whose_display_agent_names_no_bead_joins_nothing() {
let beads = rows(r#"[{"id":"p-1","title":"root","status":"open"}]"#);
let live = panes(
r#"{"pane_id":"w:p1","cwd":"/home/user/proj","agent_status":"working",
"display_agent":"orch: some-effort"}"#,
);
let cfg = vec![project("proj", "/home/user/proj")];
let joined = resolve(
&[ProjectRows {
project: "proj",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
assert_eq!(joined.agents, BTreeMap::new());
assert_eq!(joined.conflicts, vec![]);
assert_eq!(loose(&live, &joined), vec!["w:p1"]);
}
#[test]
fn the_longest_configured_path_containing_a_pane_wins() {
let cfg = vec![
project("outer", "/home/user/dev"),
project("inner", "/home/user/dev/inner"),
];
assert_eq!(
project_holding(Path::new("/home/user/dev/inner/src"), &cfg).map(|p| p.name.as_str()),
Some("inner")
);
assert_eq!(
project_holding(Path::new("/home/user/dev/other"), &cfg).map(|p| p.name.as_str()),
Some("outer")
);
assert_eq!(project_holding(Path::new("/home/user"), &cfg), None);
}
#[test]
fn a_pane_in_another_worktree_of_the_repository_is_in_the_project() {
let cfg = vec![project_working_in(
"proj",
"/home/user/proj",
&["/home/user/proj", "/tmp/seat-a/wt"],
)];
assert_eq!(
project_holding(Path::new("/tmp/seat-a/wt/src"), &cfg).map(|p| p.name.as_str()),
Some("proj")
);
assert_eq!(project_holding(Path::new("/tmp/seat-b/wt"), &cfg), None);
}
#[test]
fn a_worktree_deeper_than_another_projects_path_wins_the_pane() {
let cfg = vec![
project("outer", "/home/user/dev"),
project_working_in("inner", "/srv/inner", &["/home/user/dev/wt"]),
];
assert_eq!(
project_holding(Path::new("/home/user/dev/wt/src"), &cfg).map(|p| p.name.as_str()),
Some("inner")
);
}
#[test]
fn a_pane_nothing_holds_is_placed_by_where_it_sits_in_the_main_working_tree() {
let cfg = vec![project("proj", "/home/user/proj")];
let mut live =
panes(r#"{"pane_id":"w:p1","cwd":"/tmp/seat-a/wt/src","agent_status":"working"}"#);
let pane = live
.remove(0)
.with_cwd_in_the_main_working_tree(Some(PathBuf::from("/home/user/proj/src")));
assert_eq!(
project_of(&pane, &cfg).map(|p| p.name.as_str()),
Some("proj")
);
}
#[test]
fn a_pane_something_holds_is_placed_by_its_own_directory_first() {
let cfg = vec![
project("seat", "/tmp/seat-a/wt"),
project("main", "/home/user/proj"),
];
let mut live =
panes(r#"{"pane_id":"w:p1","cwd":"/tmp/seat-a/wt/src","agent_status":"working"}"#);
let pane = live
.remove(0)
.with_cwd_in_the_main_working_tree(Some(PathBuf::from("/home/user/proj/src")));
assert_eq!(
project_of(&pane, &cfg).map(|p| p.name.as_str()),
Some("seat")
);
}
#[test]
fn a_pane_read_off_the_wire_is_placed_by_its_directory_alone() {
let cfg = vec![project("proj", "/home/user/proj")];
let live =
panes(r#"{"pane_id":"w:p1","cwd":"/tmp/seat-a/wt/src","agent_status":"working"}"#);
assert_eq!(live[0].cwd_in_the_main_working_tree(), None);
assert_eq!(project_of(&live[0], &cfg), None);
}
#[test]
fn a_pane_placed_by_the_main_working_tree_is_reported_in_that_project() {
let two = rows(
r#"[{"id":"x-1","title":"in project two","status":"in_progress",
"metadata":{"agent_pane":"w:p1"}}]"#,
);
let mut live =
panes(r#"{"pane_id":"w:p1","cwd":"/tmp/seat-a/wt/src","agent_status":"working"}"#);
let pane = live
.remove(0)
.with_cwd_in_the_main_working_tree(Some(PathBuf::from("/home/user/one/src")));
let cfg = vec![
project("one", "/home/user/one"),
project("two", "/home/user/two"),
];
let joined = resolve(
&[ProjectRows {
project: "two",
rows: &two,
}],
Listed::all(&[pane]),
&Config::naming(cfg),
);
assert_eq!(
joined.conflicts,
vec![Conflict::PaneInAnotherProject {
bead: key("two", "x-1"),
pane: pane_key("w:p1"),
pane_project: Some("one".to_string()),
}]
);
}
#[test]
fn a_path_is_matched_by_whole_directories_rather_than_by_text() {
let cfg = vec![project("bead", "/home/user/dev/bead")];
assert_eq!(
project_holding(Path::new("/home/user/dev/beady"), &cfg),
None
);
}
#[test]
fn a_pane_in_no_configured_project_joins_nothing_and_is_unattributed() {
let beads = rows(BEADS);
let live = parse_agent_list(A_SESSION, PANES).expect("the fixture parses");
let cfg = vec![project("orbital", FIXTURE_PROJECT_PATH)];
let joined = resolve(
&[ProjectRows {
project: "orbital",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
assert_eq!(
loose(&live, &joined),
vec!["wE:p1", "wE:p2", "wF:p1", "wF:pD", "wG:p1", "wG:p2", "wG:p4", "wG:p6", "wH:p1",]
);
assert_eq!(joined.conflicts, vec![]);
}
#[test]
fn colliding_prefixes_do_not_cross_attach_an_inferred_agent() {
let one = rows(r#"[{"id":"x-1","title":"in project one","status":"in_progress"}]"#);
let two = rows(r#"[{"id":"x-1","title":"in project two","status":"in_progress"}]"#);
let live = panes(
r#"{"pane_id":"w:p1","cwd":"/home/user/one/src","agent_status":"working",
"display_agent":"x-1"}"#,
);
let cfg = vec![
project("one", "/home/user/one"),
project("two", "/home/user/two"),
];
let joined = resolve(
&[
ProjectRows {
project: "one",
rows: &one,
},
ProjectRows {
project: "two",
rows: &two,
},
],
Listed::all(&live),
&Config::naming(cfg),
);
assert_eq!(pane_of(&joined, "one", "x-1").pane, pane_key("w:p1"));
assert_eq!(
joined.agents.len(),
1,
"project two's x-1 is a different bead"
);
}
#[test]
fn colliding_prefixes_do_not_cross_attach_an_exact_agent() {
let one = rows(r#"[{"id":"x-1","title":"in project one","status":"open"}]"#);
let two = rows(
r#"[{"id":"x-1","title":"in project two","status":"in_progress",
"metadata":{"agent_pane":"w:p1"}}]"#,
);
let live =
panes(r#"{"pane_id":"w:p1","cwd":"/home/user/one/src","agent_status":"working"}"#);
let cfg = vec![
project("one", "/home/user/one"),
project("two", "/home/user/two"),
];
let joined = resolve(
&[
ProjectRows {
project: "one",
rows: &one,
},
ProjectRows {
project: "two",
rows: &two,
},
],
Listed::all(&live),
&Config::naming(cfg),
);
assert_eq!(
joined.agents,
BTreeMap::new(),
"the pane is not project two's to claim"
);
assert_eq!(
joined.conflicts,
vec![Conflict::PaneInAnotherProject {
bead: key("two", "x-1"),
pane: pane_key("w:p1"),
pane_project: Some("one".to_string()),
}]
);
assert_eq!(
joined.refused,
BTreeMap::from([(
key("two", "x-1"),
Conflict::PaneInAnotherProject {
bead: key("two", "x-1"),
pane: pane_key("w:p1"),
pane_project: Some("one".to_string()),
}
)])
);
}
#[test]
fn a_pane_naming_another_projects_bead_does_not_join_and_is_reported() {
let one = rows(r#"[{"id":"a-1","title":"only in one","status":"open"}]"#);
let two = rows(r#"[{"id":"b-1","title":"only in two","status":"open"}]"#);
let live = panes(
r#"{"pane_id":"w:p1","cwd":"/home/user/two","agent_status":"working",
"display_agent":"a-1"}"#,
);
let cfg = vec![
project("one", "/home/user/one"),
project("two", "/home/user/two"),
];
let joined = resolve(
&[
ProjectRows {
project: "one",
rows: &one,
},
ProjectRows {
project: "two",
rows: &two,
},
],
Listed::all(&live),
&Config::naming(cfg),
);
assert_eq!(joined.agents, BTreeMap::new());
assert_eq!(
joined.conflicts,
vec![Conflict::PaneInAnotherProject {
bead: key("one", "a-1"),
pane: pane_key("w:p1"),
pane_project: Some("two".to_string()),
}]
);
assert_eq!(
joined.refused,
BTreeMap::new(),
"project one's bead named no pane, so it is owed no reason it has none: \
what a pane in another project calls itself is not its claim"
);
}
#[test]
fn a_pane_in_a_project_the_scope_left_out_names_nothing_and_is_not_reported() {
let one = rows(r#"[{"id":"x-1","title":"in project one","status":"in_progress"}]"#);
let live = panes(
r#"{"pane_id":"w:p1","cwd":"/home/user/two/src","agent_status":"working",
"display_agent":"x-1"}"#,
);
let cfg = Config::naming(vec![
project("one", "/home/user/one"),
project("two", "/home/user/two"),
])
.scoped_to(&["one".to_string()])
.expect("one is configured");
let joined = resolve(
&[ProjectRows {
project: "one",
rows: &one,
}],
Listed::all(&live),
&cfg,
);
assert_eq!(joined.agents, BTreeMap::new());
assert_eq!(joined.conflicts, vec![]);
}
#[test]
fn a_bead_naming_a_pane_in_a_project_the_scope_left_out_is_told_which_project() {
let beads = rows(
r#"[{"id":"p-1","title":"root","status":"in_progress",
"metadata":{"agent_pane":"w:p1"}}]"#,
);
let live =
panes(r#"{"pane_id":"w:p1","cwd":"/home/user/two/src","agent_status":"working"}"#);
let cfg = Config::naming(vec![
project("one", "/home/user/one"),
project("two", "/home/user/two"),
])
.scoped_to(&["one".to_string()])
.expect("one is configured");
let joined = resolve(
&[ProjectRows {
project: "one",
rows: &beads,
}],
Listed::all(&live),
&cfg,
);
assert_eq!(
joined.conflicts,
vec![Conflict::PaneInAnotherProject {
bead: key("one", "p-1"),
pane: pane_key("w:p1"),
pane_project: Some("two".to_string()),
}]
);
}
#[test]
fn a_bead_naming_a_pane_in_no_configured_project_does_not_join_and_is_reported() {
let beads = rows(
r#"[{"id":"p-1","title":"root","status":"in_progress",
"metadata":{"agent_pane":"w:p1"}}]"#,
);
let live = panes(r#"{"pane_id":"w:p1","cwd":"/tmp","agent_status":"working"}"#);
let cfg = vec![project("proj", "/home/user/proj")];
let joined = resolve(
&[ProjectRows {
project: "proj",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
assert_eq!(joined.agents, BTreeMap::new());
let outside = Conflict::PaneInAnotherProject {
bead: key("proj", "p-1"),
pane: pane_key("w:p1"),
pane_project: None,
};
assert_eq!(joined.conflicts, vec![outside.clone()]);
assert_eq!(
joined.refused,
BTreeMap::from([(key("proj", "p-1"), outside)])
);
}
#[test]
fn the_two_directions_naming_different_panes_is_reported_and_the_beads_key_wins() {
let beads = rows(
r#"[{"id":"p-1","title":"root","status":"in_progress",
"metadata":{"agent_pane":"w:p1"}}]"#,
);
let live = panes(
r#"{"pane_id":"w:p1","cwd":"/home/user/proj","agent_status":"working"},
{"pane_id":"w:p2","cwd":"/home/user/proj","agent_status":"idle",
"display_agent":"p-1"}"#,
);
let cfg = vec![project("proj", "/home/user/proj")];
let joined = resolve(
&[ProjectRows {
project: "proj",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
let a = pane_of(&joined, "proj", "p-1");
assert_eq!(
a.pane,
pane_key("w:p1"),
"the bead's own key is exact and wins"
);
assert_eq!(a.source, JoinSource::AgentPane);
assert_eq!(
joined.conflicts,
vec![Conflict::BeadAndPaneDisagree {
bead: key("proj", "p-1"),
named_by_bead: pane_key("w:p1"),
named_by_pane: pane_key("w:p2"),
}],
"winning is not the same as agreeing"
);
}
#[test]
fn the_two_directions_naming_one_pane_is_not_a_conflict() {
let beads = rows(
r#"[{"id":"p-1","title":"root","status":"in_progress",
"metadata":{"agent_pane":"w:p1"}}]"#,
);
let live = panes(
r#"{"pane_id":"w:p1","cwd":"/home/user/proj","agent_status":"working",
"display_agent":"p-1"}"#,
);
let cfg = vec![project("proj", "/home/user/proj")];
let joined = resolve(
&[ProjectRows {
project: "proj",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
assert_eq!(
pane_of(&joined, "proj", "p-1").source,
JoinSource::AgentPane
);
assert_eq!(joined.conflicts, vec![]);
}
#[test]
fn several_panes_naming_one_bead_leaves_it_unclaimed_and_reported() {
let beads = rows(r#"[{"id":"p-1","title":"root","status":"in_progress"}]"#);
let live = panes(
r#"{"pane_id":"w:p2","cwd":"/home/user/proj","agent_status":"working",
"display_agent":"p-1"},
{"pane_id":"w:p1","cwd":"/home/user/proj","agent_status":"idle",
"display_agent":"p-1"}"#,
);
let cfg = vec![project("proj", "/home/user/proj")];
let joined = resolve(
&[ProjectRows {
project: "proj",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
assert_eq!(joined.agents, BTreeMap::new(), "neither pane wins");
assert_eq!(
joined.conflicts,
vec![Conflict::SeveralPanesNameOneBead {
bead: key("proj", "p-1"),
panes: vec![pane_key("w:p1"), pane_key("w:p2")],
}]
);
assert_eq!(loose(&live, &joined), vec!["w:p1", "w:p2"]);
}
#[test]
fn several_beads_naming_one_pane_leaves_them_all_unclaimed_and_reported() {
let beads = rows(
r#"[
{"id":"p-1","title":"root","status":"open"},
{"id":"p-1.1","title":"one","status":"in_progress",
"dependencies":[{"depends_on_id":"p-1","type":"parent-child"}],
"metadata":{"agent_pane":"w:p1"}},
{"id":"p-1.2","title":"two","status":"in_progress",
"dependencies":[{"depends_on_id":"p-1","type":"parent-child"}],
"metadata":{"agent_pane":"w:p1"}}
]"#,
);
let live = panes(r#"{"pane_id":"w:p1","cwd":"/home/user/proj","agent_status":"working"}"#);
let cfg = vec![project("proj", "/home/user/proj")];
let joined = resolve(
&[ProjectRows {
project: "proj",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
assert_eq!(joined.agents, BTreeMap::new(), "neither bead gets the pane");
let contested = Conflict::SeveralBeadsNameOnePane {
pane: pane_key("w:p1"),
caption: None,
beads: vec![key("proj", "p-1.1"), key("proj", "p-1.2")],
};
assert_eq!(joined.conflicts, vec![contested.clone()]);
assert_eq!(
joined.refused,
BTreeMap::from([
(key("proj", "p-1.1"), contested.clone()),
(key("proj", "p-1.2"), contested),
]),
"each of them claimed the pane, so each is owed the reason it has none"
);
}
#[test]
fn a_contested_pane_carries_its_own_account_of_what_it_is_working_on() {
let beads = rows(
r#"[
{"id":"orb-9fw","title":"orbital v1","status":"open"},
{"id":"orb-2bb.16","title":"a claim its seat moved on from",
"status":"in_progress",
"dependencies":[{"depends_on_id":"orb-9fw","type":"parent-child"}],
"metadata":{"agent_pane":"wM:pG"}},
{"id":"orb-9fw.12","title":"retiring the dep-tree row shape",
"status":"in_progress",
"dependencies":[{"depends_on_id":"orb-9fw","type":"parent-child"}],
"metadata":{"agent_pane":"wM:pG"}},
{"id":"orb-2bb.19","title":"the other claim it moved on from",
"status":"in_progress",
"dependencies":[{"depends_on_id":"orb-9fw","type":"parent-child"}],
"metadata":{"agent_pane":"wM:pG"}}
]"#,
);
let live = parse_agent_list(A_SESSION, JOINED_PANES).expect("the fixture parses");
let cfg = vec![project("orbital", FIXTURE_PROJECT_PATH)];
let joined = resolve(
&[ProjectRows {
project: "orbital",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
assert_eq!(joined.agents, BTreeMap::new(), "none of the three gets it");
assert_eq!(
joined.conflicts,
vec![Conflict::SeveralBeadsNameOnePane {
pane: pane_key("wM:pG"),
caption: Some(
"orb-9fw.12: retiring the dep-tree row shape from fixtures".to_string()
),
beads: vec![
key("orbital", "orb-2bb.16"),
key("orbital", "orb-2bb.19"),
key("orbital", "orb-9fw.12"),
],
}]
);
}
#[test]
fn a_contested_pane_that_says_nothing_about_itself_carries_nothing() {
let beads = rows(
r#"[
{"id":"p-1","title":"root","status":"open"},
{"id":"p-1.1","title":"one","status":"in_progress",
"dependencies":[{"depends_on_id":"p-1","type":"parent-child"}],
"metadata":{"agent_pane":"w:p1"}},
{"id":"p-1.2","title":"two","status":"in_progress",
"dependencies":[{"depends_on_id":"p-1","type":"parent-child"}],
"metadata":{"agent_pane":"w:p1"}}
]"#,
);
let live = panes(r#"{"pane_id":"w:p1","cwd":"/home/user/proj","agent_status":"working"}"#);
let cfg = vec![project("proj", "/home/user/proj")];
let joined = resolve(
&[ProjectRows {
project: "proj",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
assert_eq!(
joined.conflicts,
vec![Conflict::SeveralBeadsNameOnePane {
pane: pane_key("w:p1"),
caption: None,
beads: vec![key("proj", "p-1.1"), key("proj", "p-1.2")],
}]
);
}
#[test]
fn a_bead_whose_exact_claim_was_voided_still_takes_an_uncontested_inferred_pane() {
let beads = rows(
r#"[
{"id":"p-1","title":"root","status":"open"},
{"id":"p-1.1","title":"one","status":"in_progress",
"dependencies":[{"depends_on_id":"p-1","type":"parent-child"}],
"metadata":{"agent_pane":"w:p1"}},
{"id":"p-1.2","title":"two","status":"in_progress",
"dependencies":[{"depends_on_id":"p-1","type":"parent-child"}],
"metadata":{"agent_pane":"w:p1"}}
]"#,
);
let live = panes(
r#"{"pane_id":"w:p1","cwd":"/home/user/proj","agent_status":"working"},
{"pane_id":"w:p9","cwd":"/home/user/proj","agent_status":"working",
"display_agent":"p-1.1"}"#,
);
let cfg = vec![project("proj", "/home/user/proj")];
let joined = resolve(
&[ProjectRows {
project: "proj",
rows: &beads,
}],
Listed::all(&live),
&Config::naming(cfg),
);
let a = pane_of(&joined, "proj", "p-1.1");
assert_eq!(a.pane, pane_key("w:p9"));
assert_eq!(a.source, JoinSource::DisplayAgent);
assert_eq!(
joined.conflicts,
vec![
Conflict::BeadAndPaneDisagree {
bead: key("proj", "p-1.1"),
named_by_bead: pane_key("w:p1"),
named_by_pane: pane_key("w:p9"),
},
Conflict::SeveralBeadsNameOnePane {
pane: pane_key("w:p1"),
caption: None,
beads: vec![key("proj", "p-1.1"), key("proj", "p-1.2")],
},
]
);
}
#[test]
fn the_join_source_serialises_the_way_the_contract_spells_it() {
assert_eq!(
serde_json::to_string(&JoinSource::AgentPane).unwrap(),
r#""agent_pane""#
);
assert_eq!(
serde_json::to_string(&JoinSource::DisplayAgent).unwrap(),
r#""display_agent""#
);
}
#[test]
fn a_conflict_serialises_under_its_own_name() {
let out = serde_json::to_string(&Conflict::SeveralPanesNameOneBead {
bead: key("proj", "p-1"),
panes: vec![pane_key("w:p1")],
})
.unwrap();
assert_eq!(
out,
r#"{"conflict":"several-panes-name-one-bead","bead":{"project":"proj","id":"p-1"},"panes":[{"session":"default","id":"w:p1"}]}"#
);
}
}