use assert_cmd::cargo;
use assert_cmd::prelude::*;
use predicates::str::contains;
use rusqlite::Connection;
use std::process::Command;
use tempfile::TempDir;
fn setup() -> TempDir {
let dir = TempDir::new().unwrap();
Command::new(cargo::cargo_bin!("bmo"))
.current_dir(dir.path())
.arg("init")
.assert()
.success();
dir
}
fn bmo(dir: &TempDir) -> Command {
let mut cmd = Command::new(cargo::cargo_bin!("bmo"));
cmd.current_dir(dir.path());
cmd
}
fn create_issues(dir: &TempDir, n: usize) -> Vec<String> {
(1..=n)
.map(|i| {
bmo(dir)
.args(["issue", "create", "--title", &format!("Issue {i}")])
.assert()
.success();
format!("BMO-{i}")
})
.collect()
}
fn link(dir: &TempDir, ids: &[String], from_idx: usize, rel: &str, to_idx: usize) {
bmo(dir)
.args([
"issue",
"link",
"add",
&ids[from_idx - 1],
rel,
&ids[to_idx - 1],
])
.assert()
.success();
}
fn inject_blocks_edge(dir: &TempDir, from_id: i64, to_id: i64) {
let conn = Connection::open(dir.path().join(".bmo/issues.db")).unwrap();
conn.execute(
"INSERT INTO issue_relations (from_id, to_id, relation) VALUES (?1, ?2, 'blocks')",
rusqlite::params![from_id, to_id],
)
.unwrap();
}
fn setup_with_injected_cycle() -> TempDir {
let dir = setup();
create_issues(&dir, 2);
link(&dir, &["BMO-1".into(), "BMO-2".into()], 1, "blocks", 2);
inject_blocks_edge(&dir, 2, 1);
dir
}
struct CycleCase {
#[allow(dead_code)]
name: &'static str,
issue_count: usize,
setup_links: &'static [(usize, &'static str, usize)],
rejected: (usize, &'static str, usize),
}
static CYCLE_CASES: &[CycleCase] = &[
CycleCase {
name: "blocks direct: A blocks B, then B blocks A",
issue_count: 2,
setup_links: &[(1, "blocks", 2)],
rejected: (2, "blocks", 1),
},
CycleCase {
name: "blocks transitive: A→B→C, then C→A rejected",
issue_count: 3,
setup_links: &[(1, "blocks", 2), (2, "blocks", 3)],
rejected: (3, "blocks", 1),
},
CycleCase {
name: "blocks self-loop: A blocks A",
issue_count: 1,
setup_links: &[],
rejected: (1, "blocks", 1),
},
CycleCase {
name: "depends-on direct: A depends-on B (DAG: B→A), then B depends-on A (DAG: A→B)",
issue_count: 2,
setup_links: &[(1, "depends-on", 2)],
rejected: (2, "depends-on", 1),
},
CycleCase {
name: "cross-kind: A blocks B (A→B), then A depends-on B (B→A) closes cycle",
issue_count: 2,
setup_links: &[(1, "blocks", 2)],
rejected: (1, "depends-on", 2),
},
];
#[test]
fn link_add_rejects_dag_cycles() {
for case in CYCLE_CASES {
let dir = setup();
let ids = create_issues(&dir, case.issue_count);
for &(from_idx, rel, to_idx) in case.setup_links {
link(&dir, &ids, from_idx, rel, to_idx);
}
let (from_idx, rel, to_idx) = case.rejected;
bmo(&dir)
.args([
"issue",
"link",
"add",
&ids[from_idx - 1],
rel,
&ids[to_idx - 1],
])
.assert()
.code(3); }
}
struct AllowedCase {
#[allow(dead_code)]
name: &'static str,
dag_link: (usize, &'static str, usize),
allowed_link: (usize, &'static str, usize),
}
static ALLOWED_CASES: &[AllowedCase] = &[
AllowedCase {
name: "blocked-by is not a DAG edge — allowed even when inverse blocks exists",
dag_link: (1, "blocks", 2),
allowed_link: (2, "blocked-by", 1),
},
AllowedCase {
name: "dependency-of is not a DAG edge — allowed even when inverse depends-on exists",
dag_link: (1, "depends-on", 2),
allowed_link: (2, "dependency-of", 1),
},
AllowedCase {
name: "relates-to is never a DAG edge — always allowed",
dag_link: (1, "blocks", 2),
allowed_link: (2, "relates-to", 1),
},
];
#[test]
fn link_add_allows_non_dag_edges() {
for case in ALLOWED_CASES {
let dir = setup();
let ids = create_issues(&dir, 2);
let (fi, rel, ti) = case.dag_link;
link(&dir, &ids, fi, rel, ti);
let (fi, rel, ti) = case.allowed_link;
bmo(&dir)
.args(["issue", "link", "add", &ids[fi - 1], rel, &ids[ti - 1]])
.assert()
.success();
}
}
#[test]
fn plan_fails_loud_on_cycle() {
let dir = setup_with_injected_cycle();
bmo(&dir)
.args(["plan"])
.assert()
.failure()
.stderr(contains("cycle"));
}
#[test]
fn next_fails_loud_on_cycle() {
let dir = setup_with_injected_cycle();
bmo(&dir)
.args(["next"])
.assert()
.failure()
.stderr(contains("cycle"));
}
#[test]
fn agent_init_fails_loud_on_cycle() {
let dir = setup_with_injected_cycle();
bmo(&dir)
.args(["agent-init"])
.assert()
.failure()
.stderr(contains("cycle"));
}
#[test]
fn plan_next_agent_init_work_with_acyclic_graph() {
let dir = setup();
let ids = create_issues(&dir, 3);
link(&dir, &ids, 1, "blocks", 2);
link(&dir, &ids, 2, "blocks", 3);
bmo(&dir).args(["plan"]).assert().success();
bmo(&dir).args(["next"]).assert().success();
bmo(&dir).args(["agent-init"]).assert().success();
}