use std::collections::BTreeMap;
use std::path::PathBuf;
use crate::cli::BeadsAction;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::storage::Store;
use crate::sync::ProcessRunner;
use crate::sync::beads::{self, BeadsRow, EventRecord};
pub fn run(config: &Config, store: &Store, action: &BeadsAction) -> Result<()> {
let runner = ProcessRunner;
match action {
BeadsAction::Detect => {
let detection = beads::detect(&runner, config);
println!("{}", detection.detail);
println!(
" binary: {} ({})",
detection.binary,
if detection.available {
"found"
} else {
"missing"
}
);
println!(
" workspace: {}",
detection.path.as_deref().unwrap_or("none")
);
match detection.journal {
Some(true) => println!(" journal: on"),
Some(false) => println!(" journal: OFF — a live mirror would see nothing"),
None => {}
}
Ok(())
}
BeadsAction::Status => {
let rows = beads::fetch(&runner, config)?;
let status_by_id = status_map(&rows);
let (mut open, mut closed, mut blocked, mut ready) = (0usize, 0usize, 0usize, 0usize);
for row in &rows {
if row.status.as_deref() == Some("closed") {
closed += 1;
} else {
open += 1;
if beads::is_blocked(row, &status_by_id) {
blocked += 1;
} else {
ready += 1;
}
}
}
println!("beads: {} issue(s)", rows.len());
println!(" open {open}");
println!(" closed {closed}");
println!(" blocked {blocked}");
println!(" ready {ready}");
Ok(())
}
BeadsAction::List { all } => {
let rows = beads::fetch(&runner, config)?;
let status_by_id = status_map(&rows);
let mut shown = 0usize;
for row in &rows {
if row.status.as_deref() == Some("closed") && !*all {
continue;
}
println!("{}", render_line(row, &status_by_id));
shown += 1;
}
if shown == 0 {
println!("no issues");
}
Ok(())
}
BeadsAction::Show { issue } => {
let rows = beads::fetch(&runner, config)?;
let row = find_row(&rows, issue)?;
print!("{}", render_detail(row));
Ok(())
}
BeadsAction::Watch { since, interval } => {
watch_with(&runner, config, since.unwrap_or(0), *interval, None)
}
BeadsAction::Push {
dry_run,
all,
verbose,
} => {
beads::ensure_writes_allowed(config)?;
push(config, store, *dry_run, *all, *verbose)
}
BeadsAction::Pull { quiet } => {
beads::ensure_writes_allowed(config)?;
pull(config, store, *quiet)
}
BeadsAction::Reconcile { quiet } => {
beads::ensure_writes_allowed(config)?;
pull(config, store, *quiet)?;
push(config, store, false, false, false)
}
}
}
fn root(store: &Store) -> PathBuf {
Config::project_root(&store.issues_dir).unwrap_or_else(|| PathBuf::from("."))
}
fn status_map(rows: &[BeadsRow]) -> BTreeMap<String, String> {
rows.iter()
.map(|row| (row.id.clone(), row.status.clone().unwrap_or_default()))
.collect()
}
fn render_line(row: &BeadsRow, status_by_id: &BTreeMap<String, String>) -> String {
let marker = if beads::is_blocked(row, status_by_id) {
" [blocked]"
} else {
""
};
format!(
"{:<28} {:<12} p{} {}{}",
row.id,
row.status.as_deref().unwrap_or("?"),
row.priority.map_or("-".to_string(), |p| p.to_string()),
row.title,
marker
)
}
fn render_detail(row: &BeadsRow) -> String {
let mut out = String::new();
out.push_str(&format!("{}\n", row.title));
out.push_str(&format!(" id: {}\n", row.id));
out.push_str(&format!(
" status: {}\n",
row.status.as_deref().unwrap_or("?")
));
if let Some(priority) = row.priority {
out.push_str(&format!(" priority: p{priority}\n"));
}
if let Some(kind) = &row.issue_type {
out.push_str(&format!(" type: {kind}\n"));
}
if let Some(assignee) = row.effective_assignee() {
out.push_str(&format!(" assignee: {assignee}\n"));
}
if !row.labels.is_empty() {
out.push_str(&format!(" labels: {}\n", row.labels.join(", ")));
}
if let Some(updated) = &row.updated_at {
out.push_str(&format!(" updated: {updated}\n"));
}
if !row.dependencies.is_empty() {
out.push_str(" dependencies:\n");
for dep in &row.dependencies {
out.push_str(&format!(" {} {}\n", dep.kind, dep.depends_on_id));
}
}
if let Some(description) = &row.description {
if !description.trim().is_empty() {
out.push_str(&format!("\n{description}\n"));
}
}
out
}
fn find_row<'a>(rows: &'a [BeadsRow], selector: &str) -> Result<&'a BeadsRow> {
if let Some(row) = rows.iter().find(|row| row.id == selector) {
return Ok(row);
}
let matches: Vec<&BeadsRow> = rows
.iter()
.filter(|row| row.id.contains(selector) || row.title.contains(selector))
.collect();
match matches.len() {
0 => Err(TixError::NotFound(format!(
"no beads issue matches `{selector}`"
))),
1 => Ok(matches[0]),
_ => Err(TixError::InvalidInput(format!(
"`{selector}` matches {} beads issues: {}",
matches.len(),
matches
.iter()
.map(|row| row.id.as_str())
.collect::<Vec<_>>()
.join(", ")
))),
}
}
fn watch_with(
runner: &ProcessRunner,
config: &Config,
since: u64,
interval: u64,
max_polls: Option<usize>,
) -> Result<()> {
let mut checkpoint = since;
let mut printed = since;
let mut polls = 0usize;
loop {
let records = beads::events(runner, config, checkpoint, 200)?;
for record in &records {
if record.seq <= printed {
continue;
}
println!("{}", render_event(record));
printed = record.seq;
checkpoint = record.seq;
}
polls += 1;
if max_polls.is_some_and(|max| polls >= max) {
return Ok(());
}
std::thread::sleep(std::time::Duration::from_secs(interval.max(1)));
}
}
fn render_event(record: &EventRecord) -> String {
let status = record
.issue
.as_ref()
.and_then(|issue| issue.status.clone())
.unwrap_or_default();
let title = record
.issue
.as_ref()
.map(|issue| issue.title.clone())
.unwrap_or_default();
format!(
"{:>6} {:<18} {:<8} {:<10} {}",
record.seq,
record.ts.as_deref().unwrap_or("-"),
record.op,
status,
if title.is_empty() {
record.issue_id.clone()
} else {
title
}
)
}
fn push(config: &Config, store: &Store, dry_run: bool, all: bool, verbose: bool) -> Result<()> {
let report = crate::commands::export::run_beads_core(
config,
store,
crate::commands::export::ExportOptions {
dry_run,
all,
verbose,
},
)?;
if dry_run && report.trim().is_empty() {
eprintln!("nothing changed since the last export (pass --all to send everything)");
}
print!("{report}");
Ok(())
}
fn pull(config: &Config, store: &Store, quiet: bool) -> Result<()> {
let root = root(store);
let mut state = beads::load_state(&root);
let rows = beads::fetch(&ProcessRunner, config)?;
let resolution = crate::workflow::resolution(config, config.default_config(), "default");
let report = beads::import_into(store, config, &resolution, &rows, &mut state)?;
beads::save_state(&root, &state)?;
if !quiet {
for diagnostic in &report.diagnostics {
eprintln!("{diagnostic}");
}
println!(
"pulled {} bead(s): {} created{}, {} updated, {} skipped",
rows.len(),
report.created,
if report.foreign > 0 {
format!(" ({} beads-originated)", report.foreign)
} else {
String::new()
},
report.updated,
report.skipped
);
}
Ok(())
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
fn row(id: &str, status: &str, title: &str) -> BeadsRow {
BeadsRow {
record_type: "issue".to_string(),
id: id.to_string(),
title: title.to_string(),
description: None,
status: Some(status.to_string()),
priority: Some(2),
issue_type: Some("task".to_string()),
assignee: None,
owner: None,
labels: Vec::new(),
dependencies: Vec::new(),
close_reason: None,
created_at: None,
updated_at: None,
}
}
#[test]
fn a_blocking_dependency_marks_the_dependent_blocked() {
let mut dependent = row("tix-2", "open", "two");
dependent.dependencies.push(beads::BeadsDependency {
issue_id: "tix-2".to_string(),
depends_on_id: "tix-1".to_string(),
kind: "blocks".to_string(),
});
let rows = vec![row("tix-1", "open", "one"), dependent.clone()];
let map = status_map(&rows);
assert!(beads::is_blocked(&dependent, &map));
assert!(render_line(&dependent, &map).contains("[blocked]"));
let rows = vec![row("tix-1", "closed", "one"), dependent.clone()];
let map = status_map(&rows);
assert!(!beads::is_blocked(&dependent, &map));
}
#[test]
fn show_resolves_an_exact_id_a_unique_fragment_and_refuses_ambiguity() {
let rows = vec![row("tix-1", "open", "alpha"), row("tix-22", "open", "beta")];
assert_eq!(find_row(&rows, "tix-1").unwrap().title, "alpha");
assert_eq!(find_row(&rows, "beta").unwrap().id, "tix-22");
assert_eq!(find_row(&rows, "tix-2").unwrap().id, "tix-22");
assert!(matches!(
find_row(&rows, "tix"),
Err(TixError::InvalidInput(_))
));
assert!(matches!(
find_row(&rows, "nope"),
Err(TixError::NotFound(_))
));
}
#[test]
fn a_journal_record_renders_its_operation_and_state() {
let record = EventRecord {
seq: 42,
ts: Some("2026-01-02T03:04:05Z".to_string()),
op: "create".to_string(),
issue_id: "tix-1".to_string(),
actor: None,
issue: Some(row("tix-1", "open", "wire the seam")),
};
let line = render_event(&record);
assert!(line.contains("42"), "{line}");
assert!(line.contains("create"), "{line}");
assert!(line.contains("wire the seam"), "{line}");
}
}