use crate::format::{issue_to_markdown, markdown_to_issue};
use crate::hash;
use crate::lock::Lock;
use crate::types::{BlockedIssue, DependencyType, EditField, Issue, IssueType, Stats, Status};
use anyhow::{Context, Result};
use regex::Regex;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
pub struct Storage {
beads_dir: PathBuf,
issues_dir: PathBuf,
}
fn numeric_id_suffix(id: &str) -> Option<u32> {
let suffix = id.rsplit_once('-').map(|(_, s)| s).unwrap_or(id);
suffix.parse::<u32>().ok()
}
fn compare_for_list(a: &Issue, b: &Issue) -> std::cmp::Ordering {
match (numeric_id_suffix(&a.id), numeric_id_suffix(&b.id)) {
(Some(an), Some(bn)) => an.cmp(&bn),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.created_at.cmp(&b.created_at),
}
}
fn replace_issue_ids_in_text(text: &str, id_mapping: &HashMap<String, String>) -> String {
if text.is_empty() || id_mapping.is_empty() {
return text.to_string();
}
let mut patterns: Vec<String> = id_mapping.keys().map(|id| regex::escape(id)).collect();
if patterns.is_empty() {
return text.to_string();
}
patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
let pattern = format!(r"\b({})\b", patterns.join("|"));
let re = match Regex::new(&pattern) {
Ok(r) => r,
Err(_) => return text.to_string(), };
re.replace_all(text, |caps: ®ex::Captures| {
let matched_id = &caps[1];
id_mapping
.get(matched_id)
.cloned()
.unwrap_or_else(|| matched_id.to_string())
})
.to_string()
}
fn replace_ids_in_issue_text(issue: &mut Issue, id_mapping: &HashMap<String, String>) {
issue.title = replace_issue_ids_in_text(&issue.title, id_mapping);
issue.description = replace_issue_ids_in_text(&issue.description, id_mapping);
issue.design = replace_issue_ids_in_text(&issue.design, id_mapping);
issue.acceptance_criteria = replace_issue_ids_in_text(&issue.acceptance_criteria, id_mapping);
issue.notes = replace_issue_ids_in_text(&issue.notes, id_mapping);
}
impl Storage {
pub fn get_beads_dir(&self) -> PathBuf {
self.beads_dir.clone()
}
}
impl Storage {
pub fn open(beads_dir: PathBuf) -> Result<Self> {
let issues_dir = beads_dir.join("issues");
fs::create_dir_all(&issues_dir).context("Failed to create issues directory")?;
let config_path = beads_dir.join("config.yaml");
if !config_path.exists() {
let prefix = infer_prefix(&beads_dir).unwrap_or_else(|| "bd".to_string());
let mut config = HashMap::new();
config.insert("issue-prefix".to_string(), prefix);
let config_yaml = serde_yaml::to_string(&config)?;
fs::write(&config_path, config_yaml).context("Failed to create config.yaml")?;
} else {
let content = fs::read_to_string(&config_path).context("Failed to read config.yaml")?;
serde_yaml::from_str::<HashMap<String, String>>(&content)
.context("Failed to parse config.yaml")?;
}
let minibeads_config_path = beads_dir.join("config-minibeads.yaml");
if !minibeads_config_path.exists() {
create_minibeads_config(&beads_dir, false)?; }
ensure_gitignore(&beads_dir)?;
Ok(Self {
beads_dir,
issues_dir,
})
}
pub fn init(beads_dir: PathBuf, prefix: Option<String>, mb_hash_ids: bool) -> Result<Self> {
fs::create_dir_all(&beads_dir).context("Failed to create .beads directory")?;
let issues_dir = beads_dir.join("issues");
fs::create_dir_all(&issues_dir).context("Failed to create issues directory")?;
let prefix = prefix
.or_else(|| infer_prefix(&beads_dir))
.unwrap_or_else(|| "bd".to_string());
let config_path = beads_dir.join("config.yaml");
let mut config = HashMap::new();
config.insert("issue-prefix".to_string(), prefix);
let config_yaml = serde_yaml::to_string(&config)?;
fs::write(&config_path, config_yaml).context("Failed to write config.yaml")?;
create_minibeads_config(&beads_dir, mb_hash_ids)?;
ensure_gitignore(&beads_dir)?;
Ok(Self {
beads_dir,
issues_dir,
})
}
pub fn get_prefix(&self) -> Result<String> {
let config_path = self.beads_dir.join("config.yaml");
if !config_path.exists() {
return self.infer_prefix_from_issues();
}
let content = fs::read_to_string(&config_path).context("Failed to read config.yaml")?;
let config: HashMap<String, String> =
serde_yaml::from_str(&content).context("Failed to parse config.yaml")?;
match config.get("issue-prefix") {
Some(prefix) => Ok(prefix.clone()),
None => self.infer_prefix_from_issues(),
}
}
fn use_hash_ids(&self) -> Result<bool> {
let config_path = self.beads_dir.join("config-minibeads.yaml");
if !config_path.exists() {
return Ok(false); }
let content =
fs::read_to_string(&config_path).context("Failed to read config-minibeads.yaml")?;
let config: HashMap<String, String> =
serde_yaml::from_str(&content).context("Failed to parse config-minibeads.yaml")?;
match config.get("mb-hash-ids") {
Some(value) => Ok(value == "true"),
None => Ok(false),
}
}
fn get_hash_encoding(&self) -> Result<hash::HashEncoding> {
let config_path = self.beads_dir.join("config-minibeads.yaml");
if !config_path.exists() {
return Ok(hash::HashEncoding::Base36); }
let content =
fs::read_to_string(&config_path).context("Failed to read config-minibeads.yaml")?;
let config: HashMap<String, String> =
serde_yaml::from_str(&content).context("Failed to parse config-minibeads.yaml")?;
match config.get("hash-encoding") {
Some(value) => match value.as_str() {
"hex" => Ok(hash::HashEncoding::Hex),
"base36" => Ok(hash::HashEncoding::Base36),
_ => {
eprintln!(
"Warning: Unknown hash-encoding value '{}' in config-minibeads.yaml, using base36",
value
);
Ok(hash::HashEncoding::Base36)
}
},
None => Ok(hash::HashEncoding::Base36),
}
}
fn infer_prefix_from_issues(&self) -> Result<String> {
let entries = fs::read_dir(&self.issues_dir).context("Failed to read issues directory")?;
let mut prefixes = HashMap::new();
for entry in entries {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if let Some(issue_id) = name_str.strip_suffix(".md") {
if let Some(pos) = issue_id.rfind('-') {
let prefix = &issue_id[..pos];
*prefixes.entry(prefix.to_string()).or_insert(0) += 1;
}
}
}
prefixes
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(prefix, _)| prefix)
.ok_or_else(|| anyhow::anyhow!("No issues found to infer prefix"))
}
fn get_next_number(&self, prefix: &str) -> Result<u32> {
let entries = fs::read_dir(&self.issues_dir).context("Failed to read issues directory")?;
let mut max_num = 0;
for entry in entries {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if let Some(issue_id) = name_str.strip_suffix(".md") {
if let Some(pos) = issue_id.rfind('-') {
let issue_prefix = &issue_id[..pos];
let num_str = &issue_id[pos + 1..];
if issue_prefix == prefix {
if let Ok(num) = num_str.parse::<u32>() {
max_num = max_num.max(num);
}
}
}
}
}
Ok(max_num + 1)
}
fn generate_hash_id(&self, prefix: &str, title: &str, description: &str) -> Result<String> {
use chrono::Utc;
let timestamp = Utc::now();
let entries = fs::read_dir(&self.issues_dir).context("Failed to read issues directory")?;
let issue_count = entries.count();
let encoding = self.get_hash_encoding()?;
hash::generate_hash_id_with_collision_check(
prefix,
title,
description,
timestamp,
issue_count,
encoding,
|candidate| self.issues_dir.join(format!("{}.md", candidate)).exists(),
)
}
#[allow(clippy::too_many_arguments)]
pub fn create_issue(
&self,
title: String,
description: String,
design: Option<String>,
acceptance: Option<String>,
priority: i32,
issue_type: IssueType,
assignee: Option<String>,
labels: Vec<String>,
external_ref: Option<String>,
id: Option<String>,
deps: Vec<(String, DependencyType)>,
) -> Result<Issue> {
let _lock = Lock::acquire(&self.beads_dir)?;
let issue_id = if let Some(id) = id {
id
} else {
let prefix = self.get_prefix()?;
let use_hash_ids = self.use_hash_ids()?;
if use_hash_ids {
self.generate_hash_id(&prefix, &title, &description)?
} else {
let num = self.get_next_number(&prefix)?;
format!("{}-{}", prefix, num)
}
};
let mut issue = Issue::new(issue_id.clone(), title, priority, issue_type);
issue.description = if description.is_empty() {
String::new()
} else {
description
};
issue.design = design.unwrap_or_default();
issue.acceptance_criteria = acceptance.unwrap_or_default();
issue.assignee = assignee.unwrap_or_default();
issue.labels = labels;
issue.external_ref = external_ref;
for (dep_id, dep_type) in deps {
self.validate_dependency_exists(&dep_id);
issue.depends_on.insert(dep_id, dep_type);
}
let issue_path = self.issues_dir.join(format!("{}.md", issue_id));
let markdown = issue_to_markdown(&issue)?;
fs::write(&issue_path, markdown).context("Failed to write issue file")?;
Ok(issue)
}
pub fn get_issue(&self, id: &str) -> Result<Option<Issue>> {
let _lock = Lock::acquire(&self.beads_dir)?;
let issue_path = self.issues_dir.join(format!("{}.md", id));
if !issue_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&issue_path).context("Failed to read issue file")?;
let mut issue = markdown_to_issue(id, &content)?;
let all_issues = self.list_all_issues_no_dependents()?;
Self::populate_dependents_for_one(&all_issues, &mut issue);
Ok(Some(issue))
}
fn list_all_issues_no_dependents(&self) -> Result<Vec<Issue>> {
let entries = fs::read_dir(&self.issues_dir).context("Failed to read issues directory")?;
let mut issues = Vec::new();
for entry in entries {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.ends_with(".md") {
continue;
}
let issue_id = &name_str[..name_str.len() - 3];
let content = fs::read_to_string(entry.path())?;
let issue = markdown_to_issue(issue_id, &content)?;
issues.push(issue);
}
Ok(issues)
}
fn populate_dependents_for_one(all_issues: &[Issue], target_issue: &mut Issue) {
use crate::types::Dependency;
let mut dependents = Vec::new();
for issue in all_issues {
if issue.depends_on.contains_key(&target_issue.id) {
if let Some(dep_type) = issue.depends_on.get(&target_issue.id) {
dependents.push(Dependency {
id: issue.id.clone(),
dep_type: dep_type.to_string(),
});
}
}
}
target_issue.dependents = dependents;
}
pub fn update_issue(&self, id: &str, updates: HashMap<String, String>) -> Result<Issue> {
let _lock = Lock::acquire(&self.beads_dir)?;
let issue_path = self.issues_dir.join(format!("{}.md", id));
if !issue_path.exists() {
anyhow::bail!("Issue not found: {}", id);
}
let content = fs::read_to_string(&issue_path).context("Failed to read issue file")?;
let mut issue = markdown_to_issue(id, &content)?;
for (key, value) in updates {
match key.as_str() {
"title" => issue.title = value,
"description" => issue.description = value,
"design" => issue.design = value,
"notes" => issue.notes = value,
"acceptance_criteria" => issue.acceptance_criteria = value,
"status" => issue.status = value.parse()?,
"priority" => issue.priority = value.parse()?,
"issue_type" => issue.issue_type = value.parse()?,
"assignee" => issue.assignee = value,
"external_ref" => {
issue.external_ref = if value.is_empty() { None } else { Some(value) }
}
_ => {}
}
}
issue.updated_at = chrono::Utc::now();
let markdown = issue_to_markdown(&issue)?;
fs::write(&issue_path, markdown).context("Failed to write issue file")?;
Ok(issue)
}
pub fn search_replace_issue(
&self,
id: &str,
field: EditField,
search: &str,
replace: &str,
replace_all: bool,
) -> Result<Issue> {
let _lock = Lock::acquire(&self.beads_dir)?;
if search.is_empty() {
anyhow::bail!("--search text must not be empty");
}
let issue_path = self.issues_dir.join(format!("{}.md", id));
if !issue_path.exists() {
anyhow::bail!("Issue not found: {}", id);
}
let content = fs::read_to_string(&issue_path).context("Failed to read issue file")?;
let mut issue = markdown_to_issue(id, &content)?;
let target = issue.text_field_mut(field);
let occurrences = target.matches(search).count();
if occurrences == 0 {
anyhow::bail!(
"Search text not found in {} field of {}. The --search text must match the current contents exactly (including whitespace and newlines).",
field,
id
);
}
if occurrences > 1 && !replace_all {
anyhow::bail!(
"Search text matches {} times in {} field of {}. Add surrounding context so it matches exactly once, or pass --replace-all to replace every occurrence.",
occurrences,
field,
id
);
}
let limit = if replace_all { occurrences } else { 1 };
*target = target.replacen(search, replace, limit);
issue.updated_at = chrono::Utc::now();
let markdown = issue_to_markdown(&issue)?;
fs::write(&issue_path, markdown).context("Failed to write issue file")?;
Ok(issue)
}
pub fn claim_issue(
&self,
id: &str,
actor: &str,
claimed_until: chrono::DateTime<chrono::Utc>,
extra_updates: &HashMap<String, String>,
) -> Result<Issue> {
let _lock = Lock::acquire(&self.beads_dir)?;
let issue_path = self.issues_dir.join(format!("{}.md", id));
if !issue_path.exists() {
anyhow::bail!("Issue not found: {}", id);
}
let content = fs::read_to_string(&issue_path).context("Failed to read issue file")?;
let mut issue = markdown_to_issue(id, &content)?;
if issue.status == Status::Closed {
anyhow::bail!("Cannot claim {}: issue is closed", id);
}
let now = chrono::Utc::now();
if issue.is_actively_claimed(now) && issue.assignee != actor {
let until = issue
.claimed_until
.map(|u| format!(" until {}", u.to_rfc3339()))
.unwrap_or_default();
anyhow::bail!(
"Issue {} is already claimed by '{}'{}",
id,
issue.assignee,
until
);
}
for (key, value) in extra_updates {
match key.as_str() {
"title" => issue.title = value.clone(),
"description" => issue.description = value.clone(),
"design" => issue.design = value.clone(),
"notes" => issue.notes = value.clone(),
"acceptance_criteria" => issue.acceptance_criteria = value.clone(),
"priority" => issue.priority = value.parse()?,
"issue_type" => issue.issue_type = value.parse()?,
"external_ref" => {
issue.external_ref = if value.is_empty() {
None
} else {
Some(value.clone())
}
}
_ => {}
}
}
issue.assignee = actor.to_string();
issue.status = Status::InProgress;
issue.claimed_at = Some(now);
issue.claimed_until = Some(claimed_until);
issue.updated_at = now;
let markdown = issue_to_markdown(&issue)?;
fs::write(&issue_path, markdown).context("Failed to write issue file")?;
Ok(issue)
}
pub fn release_issue(&self, id: &str, actor: &str, force: bool) -> Result<Issue> {
let _lock = Lock::acquire(&self.beads_dir)?;
let issue_path = self.issues_dir.join(format!("{}.md", id));
if !issue_path.exists() {
anyhow::bail!("Issue not found: {}", id);
}
let content = fs::read_to_string(&issue_path).context("Failed to read issue file")?;
let mut issue = markdown_to_issue(id, &content)?;
if !issue.assignee.is_empty() && issue.assignee != actor && !force {
anyhow::bail!(
"Issue {} is claimed by '{}', not '{}'. Use --force to release it anyway.",
id,
issue.assignee,
actor
);
}
issue.assignee = String::new();
issue.claimed_at = None;
issue.claimed_until = None;
if issue.status == Status::InProgress {
issue.status = Status::Open;
}
issue.updated_at = chrono::Utc::now();
let markdown = issue_to_markdown(&issue)?;
fs::write(&issue_path, markdown).context("Failed to write issue file")?;
Ok(issue)
}
pub fn close_issue(&self, id: &str, _reason: &str) -> Result<Issue> {
let _lock = Lock::acquire(&self.beads_dir)?;
let issue_path = self.issues_dir.join(format!("{}.md", id));
if !issue_path.exists() {
anyhow::bail!("Issue not found: {}", id);
}
let content = fs::read_to_string(&issue_path).context("Failed to read issue file")?;
let mut issue = markdown_to_issue(id, &content)?;
issue.status = Status::Closed;
issue.closed_at = Some(chrono::Utc::now());
issue.updated_at = chrono::Utc::now();
let markdown = issue_to_markdown(&issue)?;
fs::write(&issue_path, markdown).context("Failed to write issue file")?;
Ok(issue)
}
pub fn reopen_issue(&self, id: &str) -> Result<Issue> {
let _lock = Lock::acquire(&self.beads_dir)?;
let issue_path = self.issues_dir.join(format!("{}.md", id));
if !issue_path.exists() {
anyhow::bail!("Issue not found: {}", id);
}
let content = fs::read_to_string(&issue_path).context("Failed to read issue file")?;
let mut issue = markdown_to_issue(id, &content)?;
issue.status = Status::Open;
issue.closed_at = None;
issue.updated_at = chrono::Utc::now();
let markdown = issue_to_markdown(&issue)?;
fs::write(&issue_path, markdown).context("Failed to write issue file")?;
Ok(issue)
}
pub fn rename_issue(&self, old_id: &str, new_id: &str, dry_run: bool) -> Result<Vec<String>> {
let _lock = Lock::acquire(&self.beads_dir)?;
let old_path = self.issues_dir.join(format!("{}.md", old_id));
let new_path = self.issues_dir.join(format!("{}.md", new_id));
if !old_path.exists() {
anyhow::bail!("Issue not found: {}", old_id);
}
if new_path.exists() {
anyhow::bail!("Target issue ID already exists: {}", new_id);
}
let mut changes = Vec::new();
changes.push(format!("Rename file: {}.md -> {}.md", old_id, new_id));
let content = fs::read_to_string(&old_path).context("Failed to read issue file")?;
let mut issue = markdown_to_issue(old_id, &content)?;
issue.id = new_id.to_string();
issue.updated_at = chrono::Utc::now();
changes.push(format!(
"Update ID in frontmatter: {} -> {}",
old_id, new_id
));
let mut id_mapping = HashMap::new();
id_mapping.insert(old_id.to_string(), new_id.to_string());
replace_ids_in_issue_text(&mut issue, &id_mapping);
let all_issues = self.list_all_issues_no_dependents()?;
let mut issues_to_update = Vec::new();
for other_issue in all_issues {
if other_issue.id == old_id {
continue; }
let mut other_issue = other_issue;
let mut has_changes = false;
if other_issue.depends_on.contains_key(old_id) {
changes.push(format!(
"Update dependency in {}: {} -> {}",
other_issue.id, old_id, new_id
));
has_changes = true;
}
let old_title = other_issue.title.clone();
let old_description = other_issue.description.clone();
let old_design = other_issue.design.clone();
let old_notes = other_issue.notes.clone();
let old_acceptance = other_issue.acceptance_criteria.clone();
replace_ids_in_issue_text(&mut other_issue, &id_mapping);
if other_issue.title != old_title
|| other_issue.description != old_description
|| other_issue.design != old_design
|| other_issue.notes != old_notes
|| other_issue.acceptance_criteria != old_acceptance
{
changes.push(format!(
"Update text references in {}: {} -> {}",
other_issue.id, old_id, new_id
));
has_changes = true;
}
if has_changes {
issues_to_update.push(other_issue);
}
}
if dry_run {
return Ok(changes);
}
for mut other_issue in issues_to_update {
if let Some(dep_type) = other_issue.depends_on.remove(old_id) {
other_issue.depends_on.insert(new_id.to_string(), dep_type);
}
other_issue.updated_at = chrono::Utc::now();
let other_path = self.issues_dir.join(format!("{}.md", other_issue.id));
let markdown = issue_to_markdown(&other_issue)?;
fs::write(&other_path, markdown)
.context(format!("Failed to update issue: {}", other_issue.id))?;
}
let markdown = issue_to_markdown(&issue)?;
fs::write(&new_path, markdown).context("Failed to write renamed issue")?;
fs::remove_file(&old_path).context("Failed to remove old issue file")?;
Ok(changes)
}
pub fn repair_references(&self, dry_run: bool) -> Result<Vec<String>> {
let _lock = Lock::acquire(&self.beads_dir)?;
let mut changes = Vec::new();
let all_issues = self.list_all_issues_no_dependents()?;
let valid_ids: std::collections::HashSet<String> =
all_issues.iter().map(|i| i.id.clone()).collect();
for issue in all_issues {
let mut broken_refs = Vec::new();
for dep_id in issue.depends_on.keys() {
if !valid_ids.contains(dep_id) {
broken_refs.push(dep_id.clone());
}
}
if !broken_refs.is_empty() {
for broken_ref in &broken_refs {
changes.push(format!(
"Remove broken reference in {}: {} (does not exist)",
issue.id, broken_ref
));
}
if !dry_run {
let mut updated_issue = issue.clone();
for broken_ref in &broken_refs {
updated_issue.depends_on.remove(broken_ref);
}
updated_issue.updated_at = chrono::Utc::now();
let issue_path = self.issues_dir.join(format!("{}.md", updated_issue.id));
let markdown = issue_to_markdown(&updated_issue)?;
fs::write(&issue_path, markdown)
.context(format!("Failed to update issue: {}", updated_issue.id))?;
}
}
}
if changes.is_empty() {
changes.push("No broken references found".to_string());
}
Ok(changes)
}
fn validate_dependency_exists(&self, dep_id: &str) -> bool {
let dep_path = self.issues_dir.join(format!("{}.md", dep_id));
let exists = dep_path.exists();
if !exists {
eprintln!("Warning: Dependency target does not exist: {}", dep_id);
eprintln!(" This issue will be blocked until {} is created.", dep_id);
}
exists
}
pub fn add_dependency(
&self,
from_id: &str,
to_id: &str,
dep_type: DependencyType,
) -> Result<()> {
let _lock = Lock::acquire(&self.beads_dir)?;
let issue_path = self.issues_dir.join(format!("{}.md", from_id));
if !issue_path.exists() {
anyhow::bail!("Issue not found: {}", from_id);
}
self.validate_dependency_exists(to_id);
let content = fs::read_to_string(&issue_path).context("Failed to read issue file")?;
let mut issue = markdown_to_issue(from_id, &content)?;
issue.depends_on.insert(to_id.to_string(), dep_type);
issue.updated_at = chrono::Utc::now();
let markdown = issue_to_markdown(&issue)?;
fs::write(&issue_path, markdown).context("Failed to write issue file")?;
Ok(())
}
pub fn remove_dependency(&self, from_id: &str, to_id: &str) -> Result<()> {
let _lock = Lock::acquire(&self.beads_dir)?;
let issue_path = self.issues_dir.join(format!("{}.md", from_id));
if !issue_path.exists() {
anyhow::bail!("Issue not found: {}", from_id);
}
let content = fs::read_to_string(&issue_path).context("Failed to read issue file")?;
let mut issue = markdown_to_issue(from_id, &content)?;
if issue.depends_on.remove(to_id).is_none() {
anyhow::bail!("Dependency not found: {} -> {}", from_id, to_id);
}
issue.updated_at = chrono::Utc::now();
let markdown = issue_to_markdown(&issue)?;
fs::write(&issue_path, markdown).context("Failed to write issue file")?;
Ok(())
}
pub fn get_dependency_tree(
&self,
issue_id: &str,
max_depth: usize,
show_all_paths: bool,
) -> Result<crate::types::TreeNode> {
use std::collections::HashSet;
let issues = self.list_issues(None, None, None, None, None)?;
let issues_map: HashMap<String, Issue> = issues
.into_iter()
.map(|issue| (issue.id.clone(), issue))
.collect();
let root_issue = issues_map
.get(issue_id)
.ok_or_else(|| anyhow::anyhow!("Issue not found: {}", issue_id))?;
let mut visited = HashSet::new();
build_tree_node(
root_issue,
&issues_map,
&mut visited,
0,
max_depth,
show_all_paths,
None,
)
}
pub fn detect_dependency_cycles(&self) -> Result<Vec<Vec<String>>> {
use std::collections::{HashMap, HashSet};
let issues = self.list_issues(None, None, None, None, None)?;
let issues_map: HashMap<String, Issue> = issues
.into_iter()
.map(|issue| (issue.id.clone(), issue))
.collect();
let mut cycles = Vec::new();
let mut visited = HashSet::new();
let mut rec_stack = HashSet::new();
let mut path = Vec::new();
for issue_id in issues_map.keys() {
if !visited.contains(issue_id) {
find_cycles_dfs(
issue_id,
&issues_map,
&mut visited,
&mut rec_stack,
&mut path,
&mut cycles,
);
}
}
Ok(cycles)
}
}
fn build_tree_node(
issue: &Issue,
issues_map: &HashMap<String, Issue>,
visited: &mut std::collections::HashSet<String>,
current_depth: usize,
max_depth: usize,
show_all_paths: bool,
dep_type: Option<String>,
) -> Result<crate::types::TreeNode> {
let mut node = crate::types::TreeNode {
id: issue.id.clone(),
title: issue.title.clone(),
status: issue.status,
priority: issue.priority,
dep_type,
children: Vec::new(),
is_cycle: false,
depth_exceeded: false,
};
if !show_all_paths && visited.contains(&issue.id) {
node.is_cycle = true;
return Ok(node);
}
if current_depth >= max_depth {
node.depth_exceeded = true;
return Ok(node);
}
if !show_all_paths {
visited.insert(issue.id.clone());
}
for (dep_id, dep_type_val) in &issue.depends_on {
if let Some(dep_issue) = issues_map.get(dep_id) {
let child = build_tree_node(
dep_issue,
issues_map,
visited,
current_depth + 1,
max_depth,
show_all_paths,
Some(dep_type_val.to_string()),
)?;
node.children.push(child);
}
}
if !show_all_paths {
visited.remove(&issue.id);
}
Ok(node)
}
fn find_cycles_dfs(
current_id: &str,
issues_map: &HashMap<String, Issue>,
visited: &mut std::collections::HashSet<String>,
rec_stack: &mut std::collections::HashSet<String>,
path: &mut Vec<String>,
cycles: &mut Vec<Vec<String>>,
) {
visited.insert(current_id.to_string());
rec_stack.insert(current_id.to_string());
path.push(current_id.to_string());
if let Some(issue) = issues_map.get(current_id) {
for dep_id in issue.depends_on.keys() {
if !visited.contains(dep_id) {
find_cycles_dfs(dep_id, issues_map, visited, rec_stack, path, cycles);
} else if rec_stack.contains(dep_id) {
if let Some(cycle_start_idx) = path.iter().position(|id| id == dep_id) {
let cycle: Vec<String> = path[cycle_start_idx..].to_vec();
if !cycles.iter().any(|c| cycles_equal(c, &cycle)) {
cycles.push(cycle);
}
}
}
}
}
rec_stack.remove(current_id);
path.pop();
}
fn cycles_equal(cycle1: &[String], cycle2: &[String]) -> bool {
if cycle1.len() != cycle2.len() {
return false;
}
for i in 0..cycle1.len() {
let mut match_found = true;
for j in 0..cycle1.len() {
if cycle1[(i + j) % cycle1.len()] != cycle2[j] {
match_found = false;
break;
}
}
if match_found {
return true;
}
}
false
}
impl Storage {
fn populate_dependents(issues: &mut [Issue]) {
use crate::types::Dependency;
use std::collections::HashMap;
let mut reverse_deps: HashMap<String, Vec<Dependency>> = HashMap::new();
for issue in issues.iter() {
for (dep_id, dep_type) in &issue.depends_on {
reverse_deps
.entry(dep_id.clone())
.or_default()
.push(Dependency {
id: issue.id.clone(),
dep_type: dep_type.to_string(),
});
}
}
for issue in issues.iter_mut() {
issue.dependents = reverse_deps.remove(&issue.id).unwrap_or_default();
}
}
pub fn list_issues(
&self,
status: Option<Status>,
priority: Option<Vec<i32>>,
issue_type: Option<IssueType>,
assignee: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<Issue>> {
let _lock = Lock::acquire(&self.beads_dir)?;
let entries = fs::read_dir(&self.issues_dir).context("Failed to read issues directory")?;
let mut issues = Vec::new();
for entry in entries {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.ends_with(".md") {
continue;
}
let issue_id = &name_str[..name_str.len() - 3];
let content = fs::read_to_string(entry.path())?;
let issue = markdown_to_issue(issue_id, &content)?;
if let Some(s) = status {
if issue.status != s {
continue;
}
}
if let Some(ref priorities) = priority {
if !priorities.contains(&issue.priority) {
continue;
}
}
if let Some(t) = issue_type {
if issue.issue_type != t {
continue;
}
}
if let Some(a) = assignee {
if issue.assignee != a {
continue;
}
}
issues.push(issue);
}
issues.sort_by(compare_for_list);
if let Some(limit) = limit {
issues.truncate(limit);
}
Self::populate_dependents(&mut issues);
Ok(issues)
}
pub fn get_stats(&self) -> Result<Stats> {
let issues = self.list_issues(None, None, None, None, None)?;
let total = issues.len();
let open = issues.iter().filter(|i| i.status == Status::Open).count();
let in_progress = issues
.iter()
.filter(|i| i.status == Status::InProgress)
.count();
let closed = issues.iter().filter(|i| i.status == Status::Closed).count();
let blocked = issues
.iter()
.filter(|i| i.status != Status::Closed && i.has_blocking_dependencies())
.count();
let ready = issues
.iter()
.filter(|i| i.status == Status::Open && !i.has_blocking_dependencies())
.count();
let mut lead_times = Vec::new();
for issue in &issues {
if issue.status == Status::Closed {
if let Some(closed_at) = issue.closed_at {
let duration = closed_at.signed_duration_since(issue.created_at);
lead_times.push(duration.num_hours() as f64);
}
}
}
let avg_lead_time_hours = if lead_times.is_empty() {
0.0
} else {
lead_times.iter().sum::<f64>() / lead_times.len() as f64
};
Ok(Stats {
total_issues: total,
open_issues: open,
in_progress_issues: in_progress,
blocked_issues: blocked,
closed_issues: closed,
ready_issues: ready,
average_lead_time_hours: avg_lead_time_hours,
})
}
pub fn get_blocked(&self) -> Result<Vec<BlockedIssue>> {
let issues = self.list_issues(None, None, None, None, None)?;
let mut blocked = Vec::new();
for issue in issues {
if issue.status == Status::Closed {
continue;
}
let blocked_by: Vec<String> = issue.get_blocking_dependencies().cloned().collect();
if !blocked_by.is_empty() {
let blocked_by_count = blocked_by.len();
blocked.push(BlockedIssue {
issue,
blocked_by,
blocked_by_count,
});
}
}
Ok(blocked)
}
pub fn get_ready(
&self,
assignee: Option<&str>,
priority: Option<i32>,
limit: usize,
sort_policy: &str,
) -> Result<Vec<Issue>> {
let priority_list = priority.map(|p| vec![p]);
let issues = self.list_issues(Some(Status::Open), priority_list, None, assignee, None)?;
let mut ready: Vec<Issue> = issues
.into_iter()
.filter(|i| !i.has_blocking_dependencies())
.collect();
match sort_policy {
"priority" => {
ready.sort_by_key(|i| i.priority);
}
"oldest" => {
ready.sort_by_key(|i| i.created_at);
}
"hybrid" => {
ready.sort_by(|a, b| {
a.priority
.cmp(&b.priority)
.then_with(|| a.created_at.cmp(&b.created_at))
});
}
_ => {
ready.sort_by(|a, b| {
a.priority
.cmp(&b.priority)
.then_with(|| a.created_at.cmp(&b.created_at))
});
}
}
ready.truncate(limit);
Ok(ready)
}
pub fn export_to_jsonl(
&self,
output_path: &Path,
status: Option<Status>,
priority: Option<i32>,
issue_type: Option<IssueType>,
assignee: Option<&str>,
) -> Result<usize> {
use std::io::Write;
let priority_list = priority.map(|p| vec![p]);
let issues = self.list_issues(status, priority_list, issue_type, assignee, None)?;
let mut file = fs::File::create(output_path)
.with_context(|| format!("Failed to create output file: {}", output_path.display()))?;
for issue in &issues {
let json =
serde_json::to_string(&issue).context("Failed to serialize issue to JSON")?;
writeln!(file, "{}", json).context("Failed to write to output file")?;
}
Ok(issues.len())
}
#[allow(dead_code)] pub fn import_from_jsonl(
&self,
input_path: &Path,
overwrite: bool,
) -> Result<(usize, usize, Vec<String>)> {
use std::io::{BufRead, BufReader};
let _lock = Lock::acquire(&self.beads_dir)?;
let file = fs::File::open(input_path)
.with_context(|| format!("Failed to open input file: {}", input_path.display()))?;
let reader = BufReader::new(file);
let mut imported = 0;
let mut skipped = 0;
let mut errors = Vec::new();
for (line_num, line_result) in reader.lines().enumerate() {
let line = match line_result {
Ok(l) => l,
Err(e) => {
errors.push(format!("Line {}: Failed to read line: {}", line_num + 1, e));
continue;
}
};
if line.trim().is_empty() {
continue;
}
let issue: Issue = match serde_json::from_str(&line) {
Ok(i) => i,
Err(e) => {
errors.push(format!(
"Line {}: Failed to parse JSON: {}",
line_num + 1,
e
));
continue;
}
};
let issue_path = self.issues_dir.join(format!("{}.md", issue.id));
if issue_path.exists() && !overwrite {
skipped += 1;
continue;
}
match issue_to_markdown(&issue) {
Ok(markdown) => {
if let Err(e) = fs::write(&issue_path, &markdown) {
errors.push(format!(
"Issue {}: Failed to write markdown file: {}",
issue.id, e
));
continue;
}
if let Err(e) = set_file_mtime_from_issue(&issue_path, &issue) {
eprintln!("Warning: Failed to set mtime for {}: {}", issue.id, e);
}
imported += 1;
}
Err(e) => {
errors.push(format!(
"Issue {}: Failed to convert to markdown: {}",
issue.id, e
));
}
}
}
Ok((imported, skipped, errors))
}
pub fn rename_prefix(
&self,
new_prefix: &str,
dry_run: bool,
force: bool,
) -> Result<Vec<String>> {
let _lock = Lock::acquire(&self.beads_dir)?;
let old_prefix = self.get_prefix()?;
if old_prefix == new_prefix {
anyhow::bail!("New prefix '{}' is the same as current prefix", new_prefix);
}
if !new_prefix.chars().all(|c| c.is_alphanumeric() || c == '-') {
anyhow::bail!(
"Invalid prefix format: '{}'. Use only alphanumeric characters and hyphens.",
new_prefix
);
}
let all_issues = self.list_all_issues_no_dependents()?;
let mut id_mapping = HashMap::new();
for issue in &all_issues {
if let Some(pos) = issue.id.rfind('-') {
let issue_prefix = &issue.id[..pos];
let issue_number = &issue.id[pos + 1..];
if issue_prefix == old_prefix {
let new_id = format!("{}-{}", new_prefix, issue_number);
if !force {
let new_path = self.issues_dir.join(format!("{}.md", new_id));
if new_path.exists() {
anyhow::bail!(
"Cannot rename: new ID '{}' already exists. Use --force to override.",
new_id
);
}
}
id_mapping.insert(issue.id.clone(), new_id);
}
}
}
if id_mapping.is_empty() {
anyhow::bail!("No issues found with prefix '{}'", old_prefix);
}
let mut changes = Vec::new();
changes.push(format!(
"Update config.yaml: issue-prefix: {} -> {}",
old_prefix, new_prefix
));
for issue in &all_issues {
if let Some(new_id) = id_mapping.get(&issue.id) {
changes.push(format!("Rename file: {}.md -> {}.md", issue.id, new_id));
changes.push(format!(
"Update ID in frontmatter: {} -> {}",
issue.id, new_id
));
for dep_id in issue.depends_on.keys() {
if id_mapping.contains_key(dep_id) {
changes.push(format!(
"Update dependency in {}: {} -> {}",
new_id,
dep_id,
id_mapping.get(dep_id).unwrap()
));
}
}
}
}
if dry_run {
return Ok(changes);
}
for issue in all_issues {
let mut updated_issue = issue.clone();
let mut issue_modified = false;
if let Some(new_id) = id_mapping.get(&issue.id) {
updated_issue.id = new_id.clone();
issue_modified = true;
}
let mut new_depends_on = HashMap::new();
for (dep_id, dep_type) in &updated_issue.depends_on {
let mapped_dep_id = id_mapping.get(dep_id).unwrap_or(dep_id);
if mapped_dep_id != dep_id {
issue_modified = true;
}
new_depends_on.insert(mapped_dep_id.clone(), *dep_type);
}
updated_issue.depends_on = new_depends_on;
let old_title = updated_issue.title.clone();
let old_description = updated_issue.description.clone();
let old_design = updated_issue.design.clone();
let old_notes = updated_issue.notes.clone();
let old_acceptance = updated_issue.acceptance_criteria.clone();
replace_ids_in_issue_text(&mut updated_issue, &id_mapping);
if updated_issue.title != old_title
|| updated_issue.description != old_description
|| updated_issue.design != old_design
|| updated_issue.notes != old_notes
|| updated_issue.acceptance_criteria != old_acceptance
{
issue_modified = true;
}
if issue_modified {
updated_issue.updated_at = chrono::Utc::now();
let new_path = self.issues_dir.join(format!("{}.md", updated_issue.id));
let markdown = issue_to_markdown(&updated_issue)?;
fs::write(&new_path, markdown).context(format!(
"Failed to write renamed issue: {}",
updated_issue.id
))?;
if updated_issue.id != issue.id {
let old_path = self.issues_dir.join(format!("{}.md", issue.id));
fs::remove_file(&old_path)
.context(format!("Failed to remove old issue file: {}", issue.id))?;
}
}
}
let config_path = self.beads_dir.join("config.yaml");
let mut config = HashMap::new();
config.insert("issue-prefix".to_string(), new_prefix.to_string());
let config_yaml = serde_yaml::to_string(&config)?;
fs::write(&config_path, config_yaml).context("Failed to update config.yaml")?;
Ok(changes)
}
pub fn migrate_to_hash_ids(
&self,
dry_run: bool,
update_config: bool,
) -> Result<(Vec<String>, HashMap<String, String>)> {
let _lock = Lock::acquire(&self.beads_dir)?;
if self.use_hash_ids()? {
anyhow::bail!("Database is already using hash-based IDs (mb-hash-ids: true in config-minibeads.yaml)");
}
let prefix = self.get_prefix()?;
let all_issues = self.list_all_issues_no_dependents()?;
let mut id_mapping = HashMap::new();
for issue in &all_issues {
if let Some(pos) = issue.id.rfind('-') {
let issue_prefix = &issue.id[..pos];
let issue_suffix = &issue.id[pos + 1..];
if issue_prefix == prefix && issue_suffix.parse::<u32>().is_ok() {
let hash_id =
self.generate_hash_id(&prefix, &issue.title, &issue.description)?;
let new_path = self.issues_dir.join(format!("{}.md", hash_id));
if new_path.exists() {
anyhow::bail!(
"Cannot migrate: generated hash ID '{}' already exists. This is a collision - please report this bug.",
hash_id
);
}
id_mapping.insert(issue.id.clone(), hash_id);
}
}
}
if id_mapping.is_empty() {
anyhow::bail!(
"No numeric IDs found to migrate. All issues already use hash-based or custom IDs."
);
}
let mut changes = Vec::new();
if update_config {
changes.push("Update config-minibeads.yaml: mb-hash-ids: false -> true".to_string());
}
for issue in &all_issues {
if let Some(new_id) = id_mapping.get(&issue.id) {
changes.push(format!("Rename file: {}.md -> {}.md", issue.id, new_id));
changes.push(format!(
"Update ID in frontmatter: {} -> {}",
issue.id, new_id
));
for dep_id in issue.depends_on.keys() {
if id_mapping.contains_key(dep_id) {
changes.push(format!(
"Update dependency in {}: {} -> {}",
new_id,
dep_id,
id_mapping.get(dep_id).unwrap()
));
}
}
}
}
if dry_run {
return Ok((changes, HashMap::new()));
}
for issue in all_issues {
let mut updated_issue = issue.clone();
let mut issue_modified = false;
if let Some(new_id) = id_mapping.get(&issue.id) {
updated_issue.id = new_id.clone();
issue_modified = true;
}
let mut new_depends_on = HashMap::new();
for (dep_id, dep_type) in &updated_issue.depends_on {
let mapped_dep_id = id_mapping.get(dep_id).unwrap_or(dep_id);
if mapped_dep_id != dep_id {
issue_modified = true;
}
new_depends_on.insert(mapped_dep_id.clone(), *dep_type);
}
updated_issue.depends_on = new_depends_on;
let old_title = updated_issue.title.clone();
let old_description = updated_issue.description.clone();
let old_design = updated_issue.design.clone();
let old_notes = updated_issue.notes.clone();
let old_acceptance = updated_issue.acceptance_criteria.clone();
replace_ids_in_issue_text(&mut updated_issue, &id_mapping);
if updated_issue.title != old_title
|| updated_issue.description != old_description
|| updated_issue.design != old_design
|| updated_issue.notes != old_notes
|| updated_issue.acceptance_criteria != old_acceptance
{
issue_modified = true;
}
if issue_modified {
updated_issue.updated_at = chrono::Utc::now();
let new_path = self.issues_dir.join(format!("{}.md", updated_issue.id));
let markdown = issue_to_markdown(&updated_issue)?;
fs::write(&new_path, markdown).context(format!(
"Failed to write renamed issue: {}",
updated_issue.id
))?;
if updated_issue.id != issue.id {
let old_path = self.issues_dir.join(format!("{}.md", issue.id));
fs::remove_file(&old_path)
.context(format!("Failed to remove old issue file: {}", issue.id))?;
}
}
}
if update_config {
let minibeads_config_path = self.beads_dir.join("config-minibeads.yaml");
update_yaml_key_value(&minibeads_config_path, "mb-hash-ids", "true")?;
}
Ok((changes, id_mapping))
}
pub fn migrate_to_numeric_ids(
&self,
dry_run: bool,
update_config: bool,
) -> Result<(Vec<String>, HashMap<String, String>)> {
let _lock = Lock::acquire(&self.beads_dir)?;
let prefix = self.get_prefix()?;
let all_issues = self.list_all_issues_no_dependents()?;
const MAX_GAP: u32 = 100;
let mut hash_issues = Vec::new();
let mut numeric_ids = Vec::new();
let mut numeric_id_to_issue: HashMap<u32, Issue> = HashMap::new();
for issue in &all_issues {
if let Some(pos) = issue.id.rfind('-') {
let issue_prefix = &issue.id[..pos];
let issue_suffix = &issue.id[pos + 1..];
if issue_prefix == prefix {
if let Ok(num) = issue_suffix.parse::<u32>() {
numeric_ids.push(num);
numeric_id_to_issue.insert(num, issue.clone());
} else {
hash_issues.push(issue.clone());
}
} else {
if issue_suffix.len() >= 4 {
hash_issues.push(issue.clone());
}
}
}
}
let (max_numeric_id, ids_above_gap) = find_max_numeric_id_before_gap(&numeric_ids, MAX_GAP);
if !ids_above_gap.is_empty() {
eprintln!("Warning: Found {} numeric ID(s) above a gap of {} (likely hash IDs with all-numeric hashes)",
ids_above_gap.len(), MAX_GAP);
eprintln!(" These will be treated as hash IDs and renumbered:");
for id_num in &ids_above_gap {
if let Some(issue) = numeric_id_to_issue.get(id_num) {
eprintln!(" - {}", issue.id);
hash_issues.push(issue.clone());
}
}
eprintln!(
" True max numeric ID before gap: {}",
max_numeric_id
);
}
if hash_issues.is_empty() {
anyhow::bail!(
"No hash-based IDs found to migrate. All issues already use numeric IDs."
);
}
hash_issues.sort_by_key(|issue| issue.created_at);
let mut id_mapping = HashMap::new();
let mut next_id = max_numeric_id + 1;
for issue in &hash_issues {
let new_id = format!("{}-{}", prefix, next_id);
let new_path = self.issues_dir.join(format!("{}.md", new_id));
if new_path.exists() {
anyhow::bail!(
"Cannot migrate: numeric ID '{}' already exists. This should not happen - please report this bug.",
new_id
);
}
id_mapping.insert(issue.id.clone(), new_id);
next_id += 1;
}
let mut changes = Vec::new();
if update_config {
changes.push("Update config-minibeads.yaml: mb-hash-ids: true -> false".to_string());
}
for issue in &all_issues {
if let Some(new_id) = id_mapping.get(&issue.id) {
changes.push(format!("Rename file: {}.md -> {}.md", issue.id, new_id));
changes.push(format!(
"Update ID in frontmatter: {} -> {}",
issue.id, new_id
));
for dep_id in issue.depends_on.keys() {
if id_mapping.contains_key(dep_id) {
changes.push(format!(
"Update dependency in {}: {} -> {}",
new_id,
dep_id,
id_mapping.get(dep_id).unwrap()
));
}
}
}
}
if dry_run {
return Ok((changes, HashMap::new()));
}
for issue in all_issues {
let mut updated_issue = issue.clone();
let mut issue_modified = false;
if let Some(new_id) = id_mapping.get(&issue.id) {
updated_issue.id = new_id.clone();
issue_modified = true;
}
let mut new_depends_on = HashMap::new();
for (dep_id, dep_type) in &updated_issue.depends_on {
let mapped_dep_id = id_mapping.get(dep_id).unwrap_or(dep_id);
if mapped_dep_id != dep_id {
issue_modified = true;
}
new_depends_on.insert(mapped_dep_id.clone(), *dep_type);
}
updated_issue.depends_on = new_depends_on;
let old_title = updated_issue.title.clone();
let old_description = updated_issue.description.clone();
let old_design = updated_issue.design.clone();
let old_notes = updated_issue.notes.clone();
let old_acceptance = updated_issue.acceptance_criteria.clone();
replace_ids_in_issue_text(&mut updated_issue, &id_mapping);
if updated_issue.title != old_title
|| updated_issue.description != old_description
|| updated_issue.design != old_design
|| updated_issue.notes != old_notes
|| updated_issue.acceptance_criteria != old_acceptance
{
issue_modified = true;
}
if issue_modified {
updated_issue.updated_at = chrono::Utc::now();
let new_path = self.issues_dir.join(format!("{}.md", updated_issue.id));
let markdown = issue_to_markdown(&updated_issue)?;
fs::write(&new_path, markdown).context(format!(
"Failed to write renamed issue: {}",
updated_issue.id
))?;
if updated_issue.id != issue.id {
let old_path = self.issues_dir.join(format!("{}.md", issue.id));
fs::remove_file(&old_path)
.context(format!("Failed to remove old issue file: {}", issue.id))?;
}
}
}
if update_config {
let minibeads_config_path = self.beads_dir.join("config-minibeads.yaml");
update_yaml_key_value(&minibeads_config_path, "mb-hash-ids", "false")?;
}
Ok((changes, id_mapping))
}
pub fn repack_numeric_ids(
&self,
dry_run: bool,
closed_issue_start: Option<u32>,
) -> Result<(Vec<String>, HashMap<String, String>)> {
let _lock = Lock::acquire(&self.beads_dir)?;
let prefix = self.get_prefix()?;
let all_issues = self.list_all_issues_no_dependents()?;
let mut numeric_issues = Vec::new();
for issue in &all_issues {
if let Some(pos) = issue.id.rfind('-') {
let issue_prefix = &issue.id[..pos];
let issue_suffix = &issue.id[pos + 1..];
if issue_prefix == prefix {
if issue_suffix.parse::<u32>().is_ok() {
numeric_issues.push(issue.clone());
}
}
}
}
if numeric_issues.is_empty() {
anyhow::bail!("No numeric IDs found to repack. Use 'mb migrate --to numeric' first.");
}
numeric_issues.sort_by_key(|issue| issue.created_at);
let mut id_mapping = HashMap::new();
if let Some(closed_start) = closed_issue_start {
let mut open_issues = Vec::new();
let mut closed_issues = Vec::new();
for issue in &numeric_issues {
if issue.status == Status::Closed {
closed_issues.push(issue.clone());
} else {
open_issues.push(issue.clone());
}
}
if open_issues.len() as u32 >= closed_start {
anyhow::bail!(
"Cannot repack: {} open issues found, but --closed-issue-start is set to {}. \
Open issues would collide with closed issue numbering. \
Use a larger value for --closed-issue-start (e.g., {}).",
open_issues.len(),
closed_start,
open_issues.len() as u32 + 1
);
}
let mut next_open_id = 1;
for issue in &open_issues {
let new_id = format!("{}-{}", prefix, next_open_id);
if issue.id != new_id {
id_mapping.insert(issue.id.clone(), new_id);
}
next_open_id += 1;
}
let mut next_closed_id = closed_start;
for issue in &closed_issues {
let new_id = format!("{}-{}", prefix, next_closed_id);
if issue.id != new_id {
id_mapping.insert(issue.id.clone(), new_id);
}
next_closed_id += 1;
}
} else {
let mut next_id = 1;
for issue in &numeric_issues {
let new_id = format!("{}-{}", prefix, next_id);
if issue.id != new_id {
id_mapping.insert(issue.id.clone(), new_id);
}
next_id += 1;
}
}
if id_mapping.is_empty() {
eprintln!("No gaps found - IDs are already contiguous (1, 2, 3, ...)");
return Ok((
vec!["No changes needed - IDs already contiguous".to_string()],
HashMap::new(),
));
}
let mut changes = Vec::new();
changes.push(format!(
"Repacking {} numeric IDs to fill gaps",
id_mapping.len()
));
for issue in &all_issues {
if let Some(new_id) = id_mapping.get(&issue.id) {
changes.push(format!("Rename file: {}.md -> {}.md", issue.id, new_id));
changes.push(format!(
"Update ID in frontmatter: {} -> {}",
issue.id, new_id
));
for dep_id in issue.depends_on.keys() {
if id_mapping.contains_key(dep_id) {
changes.push(format!(
"Update dependency in {}: {} -> {}",
new_id,
dep_id,
id_mapping.get(dep_id).unwrap()
));
}
}
}
}
if dry_run {
return Ok((changes, HashMap::new()));
}
for issue in all_issues {
let mut updated_issue = issue.clone();
let mut issue_modified = false;
if let Some(new_id) = id_mapping.get(&issue.id) {
updated_issue.id = new_id.clone();
issue_modified = true;
}
let mut new_depends_on = HashMap::new();
for (dep_id, dep_type) in &updated_issue.depends_on {
let mapped_dep_id = id_mapping.get(dep_id).unwrap_or(dep_id);
if mapped_dep_id != dep_id {
issue_modified = true;
}
new_depends_on.insert(mapped_dep_id.clone(), *dep_type);
}
updated_issue.depends_on = new_depends_on;
let old_title = updated_issue.title.clone();
let old_description = updated_issue.description.clone();
let old_design = updated_issue.design.clone();
let old_notes = updated_issue.notes.clone();
let old_acceptance = updated_issue.acceptance_criteria.clone();
replace_ids_in_issue_text(&mut updated_issue, &id_mapping);
if updated_issue.title != old_title
|| updated_issue.description != old_description
|| updated_issue.design != old_design
|| updated_issue.notes != old_notes
|| updated_issue.acceptance_criteria != old_acceptance
{
issue_modified = true;
}
if issue_modified {
updated_issue.updated_at = chrono::Utc::now();
let new_path = self.issues_dir.join(format!("{}.md", updated_issue.id));
let markdown = issue_to_markdown(&updated_issue)?;
fs::write(&new_path, markdown).context(format!(
"Failed to write repacked issue: {}",
updated_issue.id
))?;
if updated_issue.id != issue.id {
let old_path = self.issues_dir.join(format!("{}.md", issue.id));
fs::remove_file(&old_path)
.context(format!("Failed to remove old issue file: {}", issue.id))?;
}
}
}
Ok((changes, id_mapping))
}
}
fn infer_prefix(beads_dir: &Path) -> Option<String> {
let parent = beads_dir.parent()?.parent()?;
let name = parent.file_name()?.to_str()?;
let prefix = name.to_lowercase().replace([' ', '_'], "-");
Some(prefix)
}
fn create_minibeads_config(beads_dir: &Path, mb_hash_ids: bool) -> Result<()> {
use std::io::Write;
let config_path = beads_dir.join("config-minibeads.yaml");
if config_path.exists() {
return Ok(());
}
let mut file =
fs::File::create(&config_path).context("Failed to create config-minibeads.yaml")?;
writeln!(file, "# Minibeads-specific configuration options")?;
writeln!(
file,
"# This file contains options that are NOT compatible with upstream bd"
)?;
writeln!(file)?;
writeln!(
file,
"# Use hash-based issue IDs instead of sequential numbers"
)?;
writeln!(
file,
"# When true, issues are named like: prefix-a1b2c3 (based on content hash)"
)?;
writeln!(
file,
"# When false, issues are named like: prefix-1, prefix-2, ... (sequential)"
)?;
writeln!(file, "# Default: false")?;
writeln!(
file,
"mb-hash-ids: {}",
if mb_hash_ids { "true" } else { "false" }
)?;
writeln!(file)?;
writeln!(file, "# Hash encoding format for hash-based IDs")?;
writeln!(file, "# base36: Uses characters [0-9a-z] for better information density (recommended, matches upstream bd)")?;
writeln!(
file,
"# hex: Uses characters [0-9a-f] for hexadecimal encoding (legacy format)"
)?;
writeln!(file, "# Default: base36")?;
writeln!(file, "hash-encoding: base36")?;
Ok(())
}
fn ensure_gitignore(beads_dir: &Path) -> Result<()> {
use std::io::{BufRead, BufReader, Write};
let gitignore_path = beads_dir.join(".gitignore");
let required_entries = ["minibeads.lock", "command_history.log"];
let mut existing_lines = Vec::new();
if gitignore_path.exists() {
let file = fs::File::open(&gitignore_path).context("Failed to read .gitignore")?;
let reader = BufReader::new(file);
for line in reader.lines() {
existing_lines.push(line?);
}
}
let mut missing_entries = Vec::new();
for entry in &required_entries {
if !existing_lines.iter().any(|line| line.trim() == *entry) {
missing_entries.push(*entry);
}
}
if !missing_entries.is_empty() {
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&gitignore_path)
.context("Failed to open .gitignore for writing")?;
if !existing_lines.is_empty() && !existing_lines.last().unwrap().is_empty() {
writeln!(file)?;
}
for entry in missing_entries {
writeln!(file, "{}", entry)?;
}
}
Ok(())
}
#[allow(dead_code)] fn set_file_mtime_from_issue(file_path: &Path, issue: &Issue) -> Result<()> {
use filetime::{set_file_mtime, FileTime};
use std::time::SystemTime;
let system_time: SystemTime = issue.updated_at.into();
let file_time = FileTime::from_system_time(system_time);
set_file_mtime(file_path, file_time)
.with_context(|| format!("Failed to set mtime for {}", file_path.display()))?;
Ok(())
}
#[allow(dead_code)] pub fn get_file_mtime(file_path: &Path) -> Result<chrono::DateTime<chrono::Utc>> {
use chrono::{DateTime, Utc};
use std::time::SystemTime;
let metadata = fs::metadata(file_path)
.with_context(|| format!("Failed to get metadata for {}", file_path.display()))?;
let mtime: SystemTime = metadata
.modified()
.with_context(|| format!("Failed to get modified time for {}", file_path.display()))?;
let datetime: DateTime<Utc> = mtime.into();
Ok(datetime)
}
fn find_max_numeric_id_before_gap(numeric_ids: &[u32], max_gap: u32) -> (u32, Vec<u32>) {
if numeric_ids.is_empty() {
return (0, Vec::new());
}
let mut sorted_ids = numeric_ids.to_vec();
sorted_ids.sort_unstable();
let mut max_before_gap = sorted_ids[0];
let mut gap_start = 0;
let mut found_gap = false;
for i in 0..sorted_ids.len() - 1 {
let current = sorted_ids[i];
let next = sorted_ids[i + 1];
let gap_size = next - current;
if gap_size >= max_gap {
max_before_gap = current;
gap_start = i + 1;
found_gap = true;
break;
}
}
if !found_gap {
max_before_gap = *sorted_ids.last().unwrap();
return (max_before_gap, Vec::new());
}
let ids_above_gap = sorted_ids[gap_start..].to_vec();
(max_before_gap, ids_above_gap)
}
fn update_yaml_key_value(file_path: &Path, key: &str, new_value: &str) -> Result<()> {
use std::io::{BufRead, BufReader};
let file = fs::File::open(file_path)
.with_context(|| format!("Failed to open config file: {}", file_path.display()))?;
let reader = BufReader::new(file);
let mut lines: Vec<String> = reader
.lines()
.collect::<Result<_, _>>()
.with_context(|| format!("Failed to read config file: {}", file_path.display()))?;
let key_prefix = format!("{}:", key);
let mut found = false;
for line in &mut lines {
let trimmed = line.trim();
if trimmed.starts_with(&key_prefix) {
let indent = line.len() - line.trim_start().len();
*line = format!("{}{}: {}", " ".repeat(indent), key, new_value);
found = true;
break;
}
}
if !found {
anyhow::bail!(
"Key '{}' not found in config file: {}",
key,
file_path.display()
);
}
let content = lines.join("\n") + "\n"; fs::write(file_path, content)
.with_context(|| format!("Failed to write config file: {}", file_path.display()))?;
Ok(())
}
#[cfg(test)]
mod list_order_tests {
use super::*;
use crate::types::IssueType;
use chrono::{DateTime, Duration};
fn issue_at(id: &str, created: DateTime<chrono::Utc>) -> Issue {
let mut issue = Issue::new(id.to_string(), "t".to_string(), 3, IssueType::Task);
issue.created_at = created;
issue
}
#[test]
fn numeric_suffix_parsing() {
assert_eq!(numeric_id_suffix("minibeads-42"), Some(42));
assert_eq!(numeric_id_suffix("minibeads-1"), Some(1));
assert_eq!(numeric_id_suffix("minibeads-a3f9"), None);
assert_eq!(numeric_id_suffix("foo-bar-7"), Some(7));
}
#[test]
fn numeric_cluster_first_then_hash() {
let base = chrono::Utc::now();
let mut issues = vec![
issue_at("minibeads-a3f9", base + Duration::seconds(1)),
issue_at("minibeads-10", base + Duration::seconds(2)),
issue_at("minibeads-2", base + Duration::seconds(3)),
issue_at("minibeads-b001", base),
issue_at("minibeads-1", base + Duration::seconds(4)),
];
issues.sort_by(compare_for_list);
let order: Vec<&str> = issues.iter().map(|i| i.id.as_str()).collect();
assert_eq!(
order,
vec![
"minibeads-1",
"minibeads-2",
"minibeads-10",
"minibeads-b001",
"minibeads-a3f9",
]
);
}
}
#[cfg(test)]
mod config_compat_tests {
use super::*;
#[test]
fn open_tolerates_commented_issue_prefix() {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
let issues_dir = beads_dir.join("issues");
fs::create_dir_all(&issues_dir).unwrap();
fs::write(
beads_dir.join("config.yaml"),
"# Beads Configuration File\n# issue-prefix: \"\"\nno-db: false\n",
)
.unwrap();
fs::write(issues_dir.join("acme-1.md"), "placeholder").unwrap();
fs::write(issues_dir.join("acme-2.md"), "placeholder").unwrap();
let storage = Storage::open(beads_dir).expect("open must tolerate commented prefix");
assert_eq!(storage.get_prefix().unwrap(), "acme");
}
}
#[cfg(test)]
mod claim_tests {
use super::*;
use chrono::{Duration, Utc};
fn storage_with_one_issue() -> (tempfile::TempDir, Storage, String) {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
let storage =
Storage::init(beads_dir, Some("demo".to_string()), false).expect("init storage");
let issue = storage
.create_issue(
"A task".to_string(),
String::new(),
None,
None,
2,
IssueType::Task,
None,
Vec::new(),
None,
None,
Vec::new(),
)
.expect("create issue");
(tmp, storage, issue.id)
}
#[test]
fn claim_sets_assignee_status_and_window() {
let (_tmp, storage, id) = storage_with_one_issue();
let until = Utc::now() + Duration::hours(48);
let empty = HashMap::new();
let issue = storage.claim_issue(&id, "host-a", until, &empty).unwrap();
assert_eq!(issue.assignee, "host-a");
assert_eq!(issue.status, Status::InProgress);
assert!(issue.claimed_at.is_some());
assert_eq!(issue.claimed_until, Some(until));
}
#[test]
fn claim_rejected_when_actively_claimed_by_other() {
let (_tmp, storage, id) = storage_with_one_issue();
let until = Utc::now() + Duration::hours(48);
let empty = HashMap::new();
storage.claim_issue(&id, "host-a", until, &empty).unwrap();
let err = storage
.claim_issue(&id, "host-b", until, &empty)
.unwrap_err();
assert!(
err.to_string().contains("already claimed"),
"unexpected error: {err}"
);
let issue = storage.get_issue(&id).unwrap().unwrap();
assert_eq!(issue.assignee, "host-a");
}
#[test]
fn same_actor_can_refresh_its_own_claim() {
let (_tmp, storage, id) = storage_with_one_issue();
let empty = HashMap::new();
let first = Utc::now() + Duration::hours(1);
storage.claim_issue(&id, "host-a", first, &empty).unwrap();
let extended = Utc::now() + Duration::hours(72);
let issue = storage
.claim_issue(&id, "host-a", extended, &empty)
.unwrap();
assert_eq!(issue.assignee, "host-a");
assert_eq!(issue.claimed_until, Some(extended));
}
#[test]
fn expired_claim_can_be_taken_by_another_worker() {
let (_tmp, storage, id) = storage_with_one_issue();
let empty = HashMap::new();
let past = Utc::now() - Duration::hours(1);
storage.claim_issue(&id, "host-a", past, &empty).unwrap();
let fresh = Utc::now() + Duration::hours(48);
let issue = storage
.claim_issue(&id, "host-b", fresh, &empty)
.expect("stale claim should be reclaimable");
assert_eq!(issue.assignee, "host-b");
}
#[test]
fn claiming_a_closed_issue_fails() {
let (_tmp, storage, id) = storage_with_one_issue();
storage.close_issue(&id, "done").unwrap();
let until = Utc::now() + Duration::hours(1);
let err = storage
.claim_issue(&id, "host-a", until, &HashMap::new())
.unwrap_err();
assert!(err.to_string().contains("closed"), "unexpected: {err}");
}
#[test]
fn release_clears_claim_and_reopens() {
let (_tmp, storage, id) = storage_with_one_issue();
let until = Utc::now() + Duration::hours(48);
storage
.claim_issue(&id, "host-a", until, &HashMap::new())
.unwrap();
let issue = storage.release_issue(&id, "host-a", false).unwrap();
assert!(issue.assignee.is_empty());
assert_eq!(issue.status, Status::Open);
assert!(issue.claimed_at.is_none());
assert!(issue.claimed_until.is_none());
}
#[test]
fn release_by_other_requires_force() {
let (_tmp, storage, id) = storage_with_one_issue();
let until = Utc::now() + Duration::hours(48);
storage
.claim_issue(&id, "host-a", until, &HashMap::new())
.unwrap();
assert!(storage.release_issue(&id, "host-b", false).is_err());
let issue = storage.release_issue(&id, "host-b", true).unwrap();
assert!(issue.assignee.is_empty());
}
#[test]
fn claim_applies_sibling_updates_but_not_status_override() {
let (_tmp, storage, id) = storage_with_one_issue();
let until = Utc::now() + Duration::hours(48);
let mut updates = HashMap::new();
updates.insert("priority".to_string(), "0".to_string());
updates.insert("status".to_string(), "blocked".to_string());
let issue = storage.claim_issue(&id, "host-a", until, &updates).unwrap();
assert_eq!(issue.priority, 0);
assert_eq!(issue.status, Status::InProgress);
}
}
#[cfg(test)]
mod search_replace_tests {
use super::*;
fn storage_with_description(desc: &str) -> (tempfile::TempDir, Storage, String) {
let tmp = tempfile::tempdir().unwrap();
let beads_dir = tmp.path().join(".beads");
let storage =
Storage::init(beads_dir, Some("demo".to_string()), false).expect("init storage");
let issue = storage
.create_issue(
"A task".to_string(),
desc.to_string(),
None,
None,
2,
IssueType::Task,
None,
Vec::new(),
None,
None,
Vec::new(),
)
.expect("create issue");
(tmp, storage, issue.id)
}
#[test]
fn unique_match_is_replaced() {
let (_tmp, storage, id) = storage_with_description("hello world, hello there");
let issue = storage
.search_replace_issue(&id, EditField::Description, "world", "rust", false)
.unwrap();
assert_eq!(issue.description, "hello rust, hello there");
}
#[test]
fn missing_search_text_errors_and_leaves_file_untouched() {
let (_tmp, storage, id) = storage_with_description("unchanged body");
let err = storage
.search_replace_issue(&id, EditField::Description, "absent", "x", false)
.unwrap_err();
assert!(err.to_string().contains("not found"), "unexpected: {err}");
let issue = storage.get_issue(&id).unwrap().unwrap();
assert_eq!(issue.description, "unchanged body");
}
#[test]
fn ambiguous_match_errors_without_replace_all() {
let (_tmp, storage, id) = storage_with_description("a a a");
let err = storage
.search_replace_issue(&id, EditField::Description, "a", "b", false)
.unwrap_err();
assert!(err.to_string().contains("3 times"), "unexpected: {err}");
let issue = storage.get_issue(&id).unwrap().unwrap();
assert_eq!(issue.description, "a a a");
}
#[test]
fn replace_all_rewrites_every_occurrence() {
let (_tmp, storage, id) = storage_with_description("a a a");
let issue = storage
.search_replace_issue(&id, EditField::Description, "a", "b", true)
.unwrap();
assert_eq!(issue.description, "b b b");
}
#[test]
fn empty_search_is_rejected() {
let (_tmp, storage, id) = storage_with_description("body");
let err = storage
.search_replace_issue(&id, EditField::Description, "", "x", false)
.unwrap_err();
assert!(err.to_string().contains("empty"), "unexpected: {err}");
}
#[test]
fn multiline_search_targets_selected_field() {
let (_tmp, storage, id) = storage_with_description("body");
storage
.update_issue(
&id,
HashMap::from([(
"design".to_string(),
"line one\nline two\nline three".to_string(),
)]),
)
.unwrap();
let issue = storage
.search_replace_issue(
&id,
EditField::Design,
"line two\nline three",
"line two only",
false,
)
.unwrap();
assert_eq!(issue.design, "line one\nline two only");
assert_eq!(issue.description, "body");
}
}