use anyhow::{Context, Result};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use std::collections::HashSet;
use std::process::Command;
pub use crate::types::{DependencyType, IssueType, Status};
#[derive(Debug, Clone, PartialEq)]
pub enum BeadsAction {
Init {
prefix: Option<String>,
mb_hash_ids: Option<bool>,
},
Create {
expected_id: String,
title: String,
priority: i32,
issue_type: IssueType,
description: Option<String>,
},
List {
status: Option<Status>,
priority: Option<i32>,
},
Show { issue_id: String },
Update {
issue_id: String,
status: Option<Status>,
priority: Option<i32>,
},
Close { issue_id: String, reason: String },
Reopen { issue_id: String },
AddDependency {
issue_id: String,
depends_on: String,
dep_type: DependencyType,
},
Export { output: String },
}
impl std::fmt::Display for BeadsAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BeadsAction::Init {
prefix,
mb_hash_ids,
} => {
let mut parts = vec!["init".to_string()];
if let Some(p) = prefix {
parts.push(format!("--prefix {}", p));
}
if let Some(true) = mb_hash_ids {
parts.push("--mb-hash-ids".to_string());
}
write!(f, "{}", parts.join(" "))
}
BeadsAction::Create {
expected_id,
priority,
issue_type,
..
} => write!(
f,
"create {} (p:{}, type:{})",
expected_id,
priority,
issue_type.as_str()
),
BeadsAction::List { status, priority } => {
let mut parts = vec!["list".to_string()];
if let Some(s) = status {
parts.push(format!("status:{}", s.as_str()));
}
if let Some(p) = priority {
parts.push(format!("priority:{}", p));
}
write!(f, "{}", parts.join(" "))
}
BeadsAction::Show { issue_id } => write!(f, "show {}", issue_id),
BeadsAction::Update {
issue_id,
status,
priority,
} => {
let mut parts = vec![format!("update {}", issue_id)];
if let Some(s) = status {
parts.push(format!("status:{}", s.as_str()));
}
if let Some(p) = priority {
parts.push(format!("priority:{}", p));
}
write!(f, "{}", parts.join(" "))
}
BeadsAction::Close { issue_id, .. } => write!(f, "close {}", issue_id),
BeadsAction::Reopen { issue_id } => write!(f, "reopen {}", issue_id),
BeadsAction::AddDependency {
issue_id,
depends_on,
dep_type,
} => write!(
f,
"dep add {} → {} ({})",
issue_id,
depends_on,
dep_type.as_str()
),
BeadsAction::Export { output } => write!(f, "export to {}", output),
}
}
}
pub struct ActionGenerator {
rng: StdRng,
prefix: String,
next_issue_num: usize,
existing_issues: Vec<String>, closed_issues: HashSet<String>,
use_hash_ids: bool,
}
impl ActionGenerator {
pub fn new(seed: u64) -> Self {
Self::new_with_mode(seed, false)
}
pub fn new_with_mode(seed: u64, use_hash_ids: bool) -> Self {
Self {
rng: StdRng::seed_from_u64(seed),
prefix: "test".to_string(),
next_issue_num: 1,
existing_issues: Vec::new(),
closed_issues: HashSet::new(),
use_hash_ids,
}
}
pub fn generate_sequence(&mut self, num_actions: usize) -> Vec<BeadsAction> {
let mut actions = Vec::new();
actions.push(BeadsAction::Init {
prefix: Some(self.prefix.clone()),
mb_hash_ids: Some(self.use_hash_ids),
});
for _ in 0..num_actions {
actions.push(self.generate_action());
}
actions
}
fn generate_action(&mut self) -> BeadsAction {
let action_type = if self.existing_issues.is_empty() {
0
} else {
let rand_val = self.rng.gen_range(0..100);
if rand_val < 30 {
0 } else if rand_val < 45 {
1 } else if rand_val < 55 {
2 } else if rand_val < 70 {
3 } else if rand_val < 80 {
4 } else if rand_val < 85 {
5 } else if rand_val < 95 {
6 } else {
7 }
};
match action_type {
0 => self.generate_create(),
1 => self.generate_list(),
2 => self.generate_show(),
3 => self.generate_update(),
4 => self.generate_close(),
5 => self.generate_reopen(),
6 => self.generate_add_dependency(),
7 => self.generate_export(),
_ => unreachable!(),
}
}
fn generate_create(&mut self) -> BeadsAction {
let title = format!("Issue {}", self.rng.gen_range(1000..9999));
let priority = self.rng.gen_range(0..5);
let issue_type = match self.rng.gen_range(0..5) {
0 => IssueType::Bug,
1 => IssueType::Feature,
2 => IssueType::Task,
3 => IssueType::Epic,
_ => IssueType::Chore,
};
let description = if self.rng.gen_bool(0.5) {
Some(format!("Description for {}", title))
} else {
None
};
let expected_id = if self.use_hash_ids {
format!("{}-HASH", self.prefix)
} else {
format!("{}-{}", self.prefix, self.next_issue_num)
};
self.next_issue_num += 1;
if !self.use_hash_ids {
self.existing_issues.push(expected_id.clone());
}
BeadsAction::Create {
expected_id,
title,
priority,
issue_type,
description,
}
}
fn generate_list(&mut self) -> BeadsAction {
let status = if self.rng.gen_bool(0.3) {
Some(match self.rng.gen_range(0..4) {
0 => Status::Open,
1 => Status::InProgress,
2 => Status::Blocked,
_ => Status::Closed,
})
} else {
None
};
let priority = if self.rng.gen_bool(0.3) {
Some(self.rng.gen_range(0..5))
} else {
None
};
BeadsAction::List { status, priority }
}
fn generate_show(&mut self) -> BeadsAction {
let issue_id = self.pick_random_issue();
BeadsAction::Show { issue_id }
}
fn generate_update(&mut self) -> BeadsAction {
let issue_id = self.pick_random_issue();
let status = if self.rng.gen_bool(0.5) {
Some(match self.rng.gen_range(0..3) {
0 => Status::Open,
1 => Status::InProgress,
_ => Status::Blocked,
})
} else {
None
};
let priority = if self.rng.gen_bool(0.5) {
Some(self.rng.gen_range(0..5))
} else {
None
};
BeadsAction::Update {
issue_id,
status,
priority,
}
}
fn generate_close(&mut self) -> BeadsAction {
let issue_id = self.pick_random_issue();
self.closed_issues.insert(issue_id.clone());
BeadsAction::Close {
issue_id,
reason: "Completed".to_string(),
}
}
fn generate_reopen(&mut self) -> BeadsAction {
let issue_id = if !self.closed_issues.is_empty() && self.rng.gen_bool(0.7) {
let idx = self.rng.gen_range(0..self.closed_issues.len());
let issue = self.closed_issues.iter().nth(idx).unwrap().clone();
self.closed_issues.remove(&issue);
issue
} else {
self.pick_random_issue()
};
BeadsAction::Reopen { issue_id }
}
fn generate_add_dependency(&mut self) -> BeadsAction {
if self.existing_issues.len() < 2 {
return self.generate_create();
}
let issue_id =
self.existing_issues[self.rng.gen_range(0..self.existing_issues.len())].clone();
let mut depends_on =
self.existing_issues[self.rng.gen_range(0..self.existing_issues.len())].clone();
while depends_on == issue_id && self.existing_issues.len() > 1 {
depends_on =
self.existing_issues[self.rng.gen_range(0..self.existing_issues.len())].clone();
}
let dep_type = match self.rng.gen_range(0..3) {
0 => DependencyType::Blocks,
1 => DependencyType::Related,
_ => DependencyType::ParentChild,
};
BeadsAction::AddDependency {
issue_id,
depends_on,
dep_type,
}
}
fn generate_export(&mut self) -> BeadsAction {
BeadsAction::Export {
output: "issues.jsonl".to_string(),
}
}
fn pick_random_issue(&mut self) -> String {
if self.existing_issues.is_empty() {
format!("{}-1", self.prefix)
} else {
let idx = self.rng.gen_range(0..self.existing_issues.len());
self.existing_issues[idx].clone()
}
}
}
pub struct ActionExecutor {
binary_path: String,
work_dir: String,
use_no_db: bool,
}
impl ActionExecutor {
pub fn new(binary_path: &str, work_dir: &str, use_no_db: bool) -> Self {
Self {
binary_path: binary_path.to_string(),
work_dir: work_dir.to_string(),
use_no_db,
}
}
fn build_command(&self) -> Command {
let mut cmd = Command::new(&self.binary_path);
cmd.current_dir(&self.work_dir);
let beads_dir = std::path::PathBuf::from(&self.work_dir).join(".beads");
cmd.env("MB_BEADS_DIR", beads_dir);
if self.use_no_db {
cmd.arg("--no-db");
}
cmd
}
pub fn execute(&self, action: &BeadsAction) -> Result<ExecutionResult> {
let mut actual_issue_id: Option<String> = None;
let output = match action {
BeadsAction::Init {
prefix,
mb_hash_ids,
} => {
let mut cmd = self.build_command();
cmd.arg("init");
if let Some(p) = prefix {
cmd.arg("--prefix").arg(p);
}
let is_upstream = self.binary_path.contains("upstream");
if let Some(true) = mb_hash_ids {
if !is_upstream {
cmd.arg("--mb-hash-ids");
}
}
cmd.output().context("Failed to execute init command")?
}
BeadsAction::Create {
expected_id,
title,
priority,
issue_type,
description,
} => {
let mut cmd = self.build_command();
cmd.arg("create")
.arg(title)
.arg("-p")
.arg(priority.to_string())
.arg("-t")
.arg(issue_type.as_str());
if let Some(desc) = description {
cmd.arg("-d").arg(desc);
}
let output = cmd.output().context("Failed to execute create command")?;
if output.status.success() {
actual_issue_id = extract_issue_id(&String::from_utf8_lossy(&output.stdout));
}
if output.status.success() && !expected_id.contains("HASH") {
if let Some(ref actual) = actual_issue_id {
if actual != expected_id {
let expected_parts: Vec<&str> = expected_id.split('-').collect();
let actual_parts: Vec<&str> = actual.split('-').collect();
let prefix_mismatch = expected_parts.first() != actual_parts.first();
let number_mismatch = expected_parts.get(1) != actual_parts.get(1);
let mut error_msg = String::from("ISSUE ID MISMATCH!\n");
error_msg.push_str(&format!("Expected: {}\n", expected_id));
error_msg.push_str(&format!("Actual: {}\n\n", actual));
if prefix_mismatch {
error_msg.push_str(&format!(
"PREFIX MISMATCH: Expected '{}', got '{}'\n",
expected_parts.first().unwrap_or(&"?"),
actual_parts.first().unwrap_or(&"?")
));
error_msg.push_str("Possible causes:\n");
error_msg.push_str(" - Init command failed to set prefix\n");
error_msg.push_str(
" - Found existing .beads directory with different prefix\n",
);
error_msg.push_str(" - Working in wrong directory\n");
}
if number_mismatch && !prefix_mismatch {
error_msg.push_str(&format!(
"NUMBER MISMATCH: Expected '{}', got '{}'\n",
expected_parts.get(1).unwrap_or(&"?"),
actual_parts.get(1).unwrap_or(&"?")
));
error_msg.push_str("Possible causes:\n");
error_msg.push_str(" - Existing issues in database\n");
error_msg
.push_str(" - Sequential numbering assumption violated\n");
error_msg.push_str(" - Test directory not isolated\n");
}
anyhow::bail!(error_msg);
}
}
}
output
}
BeadsAction::List { status, priority } => {
let mut cmd = self.build_command();
cmd.arg("list");
if let Some(s) = status {
cmd.arg("--status").arg(s.as_str());
}
if let Some(p) = priority {
cmd.arg("--priority").arg(p.to_string());
}
cmd.output().context("Failed to execute list command")?
}
BeadsAction::Show { issue_id } => {
let mut cmd = self.build_command();
cmd.arg("show").arg(issue_id);
cmd.output().context("Failed to execute show command")?
}
BeadsAction::Update {
issue_id,
status,
priority,
} => {
let mut cmd = self.build_command();
cmd.arg("update").arg(issue_id);
if let Some(s) = status {
cmd.arg("--status").arg(s.as_str());
}
if let Some(p) = priority {
cmd.arg("--priority").arg(p.to_string());
}
cmd.output().context("Failed to execute update command")?
}
BeadsAction::Close { issue_id, reason } => {
let mut cmd = self.build_command();
cmd.arg("close").arg(issue_id).arg("--reason").arg(reason);
cmd.output().context("Failed to execute close command")?
}
BeadsAction::Reopen { issue_id } => {
let mut cmd = self.build_command();
cmd.arg("reopen").arg(issue_id);
cmd.output().context("Failed to execute reopen command")?
}
BeadsAction::AddDependency {
issue_id,
depends_on,
dep_type,
} => {
let mut cmd = self.build_command();
cmd.arg("dep")
.arg("add")
.arg(issue_id)
.arg(depends_on)
.arg("-t")
.arg(dep_type.as_str());
cmd.output().context("Failed to execute dep add command")?
}
BeadsAction::Export { output } => {
let mut cmd = self.build_command();
cmd.arg("export").arg("--output").arg(output);
cmd.output().context("Failed to execute export command")?
}
};
Ok(ExecutionResult {
success: output.status.success(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
exit_code: output.status.code(),
actual_issue_id,
})
}
#[allow(dead_code)]
pub fn execute_sequence(&self, actions: &[BeadsAction]) -> Result<Vec<ExecutionResult>> {
let mut results = Vec::new();
for action in actions {
let result = self.execute(action)?;
results.push(result);
}
Ok(results)
}
}
fn extract_issue_id(output: &str) -> Option<String> {
for line in output.lines() {
if let Some(pos) = line.find("Created issue:") {
let id = line[pos + 14..].trim();
if !id.is_empty() {
return Some(id.to_string());
}
}
if let Some(pos) = line.find("Created:") {
let id = line[pos + 8..].trim();
if !id.is_empty() {
return Some(id.to_string());
}
}
let words: Vec<&str> = line.split_whitespace().collect();
for word in words {
if word.contains('-') {
let suffix = word.split('-').next_back().unwrap_or("");
if suffix.parse::<usize>().is_ok() {
return Some(word.to_string());
}
if (4..=8).contains(&suffix.len()) && suffix.chars().all(|c| c.is_ascii_hexdigit())
{
return Some(word.to_string());
}
}
}
}
None
}
#[derive(Debug, Clone)]
pub struct ExecutionResult {
pub success: bool,
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
pub actual_issue_id: Option<String>,
}
pub struct ReferenceInterpreter {
pub issues: std::collections::HashMap<String, ReferenceIssue>,
pub prefix: String,
pub next_id: usize,
pub use_hash_ids: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReferenceIssue {
pub id: String,
pub title: String,
pub description: String,
pub status: Status,
pub priority: i32,
pub issue_type: IssueType,
pub depends_on: std::collections::HashMap<String, DependencyType>,
}
impl ReferenceInterpreter {
pub fn new(prefix: String) -> Self {
Self {
issues: std::collections::HashMap::new(),
prefix,
next_id: 1,
use_hash_ids: false,
}
}
pub fn new_with_hash_ids(prefix: String) -> Self {
Self {
issues: std::collections::HashMap::new(),
prefix,
next_id: 1,
use_hash_ids: true,
}
}
pub fn execute(&mut self, action: &BeadsAction) -> Result<()> {
match action {
BeadsAction::Init {
prefix,
mb_hash_ids,
} => {
if let Some(p) = prefix {
self.prefix = p.clone();
}
if let Some(use_hash) = mb_hash_ids {
self.use_hash_ids = *use_hash;
}
Ok(())
}
BeadsAction::Create {
expected_id,
title,
priority,
issue_type,
description,
} => {
if !self.use_hash_ids {
let computed_id = format!("{}-{}", self.prefix, self.next_id);
if *expected_id != computed_id {
anyhow::bail!(
"Reference interpreter ID mismatch: expected {}, got {}",
computed_id,
expected_id
);
}
}
let issue = ReferenceIssue {
id: expected_id.clone(),
title: title.clone(),
description: description.clone().unwrap_or_default(),
status: Status::Open,
priority: *priority,
issue_type: *issue_type,
depends_on: std::collections::HashMap::new(),
};
self.issues.insert(expected_id.clone(), issue);
self.next_id += 1;
Ok(())
}
BeadsAction::List { .. } => {
Ok(())
}
BeadsAction::Show { .. } => {
Ok(())
}
BeadsAction::Update {
issue_id,
status,
priority,
} => {
if let Some(issue) = self.issues.get_mut(issue_id) {
if let Some(s) = status {
issue.status = *s;
}
if let Some(p) = priority {
issue.priority = *p;
}
}
Ok(())
}
BeadsAction::Close { issue_id, .. } => {
if let Some(issue) = self.issues.get_mut(issue_id) {
issue.status = Status::Closed;
}
Ok(())
}
BeadsAction::Reopen { issue_id } => {
if let Some(issue) = self.issues.get_mut(issue_id) {
issue.status = Status::Open;
}
Ok(())
}
BeadsAction::AddDependency {
issue_id,
depends_on,
dep_type,
} => {
if let Some(issue) = self.issues.get_mut(issue_id) {
issue.depends_on.insert(depends_on.clone(), *dep_type);
}
Ok(())
}
BeadsAction::Export { .. } => {
Ok(())
}
}
}
pub fn get_final_state(&self) -> &std::collections::HashMap<String, ReferenceIssue> {
&self.issues
}
pub fn get_prefix(&self) -> &str {
&self.prefix
}
pub fn get_next_id(&self) -> usize {
self.next_id
}
}