use std::collections::HashSet;
use std::path::Path;
use crate::cli::Command;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::model::Priority;
use crate::storage::{IssueSelector, Store};
pub mod agents;
pub mod analytics;
pub mod archive;
pub mod beads;
pub mod bulk;
pub mod capacity;
pub mod clean;
pub mod create;
pub mod cycle;
pub mod delete;
pub mod edit;
pub mod epic;
pub mod export;
pub mod health;
pub mod import;
pub mod init;
pub mod layout;
pub mod link;
pub mod lint;
pub mod list;
pub mod mentions;
pub mod migrate;
pub mod milestone;
pub mod mv;
pub mod parent;
pub mod project;
pub mod recurring;
pub mod render;
pub mod roadmap;
pub mod rollover;
pub mod search;
pub mod status;
pub mod templates;
pub mod time;
pub mod triage;
pub mod version;
pub mod view;
pub mod views;
pub mod wip;
pub mod workflow;
pub const AMBIGUITY_PREFIX: &str = "ambiguous selector";
pub fn upsert_block(existing: &str, block: &str, start_marker: &str, end_marker: &str) -> String {
let has_start = existing.contains(start_marker);
let has_end = existing.contains(end_marker);
match (has_start, has_end) {
(true, true) => {
let start = existing.find(start_marker).unwrap();
let end = existing.find(end_marker).unwrap() + end_marker.len();
if existing[start..end].trim() == block.trim() {
return existing.to_string();
}
format!("{}{}{}", &existing[..start], block, &existing[end..])
}
(true, false) | (false, true) => {
format!("{}\n{}\n", existing.trim_end(), block)
}
(false, false) => {
let trimmed = existing.trim_end();
if trimmed.is_empty() {
format!("{block}\n")
} else {
format!("{trimmed}\n\n{block}\n")
}
}
}
}
pub fn atomic_write(path: &Path, content: &str) -> Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path.file_name().map_or_else(
|| ".tix-tmp".to_string(),
|name| format!(".{}.tix-tmp", name.to_string_lossy()),
);
let tmp = parent.join(file_name);
std::fs::write(&tmp, content)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub fn project_edit_requires_a_field() -> TixError {
TixError::InvalidInput(
"no fields to change; pass at least one of --name, --description, --status, \
--lead, --start-date or --due-date"
.to_string(),
)
}
pub fn edit_projects_clears(value: &str) -> bool {
value.trim().is_empty()
}
pub fn edit_projects_must_be_clear(value: &str) -> TixError {
TixError::InvalidInput(format!(
"--projects on `tix edit` only clears associations (`--projects \"\"`); use \
--add-project / --remove-project to change them, not `--projects {value}`"
))
}
pub fn projects_beside_template_refused(projects: Option<&str>) -> Option<TixError> {
projects.filter(|value| !value.trim().is_empty()).map(|_| {
TixError::InvalidInput(
"--projects cannot be combined with --template yet; create without \
the template and add the association with `tix project add`"
.to_string(),
)
})
}
pub fn edit_epics_clears(value: &str) -> bool {
value.trim().is_empty()
}
pub fn edit_epics_must_be_clear(value: &str) -> TixError {
TixError::InvalidInput(format!(
"--epics on `tix edit` only clears references (`--epics \"\"`); use \
--add-epic / --remove-epic to change them, not `--epics {value}`"
))
}
pub fn epics_beside_template_refused(epics: Option<&str>) -> Option<TixError> {
epics.filter(|value| !value.trim().is_empty()).map(|_| {
TixError::InvalidInput(
"--epics cannot be combined with --template yet; create without \
the template and add the reference with `tix epic add`"
.to_string(),
)
})
}
#[allow(clippy::too_many_arguments)]
fn mentions_action(action: &crate::cli::MentionsAction) -> mentions::Action<'_> {
use crate::cli::MentionsAction as Cli;
match action {
Cli::List => mentions::Action::List,
Cli::Show { id } => mentions::Action::Show(id),
Cli::Read { id } => mentions::Action::Read(id),
Cli::Ignore { id } => mentions::Action::Ignore(id),
Cli::Jump { id } => mentions::Action::Jump(id),
}
}
#[allow(clippy::too_many_lines)]
pub fn dispatch(cmd: &Command, config: &Config, store: &Store) -> Result<()> {
match cmd {
Command::Init { beads: _ } => unreachable!("init is handled before dispatch"),
Command::InitAgents { yes: _ } => unreachable!("init-agents is handled before dispatch"),
Command::Triage { .. } => unreachable!("triage is handled before dispatch"),
Command::Version => unreachable!("version is handled before dispatch"),
Command::Beads { .. } => unreachable!("beads is handled before dispatch"),
Command::Mentions { action } => mentions::run(store, &mentions_action(action)),
Command::Analytics { command } => analytics::run(config, store, command),
Command::Health { action } => health::run(config, store, action),
Command::Roadmap { command } => roadmap::run_roadmap(config, store, command).map(|_| ()),
Command::Initiative { .. } => unreachable!("initiative is handled before dispatch"),
Command::Mcp => unreachable!("mcp is handled before dispatch"),
Command::New => unreachable!("new is handled before dispatch"),
Command::Create {
title,
body,
status,
priority,
assignee,
labels,
files,
estimate,
cycles,
milestones,
projects,
epics,
reviewer,
start_date,
due_date,
parent,
related_issues,
r#type,
template,
scope,
} => {
if let Some(template_id) = template {
if let Some(error) = projects_beside_template_refused(projects.as_deref()) {
return Err(error);
}
if let Some(error) = epics_beside_template_refused(epics.as_deref()) {
return Err(error);
}
templates::create_from_template(
config,
store,
template_id,
scope.as_deref(),
&templates::CreateFields {
title: title.as_deref(),
body: body.as_deref(),
status: status.as_deref(),
priority: priority.as_deref(),
assignee: assignee.as_deref(),
labels: labels.as_deref(),
files: files.as_deref(),
estimate: estimate.as_deref(),
cycles: cycles.as_deref(),
milestones: milestones.as_deref(),
reviewer: reviewer.as_deref(),
start_date: start_date.as_deref(),
due_date: due_date.as_deref(),
parent: parent.as_deref(),
related_issues: related_issues.as_deref(),
type_: r#type.as_deref(),
},
)
} else {
create::run(
config,
store,
title.as_deref(),
body.as_deref(),
status.as_deref(),
priority.as_deref(),
assignee.as_deref(),
labels.as_deref(),
files.as_deref(),
estimate.as_deref(),
cycles.as_deref(),
milestones.as_deref(),
projects.as_deref(),
epics.as_deref(),
reviewer.as_deref(),
start_date.as_deref(),
due_date.as_deref(),
parent.as_deref(),
related_issues.as_deref(),
r#type.as_deref(),
)
}
}
Command::Parent { action } => parent::run(store, action),
Command::Link {
action,
from,
to,
r#type,
} => link::run(
store,
action.as_ref(),
from.as_deref(),
to.as_deref(),
r#type.as_deref(),
),
Command::Unlink { from, to, r#type } => {
link::unlink_cli(store, from, to, r#type.as_deref())
}
Command::Chain { issue } => link::chain(store, issue),
Command::Bulk { .. } => Err(TixError::InvalidInput(
"`tix bulk` is routed by the process entry, not by dispatch — a partial \
batch reports a non-zero status rather than an error"
.to_string(),
)),
Command::Edit {
issue,
title,
body,
status,
priority,
assignee,
labels,
files,
estimate,
cycles,
milestones,
add_project,
remove_project,
projects,
add_epic,
remove_epic,
epics,
reviewer,
parent,
start_date,
due_date,
commit,
reason,
related_issues,
r#type,
} => edit::run(
config,
store,
issue,
title.as_deref(),
body.as_deref(),
status.as_deref(),
priority.as_deref(),
assignee.as_deref(),
labels.as_deref(),
files.as_deref(),
estimate.as_deref(),
cycles.as_deref(),
milestones.as_deref(),
add_project,
remove_project,
projects.as_deref(),
add_epic,
remove_epic,
epics.as_deref(),
reviewer.as_deref(),
start_date.as_deref(),
due_date.as_deref(),
commit.as_deref(),
parent.as_deref(),
reason.as_deref(),
related_issues.as_deref(),
r#type.as_deref(),
),
Command::Move {
args,
select,
filter,
search,
dry_run,
commit,
reason,
yes,
r#override,
} => mv::run_batch(
config,
store,
args,
select.as_deref(),
filter.as_deref(),
search.as_deref(),
commit.as_deref(),
reason.as_deref(),
*yes,
*r#override,
*dry_run,
)
.map(|_| ()),
Command::Project { .. } => Err(TixError::InvalidInput(
"`tix project` is routed by the process entry, not by dispatch".to_string(),
)),
Command::Epic { .. } => Err(TixError::InvalidInput(
"`tix epic` is routed by the process entry, not by dispatch".to_string(),
)),
Command::Wip { .. } => Err(TixError::InvalidInput(
"`tix wip` is routed by the process entry, not by dispatch".to_string(),
)),
Command::View {
action,
issue,
show,
} => match (action, issue) {
(Some(_), _) => Err(TixError::InvalidInput(
"`tix view <subcommand>` is routed by the process entry, not by dispatch"
.to_string(),
)),
(None, Some(issue)) => view::run(store, issue, show.as_deref()),
(None, None) => Err(TixError::InvalidInput(
"`tix view` needs an issue selector or a subcommand; run `tix view --help`"
.to_string(),
)),
},
Command::List {
status,
filter,
project,
epic,
sort,
group,
columns,
show,
with_archived,
all,
format,
} => list::run(
store,
status.as_deref(),
filter.as_deref(),
project.as_deref(),
epic.as_deref(),
sort.as_deref(),
group.as_deref(),
columns.as_deref(),
show.as_deref(),
*with_archived,
*all,
format.as_deref(),
None,
),
Command::Ready {
columns,
limit,
project,
epic,
format,
} => list::ready(
store,
columns.as_deref(),
*limit,
project.as_deref(),
epic.as_deref(),
format.as_deref(),
),
Command::Workflow { action } => match action {
crate::cli::WorkflowAction::Categories => workflow::categories(config),
crate::cli::WorkflowAction::Show { board } => workflow::show(config, board.as_deref()),
crate::cli::WorkflowAction::Transitions => workflow::transitions(config),
},
Command::Status {
filter,
sort,
group,
health,
json,
} => status::run(
config,
store,
filter.as_deref(),
sort.as_deref(),
group.as_deref(),
*health,
*json,
),
Command::Time { command } => time::run(store, command),
Command::Delete { issue, yes } => delete::run(store, issue, *yes),
Command::Recurring { action } => recurring::run(config, store, action),
Command::Archive { .. } | Command::Restore { .. } => Err(TixError::InvalidInput(
"`tix archive`/`tix restore` are routed by the process entry, not by dispatch — \
a partial batch reports a non-zero status rather than an error"
.to_string(),
)),
Command::Template { .. } => Err(TixError::InvalidInput(
"`tix template` is routed by the process entry, not by dispatch — management \
writes `tix.toml` through a mutable config"
.to_string(),
)),
Command::Cycle { command } => cycle::run_legacy(config, store, command),
Command::Capacity { .. } => unreachable!("capacity is handled before dispatch"),
Command::Milestone { command } => milestone::run(config, store, command),
Command::Export {
target,
dry_run,
project,
cycle,
output,
verbose,
} => export::run(
config,
store,
target,
*dry_run,
project.as_deref(),
cycle.as_deref(),
output.as_deref(),
*verbose,
)
.map(|_| ()),
Command::Import {
target,
triage_policy,
} => {
if target != "speckit" && target != "beads" {
crate::sync::Tool::from_target(target)?;
}
let policy = triage_policy
.as_deref()
.map(crate::triage::import_policy::ImportPolicy::parse)
.transpose()?
.unwrap_or_default();
import::run_with_policy(config, store, target, policy)
}
Command::Rollover { cycle, yes } => rollover::run(config, store, cycle.as_deref(), *yes),
Command::Search {
string,
with_archived,
} => search::run(store, string, *with_archived),
Command::Render => render::run(config, store),
Command::Clean { force, keep } => clean::run(store, *force, *keep),
Command::Lint { no_foreign } => lint::run_with(config, store, !no_foreign).map(|_| ()),
Command::Layout { action } => layout::run(config, store, action),
Command::Migrate { action } => migrate::run(store, action),
}
}
pub fn blocked_note(state: &crate::relationships::BlockedState) -> Option<String> {
if !state.is_blocked {
return None;
}
Some(format!("blocked: {}", state.reason))
}
pub fn graph_over<'a>(
issues: &'a [crate::model::Issue],
done_column: &str,
) -> crate::relationships::RelationshipGraph<'a> {
use crate::model::Issue;
use crate::relationships::IssueView;
let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
crate::relationships::RelationshipGraph::new(&views, done_column)
}
pub fn parse_selector(store: &Store, raw: &str) -> Result<IssueSelector> {
if let Ok(number) = raw
.trim()
.strip_prefix('#')
.unwrap_or(raw.trim())
.parse::<u32>()
{
let matched: Vec<crate::model::Issue> = store
.list_all()?
.into_iter()
.filter(|issue| issue.number == number)
.collect();
return match matched.as_slice() {
[_] => Ok(IssueSelector::Number(number)),
[] => Err(TixError::NotFound(format!("issue #{number}"))),
many => Err(TixError::InvalidInput(format!(
"{AMBIGUITY_PREFIX} #{number}: {}",
many.iter()
.map(|issue| crate::id::canonical_ref(issue).to_string())
.collect::<Vec<_>>()
.join(", ")
))),
};
}
if crate::id::is_issue_id(raw) {
let issues = store.list_all()?;
let matched: Vec<&crate::model::Issue> = issues
.iter()
.filter(|i| {
i.id.as_deref()
.is_some_and(|id| id.eq_ignore_ascii_case(raw))
})
.collect();
return match matched.as_slice() {
[one] => Ok(IssueSelector::Filename(one.filename.clone())),
[] => Err(TixError::NotFound(format!("issue with id '{raw}'"))),
_ => Err(TixError::InvalidInput(format!(
"{AMBIGUITY_PREFIX} id '{raw}': {}",
matched
.iter()
.map(|i| format!("#{} ({})", i.number, i.filename))
.collect::<Vec<_>>()
.join(", ")
))),
};
}
let issues = store.list_all()?;
let by_filename: Vec<&String> = issues
.iter()
.filter(|i| i.filename.contains(raw))
.map(|i| &i.filename)
.collect();
if by_filename.len() == 1 {
return Ok(IssueSelector::Filename(by_filename[0].clone()));
}
let by_title: Vec<&crate::model::Issue> =
issues.iter().filter(|i| i.title.contains(raw)).collect();
if by_title.len() == 1 {
return Ok(IssueSelector::Filename(by_title[0].filename.clone()));
}
if by_filename.len() > 1 || by_title.len() > 1 {
let mut seen: HashSet<u32> = HashSet::new();
let candidates: Vec<String> = issues
.iter()
.filter(|i| i.filename.contains(raw) || i.title.contains(raw))
.filter(|i| seen.insert(i.number))
.map(|i| format!("#{} ({})", i.number, i.filename))
.collect();
return Err(TixError::InvalidInput(format!(
"{AMBIGUITY_PREFIX} '{raw}': {}",
candidates.join(", ")
)));
}
Err(TixError::NotFound(format!(
"issue matching '{raw}' by number, filename or title"
)))
}
pub fn parse_priority(raw: &str) -> Result<Priority> {
match raw.to_ascii_lowercase().as_str() {
"low" => Ok(Priority::Low),
"medium" => Ok(Priority::Medium),
"high" => Ok(Priority::High),
"" => Ok(Priority::Other(String::new())),
_ => Err(TixError::InvalidInput(format!(
"invalid priority '{raw}' — use '' (unset), low, medium or high"
))),
}
}
pub fn parse_csv_list(raw: &str) -> Vec<String> {
raw.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub fn resolve_auto_commit(store: &Store, issue: &crate::model::Issue) -> Result<String> {
let cwd = std::env::current_dir().unwrap_or_default();
let path = store.issues_dir.join(&issue.filename);
crate::git::resolve_closing_commit(&cwd, &path).ok_or_else(|| {
TixError::InvalidInput(
"could not resolve a commit hash for `auto` — not a git repository or the issue file is uncommitted"
.to_string(),
)
})
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use std::fs;
use tempfile::TempDir;
fn make_store() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = crate::config::Config {
issues_dir: dir.clone(),
..crate::config::Config::default()
};
config.save(&config_path).unwrap();
let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn write_issue(store: &Store, name: &str, number: u32, title: &str) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody"
);
fs::write(store.issues_dir.join(name), contents).unwrap();
}
fn write_issue_with_id(store: &Store, name: &str, number: u32, title: &str, id: &str) {
let contents = format!(
"---\nnumber: {number}\nid: {id}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nbody"
);
fs::write(store.issues_dir.join(name), contents).unwrap();
}
#[test]
fn parse_priority_accepts_an_unset_value_and_still_rejects_typos() {
let undefined = parse_priority("").unwrap();
assert!(undefined.is_undefined());
assert_eq!(undefined.to_string(), "");
assert_eq!(parse_priority("medium").unwrap(), Priority::default());
for typo in ["hih", "urgent", "0", "lowest"] {
assert!(
parse_priority(typo).is_err(),
"{typo:?} must still be rejected"
);
}
}
#[test]
fn selector_bare_number_wins() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(&store, "2026-01-02-ticket-55.md", 55, "Ticket 55");
assert_eq!(
parse_selector(&store, "55").unwrap(),
IssueSelector::Number(55)
);
}
#[test]
fn selector_unique_filename_fragment() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(&store, "2026-05-10-add-docs.md", 1, "Add documentation");
assert_eq!(
parse_selector(&store, "add-doc").unwrap(),
IssueSelector::Filename("2026-05-10-add-docs.md".into())
);
}
#[test]
fn selector_unique_title_fragment() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(&store, "2026-01-01-fix-login.md", 1, "Fix login bug");
assert_eq!(
parse_selector(&store, "login").unwrap(),
IssueSelector::Filename("2026-01-01-fix-login.md".into())
);
}
#[test]
fn selector_unique_filename_fragment_matches_grouped_relative_path() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-05-10-add-docs.md",
1,
"Add documentation",
);
assert_eq!(
parse_selector(&store, "add-doc").unwrap(),
IssueSelector::Filename("alice/2026-05-10-add-docs.md".into())
);
}
#[test]
fn selector_full_grouped_relative_path_resolves() {
let (tmp, store) = make_store();
let _ = tmp;
fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
write_issue(
&store,
"alice/2026-05-10-add-docs.md",
1,
"Add documentation",
);
assert_eq!(
parse_selector(&store, "alice/2026-05-10-add-docs.md").unwrap(),
IssueSelector::Filename("alice/2026-05-10-add-docs.md".into())
);
}
#[test]
fn selector_ambiguous_lists_candidates() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(&store, "2026-01-01-a.md", 1, "Fix bug A");
write_issue(&store, "2026-01-02-b.md", 2, "Fix bug B");
let err = parse_selector(&store, "bug").unwrap_err();
assert!(
matches!(&err, TixError::InvalidInput(m) if m.starts_with(AMBIGUITY_PREFIX)),
"got {err:?}"
);
assert!(err.to_string().contains("#1"));
assert!(err.to_string().contains("#2"));
}
#[test]
fn selector_missing_is_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(&store, "2026-01-01-a.md", 1, "Fix bug A");
assert!(matches!(
parse_selector(&store, "zzz-nonexistent").unwrap_err(),
TixError::NotFound(_)
));
}
#[test]
fn selector_resolves_an_issue_id_case_insensitively() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue_with_id(
&store,
"2026-01-01-A1B2C3D4-fix-login.md",
1,
"Fix login",
"A1B2C3D4",
);
for raw in ["A1B2C3D4", "a1b2c3d4", "a1B2c3D4"] {
assert_eq!(
parse_selector(&store, raw).unwrap(),
IssueSelector::Filename("2026-01-01-A1B2C3D4-fix-login.md".into()),
"{raw} must resolve the id"
);
}
}
#[test]
fn selector_unknown_wellformed_id_is_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue_with_id(
&store,
"2026-01-01-A1B2C3D4-fix-login.md",
1,
"Fix login",
"A1B2C3D4",
);
assert!(matches!(
parse_selector(&store, "ZZZZZZZZ").unwrap_err(),
TixError::NotFound(_)
));
}
#[test]
fn an_all_digits_selector_stays_a_number_not_an_id() {
let (tmp, store) = make_store();
let _ = tmp;
write_issue(&store, "2026-01-01-a.md", 12_345_678, "Big number");
assert_eq!(
parse_selector(&store, "12345678").unwrap(),
IssueSelector::Number(12_345_678)
);
}
#[test]
fn priority_accepts_the_three_named_values_and_an_explicit_unset() {
assert_eq!(parse_priority("low").unwrap(), Priority::Low);
assert_eq!(parse_priority("HIGH").unwrap(), Priority::High);
assert_eq!(parse_priority("medium").unwrap(), Priority::Medium);
assert!(parse_priority("urgent").is_err());
assert!(parse_priority("").unwrap().is_undefined());
}
}