use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::sync::Mutex;
use anyhow::Context;
use serde::Deserialize;
use chrono::{DateTime, Utc};
use serde::Deserializer;
use crate::collect::environment;
use crate::collect::run::{Env, FailureKind, RunFailure, Runner};
use crate::collect::tracker::{OpenFailure, Tracker, Trackers};
use crate::config::Project;
use crate::model::types::{Bead, Dependency, Edge, Status};
pub fn parse_beads(s: &str) -> anyhow::Result<Vec<Bead>> {
let rows: Vec<Row> =
serde_json::from_str(s).context("bd --json returned a shape we do not understand")?;
let written: Vec<serde_json::Map<String, serde_json::Value>> =
serde_json::from_str(s).context("bd --json returned a shape we do not understand")?;
Ok(rows
.into_iter()
.zip(written)
.map(|(row, written)| row.into_bead(values_of(&written)))
.collect())
}
fn values_of(row: &serde_json::Map<String, serde_json::Value>) -> BTreeMap<String, String> {
let mut values = BTreeMap::new();
for (field, value) in row {
match object_written_either_way(value) {
Some(members) => values.extend(
members
.iter()
.filter_map(|(key, member)| Some((format!("{field}.{key}"), text_of(member)?))),
),
None => {
if let Some(text) = text_of(value) {
values.insert(field.clone(), text);
}
}
}
}
values
}
fn text_of(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(text) if !text.is_empty() => Some(text.clone()),
serde_json::Value::Number(_) | serde_json::Value::Bool(_) => Some(value.to_string()),
_ => None,
}
}
#[derive(Deserialize)]
struct Row {
id: String,
#[serde(deserialize_with = "null_is_default")]
title: String,
#[serde(deserialize_with = "null_is_unrecognised")]
status: Status,
#[serde(default, deserialize_with = "null_is_default")]
priority: u8,
#[serde(default, deserialize_with = "null_is_default")]
issue_type: String,
#[serde(default, deserialize_with = "empty_is_none")]
parent: Option<String>,
#[serde(default, deserialize_with = "none_is_empty")]
dependencies: Vec<RowDependency>,
#[serde(default, deserialize_with = "text_of_each_value")]
metadata: BTreeMap<String, String>,
#[serde(default)]
owner: Option<String>,
#[serde(default)]
assignee: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
notes: Option<String>,
#[serde(default)]
updated_at: Option<DateTime<Utc>>,
#[serde(default)]
started_at: Option<DateTime<Utc>>,
#[serde(default)]
closed_at: Option<DateTime<Utc>>,
#[serde(default)]
defer_until: Option<DateTime<Utc>>,
}
#[derive(Deserialize)]
struct RowDependency {
depends_on_id: String,
#[serde(rename = "type")]
edge: Edge,
}
impl Row {
fn into_bead(self, values: BTreeMap<String, String>) -> Bead {
let row = self;
Bead {
values,
id: row.id,
title: row.title,
status: row.status,
priority: row.priority,
issue_type: row.issue_type,
parent: row.parent,
dependencies: row
.dependencies
.into_iter()
.map(|dependency| Dependency {
on: dependency.depends_on_id,
edge: dependency.edge,
})
.collect(),
metadata: row.metadata,
owner: row.owner,
assignee: row.assignee,
description: row.description,
notes: row.notes,
updated_at: row.updated_at,
started_at: row.started_at,
closed_at: row.closed_at,
defer_until: row.defer_until,
}
}
}
fn none_is_empty<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<RowDependency>, D::Error> {
Ok(Option::<Vec<RowDependency>>::deserialize(d)?.unwrap_or_default())
}
fn empty_is_none<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
Ok(Option::<String>::deserialize(d)?.filter(|parent| !parent.is_empty()))
}
fn null_is_default<'de, D, T>(d: D) -> Result<T, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de> + Default,
{
Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
}
fn null_is_unrecognised<'de, D: Deserializer<'de>>(d: D) -> Result<Status, D::Error> {
Ok(Option::<Status>::deserialize(d)?.unwrap_or_else(|| Status::Other(String::new())))
}
fn text_of_each_value<'de, D: Deserializer<'de>>(
d: D,
) -> Result<BTreeMap<String, String>, D::Error> {
Ok(Option::<serde_json::Value>::deserialize(d)?
.as_ref()
.and_then(object_written_either_way)
.map(|fields| text_of_each(fields.into_owned()))
.unwrap_or_default())
}
fn object_written_either_way(
value: &serde_json::Value,
) -> Option<std::borrow::Cow<'_, serde_json::Map<String, serde_json::Value>>> {
match value {
serde_json::Value::Object(fields) => Some(std::borrow::Cow::Borrowed(fields)),
serde_json::Value::String(spelled) => match serde_json::from_str(spelled) {
Ok(serde_json::Value::Object(fields)) => Some(std::borrow::Cow::Owned(fields)),
_ => None,
},
_ => None,
}
}
fn text_of_each(fields: serde_json::Map<String, serde_json::Value>) -> BTreeMap<String, String> {
fields
.into_iter()
.map(|(key, value)| match value {
serde_json::Value::String(text) => (key, text),
written => (key, written.to_string()),
})
.collect()
}
#[derive(Deserialize)]
struct BlockedRow {
id: String,
#[serde(default)]
blocked_by: Vec<String>,
}
pub struct Cli<'r> {
runner: &'r dyn Runner,
ambient: Option<String>,
without_a_probe: Mutex<BTreeSet<String>>,
}
impl<'r> Cli<'r> {
pub fn new(runner: &'r dyn Runner) -> Self {
Self {
runner,
ambient: environment::ambient_credential(),
without_a_probe: Mutex::default(),
}
}
}
impl Trackers for Cli<'_> {
fn of(&self, project: &Project) -> Result<Box<dyn Tracker + '_>, OpenFailure> {
let env = environment::tracker_env(self.runner, project, self.ambient.as_deref())?;
Ok(Box::new(Reader {
runner: self.runner,
name: project.name.clone(),
path: project.path.clone(),
env,
without_a_probe: &self.without_a_probe,
}))
}
}
struct Reader<'r> {
runner: &'r dyn Runner,
name: String,
path: PathBuf,
env: Env,
without_a_probe: &'r Mutex<BTreeSet<String>>,
}
impl Reader<'_> {
fn asked(&self, subcommand: &[&str]) -> Result<String, RunFailure> {
let named = self.path.to_string_lossy();
let mut argv = vec!["-C", named.as_ref(), "--readonly"];
argv.extend_from_slice(subcommand);
self.runner
.run("bd", &argv, Some(&self.path), &self.env)
.map_err(|failure| failure.reading(subcommand[0]))
}
fn working_root(&self) -> Result<String, RunFailure> {
let out = self.asked(&["sql", "--json", WORKING_ROOT])?;
let rows: Vec<HashRow> =
serde_json::from_str(&out).map_err(|e| RunFailure::parse("bd", e).reading("sql"))?;
rows.into_iter()
.next()
.map(|row| row.h)
.ok_or_else(|| RunFailure::parse("bd", "the answer holds no row").reading("sql"))
}
fn wisps(&self) -> Result<String, RunFailure> {
self.asked(&["query", EPHEMERAL, "--all", "--limit", "0", "--json"])
}
}
impl Tracker for Reader<'_> {
fn fingerprint(&self) -> Option<Result<String, RunFailure>> {
if self.without_a_probe.lock().unwrap().contains(&self.name) {
return None;
}
match self.working_root() {
Err(failure) if failure.kind == FailureKind::Unsupported => {
self.without_a_probe
.lock()
.unwrap()
.insert(self.name.clone());
None
}
answer => Some(answer),
}
}
fn all(&self) -> Result<Vec<Bead>, RunFailure> {
let out = self.asked(&["list", "--all", "--limit", "0", "--json"])?;
let mut beads = rows(&out, "list")?;
beads.extend(rows(&self.wisps()?, "query")?);
Ok(beads)
}
fn ready(&self) -> Result<BTreeSet<String>, RunFailure> {
let out = self.asked(&["ready", "--limit", "0", "--json"])?;
Ok(rows(&out, "ready")?
.into_iter()
.map(|bead| bead.id)
.collect())
}
fn blocked(&self) -> Result<BTreeMap<String, Vec<String>>, RunFailure> {
let out = self.asked(&["blocked", "--json"])?;
let blocked: Vec<BlockedRow> = serde_json::from_str(&out)
.map_err(|e| RunFailure::parse("bd", e).reading("blocked"))?;
Ok(blocked
.into_iter()
.map(|row| (row.id, row.blocked_by))
.collect())
}
}
const WORKING_ROOT: &str = "SELECT dolt_hashof_db() AS h";
#[derive(Deserialize)]
struct HashRow {
h: String,
}
const EPHEMERAL: &str = "ephemeral=true";
fn rows(out: &str, read: &str) -> Result<Vec<Bead>, RunFailure> {
parse_beads(out).map_err(|e| RunFailure::parse("bd", e.root_cause()).reading(read))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::types::{Dependency, Edge, Status};
const FIXTURE: &str = include_str!("../../tests/fixtures/bd_list.json");
fn fixture() -> Vec<Bead> {
parse_beads(FIXTURE).expect("the captured rows parse")
}
fn row(id: &str) -> Bead {
fixture()
.into_iter()
.find(|b| b.id == id)
.unwrap_or_else(|| panic!("{id} is in the fixture"))
}
#[test]
fn parses_every_row() {
assert_eq!(fixture().len(), 7);
}
const JOINED: &str = include_str!("../../tests/fixtures/joined_bd_list.json");
#[test]
fn a_captured_row_carries_the_description_the_notes_and_the_owner() {
let rows = parse_beads(JOINED).expect("the captured rows parse");
let bead = rows
.iter()
.find(|b| b.id == "orb-9fw")
.expect("orb-9fw is in the capture");
assert!(
bead.description
.as_deref()
.is_some_and(|said| said.starts_with("`orbital` reads a repository")),
"{:?}",
bead.description
);
assert!(
bead.notes
.as_deref()
.is_some_and(|said| said.starts_with("Correction to this bead's roster")),
"{:?}",
bead.notes
);
assert_eq!(bead.owner.as_deref(), Some("mira@orbital.invalid"));
}
#[test]
fn a_row_without_a_description_or_notes_parses_with_neither() {
let bead = row("bdi-2bb.4");
assert_eq!(bead.description, None);
assert_eq!(bead.notes, None);
}
#[test]
fn a_captured_row_carries_every_edge_out_of_it() {
assert_eq!(
row("bdi-2bb.4").dependencies,
vec![
Dependency {
on: "bdi-2bb".to_string(),
edge: Edge::ParentChild,
},
Dependency {
on: "bdi-2bb.3".to_string(),
edge: Edge::Blocks,
},
Dependency {
on: "bdi-2bb.9".to_string(),
edge: Edge::Blocks,
},
]
);
}
#[test]
fn statuses_map_onto_the_enum() {
assert_eq!(row("bdi-r5l").status, Status::InProgress);
assert_eq!(row("bdi-2bb").status, Status::Open);
assert_eq!(row("bdi-2bb.9").status, Status::Closed);
}
#[test]
fn every_status_spelling_bd_writes_is_recognised() {
let spellings = ["open", "in_progress", "blocked", "closed", "deferred"];
let expected = [
Status::Open,
Status::InProgress,
Status::Blocked,
Status::Closed,
Status::Deferred,
];
for (spelling, want) in spellings.iter().zip(expected) {
let json = format!(r#"[{{"id":"x","title":"t","status":"{spelling}"}}]"#);
assert_eq!(parse_beads(&json).unwrap()[0].status, want);
}
}
#[test]
fn a_status_a_later_bd_invents_is_kept_rather_than_rejected() {
let json = r#"[{"id":"x","title":"t","status":"marinating"}]"#;
let beads = parse_beads(json).expect("an unknown status still parses");
assert_eq!(beads[0].status, Status::Other("marinating".to_string()));
}
#[test]
fn an_edge_a_later_bd_invents_is_kept_rather_than_rejected() {
let json = r#"[{"id":"x","title":"t","status":"open","dependencies":[
{"depends_on_id":"y","type":"discovered-by"}]}]"#;
let beads = parse_beads(json).expect("an unknown edge still parses");
assert_eq!(
beads[0].dependencies,
vec![Dependency {
on: "y".to_string(),
edge: Edge::Other("discovered-by".to_string()),
}]
);
}
#[test]
fn metadata_is_carried_inline_and_absent_metadata_is_an_empty_map() {
let carrying = row("bdi-r5l");
assert_eq!(
carrying.metadata.get("agent_pane").map(String::as_str),
Some("wCW:p2M")
);
assert!(row("bdi-2bb.3").metadata.is_empty());
}
#[test]
fn a_rows_fields_are_carried_under_the_names_bd_spells_them() {
let rows = r#"[
{"id":"a","title":"t","status":"open","issue_type":"feature",
"external_ref":"https://jira.invalid/browse/HELIO-412",
"metadata":{"jira":"ATLAS-19","helio.ticket":"HELIO-9"}}
]"#;
let fields = &parse_beads(rows).expect("the row parses")[0].values;
assert_eq!(
fields.get("external_ref").map(String::as_str),
Some("https://jira.invalid/browse/HELIO-412")
);
assert_eq!(fields.get("id").map(String::as_str), Some("a"));
assert_eq!(
fields.get("issue_type").map(String::as_str),
Some("feature")
);
assert_eq!(
fields.get("metadata.jira").map(String::as_str),
Some("ATLAS-19")
);
assert_eq!(
fields.get("metadata.helio.ticket").map(String::as_str),
Some("HELIO-9"),
"a key holding a dot of its own is named by the whole of it"
);
}
#[test]
fn a_row_carries_every_value_a_badge_could_draw_and_nothing_that_is_not_one() {
let rows = r#"[
{"id":"a","title":"t","status":"open","priority":1,"pinned":true,
"external_ref":null,"parent":"",
"dependencies":[{"depends_on_id":"b","type":"blocks"}],
"metadata":{"jira":"ATLAS-19"}}
]"#;
let fields = &parse_beads(rows).expect("the row parses")[0].values;
assert_eq!(fields.get("priority").map(String::as_str), Some("1"));
assert_eq!(fields.get("pinned").map(String::as_str), Some("true"));
for absent in ["external_ref", "parent", "dependencies", "metadata"] {
assert_eq!(fields.get(absent), None, "{absent} is no value to draw");
}
}
#[test]
fn a_member_of_an_object_is_one_value_on_the_same_terms_as_a_field() {
let rows = r#"[
{"id":"a","title":"t","status":"open",
"metadata":{"attempts":3,"waiting":false,"phase":"vacuum-soak",
"cleared":null,"note":"",
"seats":["ada","grace"],"budget":{"hours":4}}}
]"#;
let values = &parse_beads(rows).expect("the row parses")[0].values;
assert_eq!(
values.get("metadata.attempts").map(String::as_str),
Some("3")
);
assert_eq!(
values.get("metadata.waiting").map(String::as_str),
Some("false")
);
assert_eq!(
values.get("metadata.phase").map(String::as_str),
Some("vacuum-soak")
);
for absent in [
"metadata.cleared",
"metadata.note",
"metadata.seats",
"metadata.budget",
] {
assert_eq!(values.get(absent), None, "{absent} is no value to draw");
}
}
#[test]
fn a_metadata_value_that_is_not_a_string_is_read_as_its_text() {
let rows = r#"[
{"id":"a","title":"t","status":"open",
"metadata":{"blocks_backstop_removal":true}},
{"id":"b","title":"t","status":"open",
"metadata":{"attempts":3,"working_topic":"x"}}
]"#;
let beads = parse_beads(rows).expect("one non-string value does not lose a tracker");
assert_eq!(
beads[0].metadata.get("blocks_backstop_removal"),
Some(&"true".to_string())
);
assert_eq!(beads[1].metadata.get("attempts"), Some(&"3".to_string()));
assert_eq!(
beads[1].metadata.get("working_topic"),
Some(&"x".to_string()),
"a string keeps its own text, without the quotes JSON writes it in"
);
}
#[test]
fn a_metadata_written_as_a_string_is_read_as_the_object_it_spells() {
let rows = r#"[
{"id":"a","title":"t","status":"open","metadata":"{}"},
{"id":"b","title":"t","status":"open",
"metadata":"{\"phase\":\"vacuum-soak\",\"attempts\":3}"}
]"#;
let beads = parse_beads(rows).expect("a metadata written as a string still parses");
assert!(beads[0].metadata.is_empty());
assert_eq!(
beads[1].metadata.get("phase"),
Some(&"vacuum-soak".to_string())
);
assert_eq!(
beads[1].metadata.get("attempts"),
Some(&"3".to_string()),
"a value inside the string is read the way one inside an object is"
);
}
#[test]
fn a_metadata_that_spells_no_object_is_read_as_none() {
let rows = r#"[
{"id":"a","title":"t","status":"open","metadata":"the sails"},
{"id":"b","title":"t","status":"open","metadata":7}
]"#;
let beads = parse_beads(rows).expect("neither costs the tracker it is in");
assert!(beads[0].metadata.is_empty());
assert!(beads[1].metadata.is_empty());
}
#[test]
fn a_field_written_null_reads_as_the_field_bd_left_out() {
let rows = r#"[{"id":"a","title":null,"status":"open",
"priority":null,"issue_type":null,"metadata":null,
"owner":null,"updated_at":null}]"#;
let beads = parse_beads(rows).expect("a null field does not lose a tracker");
assert_eq!(beads[0].title, "");
assert_eq!(beads[0].priority, 0);
assert_eq!(beads[0].issue_type, "");
assert!(beads[0].metadata.is_empty());
}
#[test]
fn a_null_status_is_a_status_bdi_does_not_recognise() {
let rows = r#"[{"id":"a","title":"t","status":null}]"#;
let beads = parse_beads(rows).expect("a null status does not lose a tracker");
assert_eq!(beads[0].status, Status::Other(String::new()));
}
#[test]
fn a_row_that_names_no_title_or_no_status_is_still_an_error() {
assert!(parse_beads(r#"[{"id":"a","status":"open"}]"#).is_err());
assert!(parse_beads(r#"[{"id":"a","title":"t"}]"#).is_err());
}
#[test]
fn the_timestamps_the_age_rules_need_follow_the_row() {
let closed = row("bdi-2bb.9");
assert!(closed.started_at.is_some());
assert!(closed.closed_at.is_some());
let open = row("bdi-2bb");
assert_eq!(open.started_at, None);
assert_eq!(open.closed_at, None);
assert!(open.updated_at.is_some());
}
#[test]
fn an_unclaimed_bead_has_no_assignee() {
assert_eq!(row("bdi-2bb.9").assignee.as_deref(), Some("Graeme Foster"));
assert_eq!(row("bdi-2bb").assignee, None);
}
#[test]
fn issue_type_distinguishes_the_root_epic_from_its_tasks() {
assert_eq!(row("bdi-2bb").issue_type, "epic");
assert_eq!(row("bdi-2bb.9").issue_type, "task");
}
#[test]
fn a_wrongly_typed_field_is_an_error_not_a_default() {
let bad = r#"[{"id":"x","title":"t","status":"open","priority":"high"}]"#;
assert!(parse_beads(bad).is_err());
}
#[test]
fn work_in_flight_ranks_ahead_of_work_that_is_finished() {
let mut statuses = vec![
Status::Closed,
Status::Open,
Status::Other("marinating".to_string()),
Status::InProgress,
Status::Deferred,
Status::Blocked,
];
statuses.sort_by_key(Status::rank);
assert_eq!(
statuses,
vec![
Status::InProgress,
Status::Blocked,
Status::Open,
Status::Deferred,
Status::Closed,
Status::Other("marinating".to_string()),
]
);
assert!(Status::Closed.is_closed());
assert!(!Status::Open.is_closed());
}
use crate::collect::environment::CREDENTIAL_VAR;
use crate::collect::run::testing::FakeRunner;
use crate::collect::run::FailureKind;
use crate::collect::tracker::Trackers;
use crate::config::{Command, Project};
use std::path::PathBuf;
fn project_dir() -> PathBuf {
PathBuf::from("/nowhere/a-project")
}
fn spelled(subcommand: &str) -> String {
format!("bd -C {} --readonly {subcommand}", project_dir().display())
}
fn credentialled() -> Env {
Env::from([(CREDENTIAL_VAR.to_string(), "hunter2".to_string())])
}
fn opened(runner: &FakeRunner) -> Reader<'_> {
Reader {
runner,
name: "atlas".to_string(),
path: project_dir(),
env: credentialled(),
without_a_probe: Box::leak(Box::default()),
}
}
fn ambient_project() -> Project {
Project {
name: "atlas".to_string(),
path: project_dir(),
environment_command: None,
credential_command: None,
poll: true,
badges: Vec::new(),
worktrees: Vec::new(),
}
}
fn launched_with<'a>(runner: &'a FakeRunner, ambient: Option<&str>) -> Cli<'a> {
Cli {
runner,
ambient: ambient.map(str::to_string),
without_a_probe: Mutex::default(),
}
}
const DIRENV: &str = "direnv exec .";
fn entering_the_directory() -> String {
format!("{DIRENV} env -0")
}
#[test]
fn a_tracker_opened_for_a_project_is_read_in_the_environment_its_config_asks_for() {
let runner = FakeRunner::default()
.with(
&entering_the_directory(),
"BEADS_DOLT_PASSWORD=the-projects-own-password",
)
.with(&spelled(TRACKER_CALL), FIXTURE)
.with(&spelled(WISP_CALL), "[]");
let project = Project {
environment_command: Some(Command::Line(DIRENV.to_string())),
..ambient_project()
};
let cli = launched_with(&runner, Some("the-launching-shells-password"));
let tracker = cli.of(&project).expect("the directory can be entered");
tracker.all().expect("the tracker answers");
let call = runner.call(&spelled(TRACKER_CALL));
assert_eq!(call.cwd.as_deref(), Some(project_dir().as_path()));
assert_eq!(
call.env,
Env::from([(
CREDENTIAL_VAR.to_string(),
"the-projects-own-password".to_string()
)]),
"the credential entering the directory produced is the one bd was given"
);
}
#[test]
fn a_project_configuring_nothing_is_read_on_the_ambient_credential() {
let runner = FakeRunner::default().with(&spelled("ready --limit 0 --json"), "[]");
let cli = launched_with(&runner, Some("hunter2"));
let tracker = cli
.of(&ambient_project())
.expect("nothing is run to open an ambient project");
tracker.ready().expect("the tracker answers");
assert_eq!(
runner.call(&spelled("ready --limit 0 --json")).env,
credentialled()
);
}
#[test]
fn a_project_whose_directory_cannot_be_entered_fails_before_bd_is_asked_anything() {
let runner = FakeRunner::default().failing(
&entering_the_directory(),
RunFailure::unstartable("direnv", "No such file or directory"),
);
let project = Project {
environment_command: Some(Command::Line(DIRENV.to_string())),
..ambient_project()
};
let failure = launched_with(&runner, None)
.of(&project)
.err()
.expect("the project cannot be opened");
assert_eq!(failure, OpenFailure::NoEnvironment);
assert!(
runner
.calls()
.iter()
.all(|call| !call.argv.starts_with("bd ")),
"bd was asked something for a project that could not be opened"
);
}
const TRACKER_CALL: &str = "list --all --limit 0 --json";
const WISP_CALL: &str = "query ephemeral=true --all --limit 0 --json";
const WISPS: &str = include_str!("../../tests/fixtures/bd_wisps.json");
const PROBE_CALL: &str = "sql --json SELECT dolt_hashof_db() AS h";
const A_WORKING_ROOT: &str = "24eg8eff89bggt3t50ft6lctiu9rlpts";
#[test]
fn the_working_root_is_one_hash_out_of_one_statement() {
let runner = FakeRunner::default().with(
&spelled(PROBE_CALL),
&format!(r#"[{{"h":"{A_WORKING_ROOT}"}}]"#),
);
let root = opened(&runner)
.fingerprint()
.expect("bd has a probe")
.expect("the tracker answered its working root");
assert_eq!(root, A_WORKING_ROOT);
assert_eq!(
runner.call(&spelled(PROBE_CALL)).env,
credentialled(),
"the probe reaches the tracker on the project's own credential"
);
}
fn cannot_run_the_probe() -> RunFailure {
RunFailure {
kind: FailureKind::Unsupported,
program: "bd".to_string(),
detail: "bd cannot run that against this tracker".to_string(),
unreadable: None,
}
}
fn did_not_answer_the_probe() -> RunFailure {
RunFailure {
kind: FailureKind::Unavailable,
program: "bd".to_string(),
detail: "bd could not reach the tracker".to_string(),
unreadable: None,
}
}
fn probes(runner: &FakeRunner) -> usize {
runner
.calls()
.iter()
.filter(|call| call.argv == spelled(PROBE_CALL))
.count()
}
#[test]
fn a_tracker_found_to_have_no_probe_is_not_probed_again_that_run() {
let runner = FakeRunner::default().failing(&spelled(PROBE_CALL), cannot_run_the_probe());
let cli = launched_with(&runner, None);
let project = ambient_project();
let first = cli.of(&project).expect("opened").fingerprint();
let second = cli.of(&project).expect("opened").fingerprint();
assert!(
first.is_none(),
"the refusal is a tracker with no probe: {first:?}"
);
assert!(second.is_none(), "and stays one: {second:?}");
assert_eq!(probes(&runner), 1, "the probe was paid for once");
}
#[test]
fn a_server_that_did_not_answer_the_probe_is_probed_again_on_the_next_refresh() {
let runner =
FakeRunner::default().failing(&spelled(PROBE_CALL), did_not_answer_the_probe());
let cli = launched_with(&runner, None);
let project = ambient_project();
for refresh in 1..=2 {
let failure = cli
.of(&project)
.expect("opened")
.fingerprint()
.expect("a server has a probe")
.expect_err("the server did not answer");
assert_eq!(failure.kind, FailureKind::Unavailable);
assert_eq!(probes(&runner), refresh, "one probe per refresh");
}
}
#[test]
fn no_probe_is_remembered_for_the_tracker_that_refused_and_not_its_neighbours() {
let harbour = Project {
name: "harbour".to_string(),
path: PathBuf::from("/tmp/harbour"),
..ambient_project()
};
let harbours_probe = format!("bd -C {} --readonly {PROBE_CALL}", harbour.path.display());
let runner = FakeRunner::default()
.failing(&spelled(PROBE_CALL), cannot_run_the_probe())
.with(&harbours_probe, &format!(r#"[{{"h":"{A_WORKING_ROOT}"}}]"#));
let cli = launched_with(&runner, None);
assert!(cli
.of(&ambient_project())
.expect("opened")
.fingerprint()
.is_none());
let root = cli
.of(&harbour)
.expect("opened")
.fingerprint()
.expect("harbour's server has a probe")
.expect("and answered it");
assert_eq!(root, A_WORKING_ROOT);
}
#[test]
fn a_probe_that_answers_no_row_is_a_failure_rather_than_a_hash() {
let runner = FakeRunner::default().with(&spelled(PROBE_CALL), "[]");
let failure = opened(&runner)
.fingerprint()
.expect("bd has a probe")
.expect_err("no row is no answer");
assert_eq!(failure.kind, FailureKind::Parse);
}
#[test]
fn a_probe_answering_something_else_is_a_failure_rather_than_a_hash() {
let runner = FakeRunner::default().with(&spelled(PROBE_CALL), "no such function");
let failure = opened(&runner)
.fingerprint()
.expect("bd has a probe")
.expect_err("an answer that is not the row is no answer");
assert_eq!(failure.kind, FailureKind::Parse);
}
#[test]
fn the_tracker_is_read_in_the_projects_directory_with_its_credential() {
let runner = FakeRunner::default()
.with(&spelled(TRACKER_CALL), FIXTURE)
.with(&spelled(WISP_CALL), "[]");
let beads = opened(&runner).all().unwrap();
assert_eq!(beads.len(), 7);
for subcommand in [TRACKER_CALL, WISP_CALL] {
let call = runner.call(&spelled(subcommand));
assert_eq!(call.cwd.as_deref(), Some(project_dir().as_path()));
assert_eq!(call.env, credentialled());
}
}
#[test]
fn a_tracker_answers_with_its_wisps_as_well_as_its_permanent_beads() {
let runner = FakeRunner::default()
.with(&spelled(TRACKER_CALL), FIXTURE)
.with(&spelled(WISP_CALL), WISPS);
let beads = opened(&runner).all().unwrap();
let ids: Vec<&str> = beads.iter().map(|bead| bead.id.as_str()).collect();
assert!(
ids.contains(&"bdi-7ao.17.2"),
"the wisp under a bead: {ids:?}"
);
assert!(
ids.contains(&"bdi-wisp-w3m"),
"the free-standing wisp: {ids:?}"
);
assert_eq!(beads.len(), 9, "both answers, neither replacing the other");
}
#[test]
fn a_row_naming_its_dependencies_as_null_still_parses() {
let json = r#"[{"id":"nix-wisp-gvi","title":"t","status":"open",
"issue_type":"molecule","parent":null,"dependencies":null}]"#;
let bead = &parse_beads(json).expect("the row parses")[0];
assert_eq!(bead.dependencies, vec![]);
}
#[test]
fn a_wisp_carries_the_edge_that_hangs_it_under_a_bead_where_it_has_one() {
let wisps = parse_beads(WISPS).expect("the captured wisps parse");
let by_id = |id: &str| {
wisps
.iter()
.find(|wisp| wisp.id == id)
.unwrap_or_else(|| panic!("{id} is in the fixture"))
.clone()
};
assert_eq!(
by_id("bdi-7ao.17.2").dependencies,
vec![Dependency {
on: "bdi-7ao.17".to_string(),
edge: Edge::ParentChild,
}]
);
assert_eq!(by_id("bdi-wisp-w3m").dependencies, vec![]);
}
const MOLECULE: &str = include_str!("../../tests/fixtures/bd_wisp_molecule.json");
#[test]
fn the_capture_keeps_a_field_bdi_does_not_read() {
let run = parse_beads(MOLECULE).expect("the captured molecule parses");
assert!(
MOLECULE.contains(r#""await_type""#),
"the capture still carries the key this is about"
);
assert_eq!(run.len(), 17, "the molecule and its steps");
assert_eq!(
run.iter()
.map(|bead| bead.dependencies.len())
.sum::<usize>(),
37,
"the edges bd wrote between them"
);
assert_eq!(
run.iter()
.filter(|bead| bead.issue_type == "molecule")
.count(),
1
);
}
#[test]
fn bd_omits_what_it_has_nothing_for_rather_than_writing_null() {
let rows: serde_json::Value = serde_json::from_str(MOLECULE).expect("the capture is json");
fn nulls(value: &serde_json::Value, at: &str, found: &mut Vec<String>) {
match value {
serde_json::Value::Null => found.push(at.to_string()),
serde_json::Value::Object(fields) => {
for (key, field) in fields {
nulls(field, &format!("{at}.{key}"), found);
}
}
serde_json::Value::Array(items) => {
for (i, item) in items.iter().enumerate() {
nulls(item, &format!("{at}[{i}]"), found);
}
}
_ => {}
}
}
let mut found = Vec::new();
nulls(&rows, "", &mut found);
assert_eq!(found, Vec::<String>::new(), "nulls bd wrote");
}
#[test]
fn a_row_carries_every_bead_it_depends_on_and_the_kind_of_each() {
let json = r#"[{"id":"p-1.4","title":"t","status":"open","dependencies":[
{"issue_id":"p-1.4","depends_on_id":"p-1","type":"parent-child"},
{"issue_id":"p-1.4","depends_on_id":"p-1.3","type":"blocks"}]}]"#;
let bead = &parse_beads(json).expect("the row parses")[0];
assert_eq!(
bead.dependencies,
vec![
Dependency {
on: "p-1".to_string(),
edge: Edge::ParentChild,
},
Dependency {
on: "p-1.3".to_string(),
edge: Edge::Blocks,
},
]
);
}
#[test]
fn ready_ids_returns_the_set_bd_considers_startable() {
let out = r#"[{"id":"p-1.1","title":"a","status":"open"},
{"id":"p-1.3","title":"b","status":"open"}]"#;
let runner = FakeRunner::default().with(&spelled("ready --limit 0 --json"), out);
let got = opened(&runner).ready().unwrap();
assert!(got.contains("p-1.1"));
assert!(got.contains("p-1.3"));
assert!(
!got.contains("p-1.4"),
"a bead bd did not list is not ready"
);
}
#[test]
fn blocked_by_carries_every_blocker_not_only_the_one_the_tree_shows() {
let out = r#"[{"id":"p-1.9","title":"a","status":"blocked","blocked_by_count":2,
"blocked_by":["p-1.2","p-1.5"]},
{"id":"p-1.11","title":"b","status":"open","blocked_by_count":1,
"blocked_by":["p-1.10"]}]"#;
let runner = FakeRunner::default().with(&spelled("blocked --json"), out);
let got = opened(&runner).blocked().unwrap();
assert_eq!(
got.get("p-1.9").map(Vec::as_slice),
Some(["p-1.2".to_string(), "p-1.5".to_string()].as_slice())
);
assert_eq!(got.len(), 2);
assert_eq!(got.get("p-1.1"), None);
}
#[test]
fn a_tracker_that_refuses_the_credential_reaches_the_caller_classified() {
let runner = FakeRunner::default().failing(
&spelled(TRACKER_CALL),
RunFailure {
kind: FailureKind::Auth,
program: "bd".to_string(),
detail: "bd was refused the tracker's credential".to_string(),
unreadable: None,
},
);
let failure = opened(&runner).all().unwrap_err();
assert_eq!(failure.kind, FailureKind::Auth);
}
#[test]
fn a_listing_that_will_not_parse_names_the_read_and_where_it_broke() {
let row = r#"[{"id":"atl-1","title":42,"status":"open"}]"#;
let runner = FakeRunner::default().with(&spelled(TRACKER_CALL), row);
let unreadable = opened(&runner)
.all()
.unwrap_err()
.unreadable
.expect("a parse failure knows what would not parse");
assert_eq!(unreadable.read, "list");
assert_eq!(
unreadable.cause,
"invalid type: integer `42`, expected a string at line 1 column 25"
);
}
#[test]
fn a_wisp_that_will_not_parse_names_the_read_that_carried_it() {
let row = r#"[{"id":"atl-2","title":42,"status":"open"}]"#;
let runner = FakeRunner::default()
.with(&spelled(TRACKER_CALL), "[]")
.with(&spelled(WISP_CALL), row);
let unreadable = opened(&runner)
.all()
.unwrap_err()
.unreadable
.expect("a parse failure knows what would not parse");
assert_eq!(unreadable.read, "query");
}
#[test]
fn an_answer_that_is_not_text_names_the_read_it_came_from() {
let runner = FakeRunner::default().failing(
&spelled("blocked --json"),
RunFailure::parse("bd", "invalid utf-8 sequence of 1 bytes from index 3"),
);
let unreadable = opened(&runner)
.blocked()
.unwrap_err()
.unreadable
.expect("a parse failure knows what would not parse");
assert_eq!(unreadable.read, "blocked");
}
#[test]
fn output_bd_could_not_have_written_is_a_parse_failure_not_an_unreachable_tracker() {
let runner = FakeRunner::default().with(&spelled("blocked --json"), "not json at all");
let failure = opened(&runner).blocked().unwrap_err();
assert_eq!(failure.kind, FailureKind::Parse);
}
#[test]
fn every_call_names_the_tracker_outright_and_refuses_writes() {
let runner = FakeRunner::default()
.with(&spelled(TRACKER_CALL), FIXTURE)
.with(&spelled(WISP_CALL), "[]");
opened(&runner).all().unwrap();
for call in runner.calls() {
let after_the_program = call
.argv
.strip_prefix("bd ")
.unwrap_or_else(|| panic!("{} is not a bd call", call.argv));
assert!(
after_the_program
.starts_with(&format!("-C {} --readonly ", project_dir().display())),
"the tracker is left to the working directory in: {}",
call.argv
);
}
}
#[test]
fn a_captured_row_carries_the_bead_it_hangs_under() {
assert_eq!(row("bdi-2bb.4").parent.as_deref(), Some("bdi-2bb"));
assert_eq!(row("bdi-2bb").parent.as_deref(), Some("bdi-7ao"));
}
#[test]
fn a_root_has_no_parent_however_bd_spells_the_absence() {
for spelling in [r#","parent":null"#, r#","parent":"""#, ""] {
let out = format!(r#"[{{"id":"p-1","title":"a","status":"open"{spelling}}}]"#);
assert_eq!(
parse_beads(&out).expect("the row parses")[0].parent,
None,
"on {spelling:?}"
);
}
}
}