From 896ed8f1e687d7c3e42379acf4f47eac9f7c15a6 Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:10:56 +0100 Subject: [PATCH] feat: let a configured project state its bead prefix A blocker in a project bdi has not read was reported as possibly in every project not read, because a prefix could only be learned from a tracker's answer. A project entry now takes an optional `prefix`, and a blocker carrying the prefix one unread project states is reported as held by that project, as `held-by-unread` in the JSON. A project stating another prefix is no longer listed as a place the blocker may be. A config with no `prefix` anywhere draws exactly as before. --- docs/configuration.md | 10 ++ docs/design.md | 17 ++- src/app/collection.rs | 11 +- src/cli.rs | 1 + src/collect/bd.rs | 1 + src/collect/discovery.rs | 3 + src/collect/environment.rs | 4 + src/config.rs | 19 ++++ src/model/join.rs | 1 + src/model/tree.rs | 217 +++++++++++++++++++++++++++++++++---- src/view/phrase.rs | 9 +- tests/snapshot_json.rs | 30 +++++ 12 files changed, 290 insertions(+), 33 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index a3bac9e..ad31aea 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,6 +27,7 @@ path = "/home/you/arkham" name = "dunwich" path = "/srv/work/dunwich" environment_command = "nix develop -c" +prefix = "dun" [[projects]] name = "kadath" @@ -127,6 +128,15 @@ says why. It runs inside the project's environment, and its output is captured rather than passed on a command line, so the password never shows in `ps`. +**`prefix`** is the prefix the project's tracker gives its beads, written as +`bd init --prefix` takes it: `prefix = "dun"` for beads named `dun-7`. `bdi` +learns a prefix from the beads a tracker answers with, so this only matters +for a project it has not read. With it, a blocker +carrying the prefix is drawn as this project's bead, which was not read, and a +blocker carrying another prefix is never put down to this project. A project +stating none may hold any blocker whose prefix no answer carries, so the line +lists it among the projects not read. + **`poll = false`** stops polling this project and relies on something [telling `bdi` when it changed](#telling-bdi-a-project-changed). Nothing then covers for a producer that dies, so the project's mark turns to `?` once diff --git a/docs/design.md b/docs/design.md index 74fbadf..057ccd5 100644 --- a/docs/design.md +++ b/docs/design.md @@ -951,12 +951,15 @@ that project's trees. tracker's beads carry its prefix, the id up to its first `-` as bd reads one, so the prefix says whose the bead would be. The line names the projects whose answers carry the prefix and hold no bead by that id, or the projects that -each hold one. Where no answer carries the prefix, it names the configured +each hold one. Where no answer carries the prefix, it falls to the configured projects that gave no answer, whether refused, unreachable or left out of the run, because nothing can learn the prefix of a tracker that did not answer. -Where every configured project answered, the bead is in a project `bdi` is -not configured to read. A parent no answer holds gets no such line, because -it would have been drawn above the bead rather than beneath it. The tree's +Only a project's config can state it. Where one of them states the prefix, +the line says the bead is in that project, which was not read. Otherwise it +names the ones that may hold it: those stating the prefix, or failing any, +those stating none. Where no configured project may hold it, the bead is in a +project `bdi` is not configured to read. A parent no answer holds gets no such +line, because it would have been drawn above the bead rather than beneath it. The tree's note still counts every bead waiting on something missing, because a fold can hide the line. @@ -2045,8 +2048,10 @@ refusing conflict as `refused`, and one it did not omits the field. `orphaned_dependencies` is every blocker the bead waits on that no tracker holds, `[]` where there is none, each an `id` and a `reason`: `not-held` with the `projects` whose beads carry its prefix, `held-by-several` with the -`projects` that each hold a bead by it, `not-read` with the configured -`projects` that gave no answer, or `unconfigured` with none. `agents` +`projects` that each hold a bead by it, `held-by-unread` with the one +configured `project` that gave no answer and whose config states its prefix, +`not-read` with the configured `projects` that gave no answer and may hold it, +or `unconfigured` with none. `agents` says which agent provider was asked and how that went, so a consumer knows which tier it is reading and which program answered for it: `state` is `answering`, `not-answering` where the provider is there and did not โ€” which diff --git a/src/app/collection.rs b/src/app/collection.rs index 93113af..6d22290 100644 --- a/src/app/collection.rs +++ b/src/app/collection.rs @@ -274,11 +274,11 @@ impl Collection { now: DateTime, ) -> Snapshot { let answered: Vec<(&str, &ProjectWork)> = self.that_answered(cfg).collect(); - let not_read: Vec<&str> = cfg + let not_read: Vec<(&str, Option<&str>)> = cfg .projects .iter() - .map(|project| project.name.as_str()) - .filter(|project| !answered.iter().any(|(answering, _)| answering == project)) + .map(|project| (project.name.as_str(), project.prefix.as_deref())) + .filter(|(project, _)| !answered.iter().any(|(answering, _)| answering == project)) .collect(); let drawn = reaching_across(&answered, ¬_read); @@ -481,10 +481,11 @@ type Drawn<'a> = (&'a str, &'a str, Result, &'a RootUnread>); /// bead waiting on work that answer does not hold is one each tree already /// names as an orphaned dependency. So only a tree naming one is assembled again, across /// every answer, and a run with none reads nothing twice. `not_read` is the -/// configured projects that gave no answer, whichever bead they might hold. +/// configured projects that gave no answer, each with the prefix its config +/// states. fn reaching_across<'a>( answered: &[(&'a str, &'a ProjectWork)], - not_read: &[&'a str], + not_read: &[(&'a str, Option<&'a str>)], ) -> Vec> { let waits_elsewhere = |read: &Result| { read.as_ref() diff --git a/src/cli.rs b/src/cli.rs index 229e0ba..a5a2d44 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1144,6 +1144,7 @@ detached path: PathBuf::from("/srv/work/dunwich"), environment_command: None, credential_command: None, + prefix: None, poll, badges: Vec::new(), worktrees: Vec::new(), diff --git a/src/collect/bd.rs b/src/collect/bd.rs index 509c1b0..9631d82 100644 --- a/src/collect/bd.rs +++ b/src/collect/bd.rs @@ -1025,6 +1025,7 @@ mod tests { path: project_dir(), environment_command: None, credential_command: None, + prefix: None, poll: true, badges: Vec::new(), worktrees: Vec::new(), diff --git a/src/collect/discovery.rs b/src/collect/discovery.rs index 584770c..f8f7fc3 100644 --- a/src/collect/discovery.rs +++ b/src/collect/discovery.rs @@ -75,6 +75,7 @@ pub fn from_the_current_directory( path: root, environment_command: None, credential_command: None, + prefix: None, poll: true, badges: Vec::new(), worktrees, @@ -342,6 +343,7 @@ detached path: PathBuf::from("/srv/work/dunwich"), environment_command: None, credential_command: None, + prefix: None, poll: true, badges: Vec::new(), worktrees: vec![PathBuf::from("/srv/work/dunwich")], @@ -1172,6 +1174,7 @@ path = "/tmp/seat-b/wt/crates/dish" path: PathBuf::from("/srv/loose"), environment_command: None, credential_command: None, + prefix: None, poll: true, badges: Vec::new(), worktrees: Vec::new(), diff --git a/src/collect/environment.rs b/src/collect/environment.rs index 025ab4d..01af20b 100644 --- a/src/collect/environment.rs +++ b/src/collect/environment.rs @@ -288,6 +288,7 @@ mod tests { path: project_dir(), environment_command: None, credential_command: None, + prefix: None, poll: true, badges: Vec::new(), worktrees: Vec::new(), @@ -528,6 +529,7 @@ mod tests { path: project_dir(), environment_command: None, credential_command: Some("op read the/password".to_string()), + prefix: None, poll: true, badges: Vec::new(), worktrees: Vec::new(), @@ -629,6 +631,7 @@ mod tests { path: project_dir(), environment_command: None, credential_command: Some("op read the/password".to_string()), + prefix: None, poll: true, badges: Vec::new(), worktrees: Vec::new(), @@ -688,6 +691,7 @@ mod tests { path: project_dir(), environment_command: None, credential_command: Some(command.to_string()), + prefix: None, poll: true, badges: Vec::new(), worktrees: Vec::new(), diff --git a/src/config.rs b/src/config.rs index 7321994..a122335 100644 --- a/src/config.rs +++ b/src/config.rs @@ -114,6 +114,12 @@ pub struct Project { /// to `ps`, so it stays rather than folding into one. #[serde(default)] pub credential_command: Option, + /// The prefix this project's tracker gives its beads, as `bd init + /// --prefix` takes it. It says which project holds a bead id before that + /// project's tracker has answered, where nothing can learn the prefix + /// from its beads. + #[serde(default)] + pub prefix: Option, /// Whether this project asks for itself every interval, or leaves saying /// its work has moved to whatever reports for it on the inbound channel. /// @@ -1079,6 +1085,7 @@ credential_command = "secret-tool lookup tracker arkham" name = "kadath" path = "/home/user/dev/kadath" credential_command = "cat /home/user/dev/kadath/.beads-password" +prefix = "kad" [[projects.badges]] key = "metadata.delivery_pr" @@ -1165,6 +1172,7 @@ path = "/home/user/dev/cinder" path: PathBuf::from("/home/user/arkham"), environment_command: None, credential_command: Some("secret-tool lookup tracker arkham".to_string()), + prefix: None, poll: true, badges: Vec::new(), worktrees: Vec::new(), @@ -1176,6 +1184,7 @@ path = "/home/user/dev/cinder" credential_command: Some( "cat /home/user/dev/kadath/.beads-password".to_string() ), + prefix: Some("kad".to_string()), poll: true, badges: vec![Badge { key: "metadata.delivery_pr".to_string(), @@ -1458,6 +1467,7 @@ title = ["title", "badge.metadata.jira", "badges"] path: PathBuf::from("/home/user").join(name), environment_command: None, credential_command: None, + prefix: None, poll: true, badges, worktrees: Vec::new(), @@ -1731,6 +1741,15 @@ path = "/home/user/dev/kadath" assert_eq!(cfg.projects[0].environment_command, None); } + /// A config written before a project could state its prefix still + /// parses, and says nothing about whose a bead is. + #[test] + fn a_project_saying_nothing_about_its_prefix_states_none() { + let cfg = Config::from_toml(ONE_PROJECT).expect("parses"); + + assert_eq!(cfg.projects[0].prefix, None); + } + const ONE_ENTERED_WITH_DIRENV: &str = r#" [[projects]] name = "kadath" diff --git a/src/model/join.rs b/src/model/join.rs index 2e5c142..2f02ac7 100644 --- a/src/model/join.rs +++ b/src/model/join.rs @@ -454,6 +454,7 @@ mod tests { path: path.into(), environment_command: None, credential_command: None, + prefix: None, poll: true, badges: Vec::new(), worktrees: Vec::new(), diff --git a/src/model/tree.rs b/src/model/tree.rs index 0958a42..7c89cc9 100644 --- a/src/model/tree.rs +++ b/src/model/tree.rs @@ -117,7 +117,7 @@ pub struct OrphanedDependency { /// A dependency names an id and no project, and a tracker's beads carry its /// prefix โ€” the id up to its first `-`, as bd reads one โ€” so the prefix is /// what says whose the bead would be. A tracker that gave no answer carries -/// a prefix nothing here can learn. +/// a prefix nothing here can learn, so only its config can state it. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(tag = "reason", rename_all = "kebab-case")] pub enum Unreachable { @@ -128,10 +128,14 @@ pub enum Unreachable { /// picks between them. HeldBySeveral { projects: Vec }, /// No tracker that answered carries the id's prefix, and these - /// configured projects gave no answer. + /// configured projects gave no answer and may hold it: the several whose + /// config states the prefix, or else every one stating none. NotRead { projects: Vec }, - /// No tracker that answered carries the id's prefix, and every - /// configured project answered. + /// No tracker that answered carries the id's prefix, and this configured + /// project, which gave no answer, is the one whose config states it. + HeldByUnread { project: String }, + /// No tracker that answered carries the id's prefix, and no configured + /// project that gave no answer may hold it. Unconfigured, } @@ -533,16 +537,17 @@ pub struct Across<'a> { holding: BTreeMap<&'a str, Vec>, /// Which of `answers` hold a bead carrying each prefix. carrying: BTreeMap<&'a str, BTreeSet>, - /// The configured projects that gave no answer. - not_read: Vec, + /// The configured projects that gave no answer, each with the prefix its + /// config states, where it states one. + not_read: Vec<(&'a str, Option<&'a str>)>, } impl<'a> Across<'a> { /// Every project's answer, by its project, and the configured projects - /// that gave none. + /// that gave none, each with the prefix its config states. pub fn of( answers: impl IntoIterator)>, - not_read: impl IntoIterator, + not_read: impl IntoIterator)>, ) -> Self { let (projects, answers): (Vec<&str>, Vec) = answers.into_iter().unzip(); let mut holding: BTreeMap<&str, Vec> = BTreeMap::new(); @@ -558,7 +563,7 @@ impl<'a> Across<'a> { answers, holding, carrying, - not_read: not_read.into_iter().map(str::to_string).collect(), + not_read: not_read.into_iter().collect(), } } @@ -656,7 +661,7 @@ impl<'a> Across<'a> { .map(|id| { let why = self.unreachable(id); let may_block = match why { - Unreachable::NotRead { .. } => true, + Unreachable::NotRead { .. } | Unreachable::HeldByUnread { .. } => true, Unreachable::HeldBySeveral { .. } => self.holding[id] .iter() .any(|&holder| !self.answers[holder].by_id[id].status.is_finished()), @@ -683,14 +688,34 @@ impl<'a> Across<'a> { projects: named(&mut holders.iter().copied()), }; } - match self.carrying.get(prefix_of(id)) { - Some(carriers) => Unreachable::NotHeld { + if let Some(carriers) = self.carrying.get(prefix_of(id)) { + return Unreachable::NotHeld { projects: named(&mut carriers.iter().copied()), - }, - None if self.not_read.is_empty() => Unreachable::Unconfigured, - None => Unreachable::NotRead { - projects: self.not_read.clone(), - }, + }; + } + let not_read_stating = |wanted: &dyn Fn(Option<&str>) -> bool| -> Vec { + self.not_read + .iter() + .filter(|(_, stated)| wanted(*stated)) + .map(|(project, _)| project.to_string()) + .collect() + }; + let is_the_ids = + |stated: &str| prefix_of(id).strip_suffix('-') == Some(stated.trim_end_matches('-')); + let stating = not_read_stating(&|stated| stated.is_some_and(is_the_ids)); + let may_hold = match stating.as_slice() { + [project] => { + return Unreachable::HeldByUnread { + project: project.clone(), + }; + } + [] => not_read_stating(&|stated| stated.is_none()), + _ => stating, + }; + if may_hold.is_empty() { + Unreachable::Unconfigured + } else { + Unreachable::NotRead { projects: may_hold } } } } @@ -1482,8 +1507,12 @@ mod tests { across_without(answers, &[]) } - /// The same, with configured projects whose trackers gave no answer. - fn across_without<'a>(answers: &'a [(&'a str, Vec)], not_read: &[&'a str]) -> Across<'a> { + /// The same, with configured projects whose trackers gave no answer, each + /// with the prefix its config states, where it states one. + fn across_without<'a>( + answers: &'a [(&'a str, Vec)], + not_read: &[(&'a str, Option<&'a str>)], + ) -> Across<'a> { Across::of( answers .iter() @@ -1501,7 +1530,9 @@ mod tests { fn orphaned(id: &str, why: Unreachable) -> OrphanedDependency { let may_block = matches!( why, - Unreachable::NotRead { .. } | Unreachable::HeldBySeveral { .. } + Unreachable::NotRead { .. } + | Unreachable::HeldByUnread { .. } + | Unreachable::HeldBySeveral { .. } ); OrphanedDependency { id: id.to_string(), @@ -1618,7 +1649,7 @@ mod tests { "arkham", &[bead("ark-1", "open", &[dep("fer-2", "blocks")])], )]; - let a = across_without(&answers, &["ferry"]) + let a = across_without(&answers, &[("ferry", None)]) .assemble("arkham", "ark-1") .expect("the rows assemble"); @@ -1633,6 +1664,150 @@ mod tests { ); } + /// A project's config can say what its beads carry before its tracker + /// has answered, so the one project stating the prefix is the one that + /// holds the blocker. + #[test] + fn a_blocker_whose_prefix_an_unread_project_states_is_held_by_that_project() { + let answers = [answer( + "arkham", + &[bead("ark-1", "open", &[dep("fer-2", "blocks")])], + )]; + let a = across_without(&answers, &[("dunwich", None), ("ferry", Some("fer"))]) + .assemble("arkham", "ark-1") + .expect("the rows assemble"); + + assert_eq!( + orphaned_beneath(&a, "ark-1"), + [orphaned( + "fer-2", + Unreachable::HeldByUnread { + project: "ferry".to_string() + } + )] + ); + } + + /// bd trims the dashes off a prefix it is given, so a config stating one + /// the way an id spells it means the same prefix. + #[test] + fn a_stated_prefix_ending_in_a_dash_is_the_prefix_without_it() { + let answers = [answer( + "arkham", + &[bead("ark-1", "open", &[dep("fer-2", "blocks")])], + )]; + let a = across_without(&answers, &[("ferry", Some("fer-"))]) + .assemble("arkham", "ark-1") + .expect("the rows assemble"); + + assert_eq!( + orphaned_beneath(&a, "ark-1"), + [orphaned( + "fer-2", + Unreachable::HeldByUnread { + project: "ferry".to_string() + } + )] + ); + } + + /// Prefixes are uncoordinated, so two configs may state the same one, + /// and then nothing picks between the two. + #[test] + fn a_blocker_whose_prefix_two_unread_projects_state_names_both() { + let answers = [answer( + "arkham", + &[bead("ark-1", "open", &[dep("fer-2", "blocks")])], + )]; + let a = across_without( + &answers, + &[ + ("dunwich", None), + ("ferry", Some("fer")), + ("innsmouth", Some("fer")), + ], + ) + .assemble("arkham", "ark-1") + .expect("the rows assemble"); + + assert_eq!( + orphaned_beneath(&a, "ark-1"), + [orphaned( + "fer-2", + Unreachable::NotRead { + projects: projects(&["ferry", "innsmouth"]) + } + )] + ); + } + + /// A project whose config states another prefix does not hold the + /// blocker, so only the unread projects stating none may. + #[test] + fn an_unread_project_stating_another_prefix_is_not_named() { + let answers = [answer( + "arkham", + &[bead("ark-1", "open", &[dep("inn-4", "blocks")])], + )]; + let a = across_without(&answers, &[("dunwich", None), ("ferry", Some("fer"))]) + .assemble("arkham", "ark-1") + .expect("the rows assemble"); + + assert_eq!( + orphaned_beneath(&a, "ark-1"), + [orphaned( + "inn-4", + Unreachable::NotRead { + projects: projects(&["dunwich"]) + } + )] + ); + } + + /// Where every unread project states a prefix and none is the blocker's, + /// no configured project can hold it. + #[test] + fn a_blocker_whose_prefix_no_project_carries_or_states_is_unconfigured() { + let answers = [answer( + "arkham", + &[bead("ark-1", "open", &[dep("inn-4", "blocks")])], + )]; + let a = across_without(&answers, &[("ferry", Some("fer"))]) + .assemble("arkham", "ark-1") + .expect("the rows assemble"); + + assert_eq!( + orphaned_beneath(&a, "ark-1"), + [orphaned("inn-4", Unreachable::Unconfigured)] + ); + } + + /// A prefix an answer carries says whose the blocker is before any + /// config does. + #[test] + fn a_prefix_an_answer_carries_outranks_one_an_unread_project_states() { + let answers = [ + answer( + "arkham", + &[bead("ark-1", "open", &[dep("dun-9", "blocks")])], + ), + answer("dunwich", &[bead("dun-7", "open", &[])]), + ]; + let a = across_without(&answers, &[("ferry", Some("dun"))]) + .assemble("arkham", "ark-1") + .expect("the rows assemble"); + + assert_eq!( + orphaned_beneath(&a, "ark-1"), + [orphaned( + "dun-9", + Unreachable::NotHeld { + projects: projects(&["dunwich"]) + } + )] + ); + } + #[test] fn a_blocker_two_other_projects_hold_is_drawn_as_held_by_both() { let answers = [ diff --git a/src/view/phrase.rs b/src/view/phrase.rs index 4082729..59b6816 100644 --- a/src/view/phrase.rs +++ b/src/view/phrase.rs @@ -433,6 +433,7 @@ pub fn unreachable(why: &Unreachable) -> String { Unreachable::NotRead { projects } => { format!("not found ยท projects not read: {}", projects.join(", ")) } + Unreachable::HeldByUnread { project } => format!("in {project}, which was not read"), Unreachable::Unconfigured => "not in any configured project".to_string(), } } @@ -1089,7 +1090,10 @@ mod tests { Unreachable::HeldBySeveral { .. } => Some(Unreachable::NotRead { projects: projects(), }), - Unreachable::NotRead { .. } => Some(Unreachable::Unconfigured), + Unreachable::NotRead { .. } => Some(Unreachable::HeldByUnread { + project: "ferry".to_string(), + }), + Unreachable::HeldByUnread { .. } => Some(Unreachable::Unconfigured), Unreachable::Unconfigured => None, }, ) @@ -1962,6 +1966,9 @@ mod tests { assert!(said.contains(project.as_str()), "{said:?}"); } } + if let Unreachable::HeldByUnread { project } = &why { + assert!(said.contains(project.as_str()), "{said:?}"); + } } } diff --git a/tests/snapshot_json.rs b/tests/snapshot_json.rs index 21b0c44..e58fdd7 100644 --- a/tests/snapshot_json.rs +++ b/tests/snapshot_json.rs @@ -1251,6 +1251,36 @@ fn a_bead_waiting_on_one_in_a_project_the_run_left_out_names_that_project_at_the ); } +/// Dunwich's config states the prefix `dun-2e7` carries, so the bead is +/// dunwich's although the run read neither dunwich nor innsmouth. +#[test] +fn a_bead_waiting_on_one_whose_prefix_an_unread_project_states_names_that_project_alone() { + let cfg = Config::from_toml( + r#" +[[projects]] +name = "arkham" +path = "/srv/work/arkham" + +[[projects]] +name = "dunwich" +path = "/srv/work/dunwich" +prefix = "dun" + +[[projects]] +name = "innsmouth" +path = "/srv/work/innsmouth" +"#, + ) + .expect("the config parses") + .scoped_to(&["arkham".to_string()]) + .expect("arkham is configured"); + + assert_eq!( + orphaned_under_the_beacon(&cfg, &arkham_waiting_on_dunwich()), + json!([{"id": "dun-2e7", "reason": "held-by-unread", "project": "dunwich"}]) + ); +} + /// Arkham is the only project configured, and it answered, so the bead is /// in a project `bdi` was never told about. #[test]