use std::collections::HashSet;
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 archive;
pub mod bulk;
pub mod create;
pub mod cycle;
pub mod delete;
pub mod edit;
pub mod export;
pub mod import;
pub mod init;
pub mod link;
pub mod lint;
pub mod list;
pub mod milestone;
pub mod mv;
pub mod parent;
pub mod render;
pub mod rollover;
pub mod search;
pub mod status;
pub mod triage;
pub mod view;
pub mod workflow;
pub const AMBIGUITY_PREFIX: &str = "ambiguous selector";
#[allow(clippy::too_many_arguments)]
pub fn dispatch(cmd: &Command, config: &Config, store: &Store) -> Result<()> {
match cmd {
Command::Init => unreachable!("init is handled before dispatch"),
Command::InitAgents => unreachable!("init-agents is handled before dispatch"),
Command::Triage { .. } => unreachable!("triage 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,
reviewer,
start_date,
due_date,
parent,
} => 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(),
reviewer.as_deref(),
start_date.as_deref(),
due_date.as_deref(),
parent.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,
reviewer,
parent,
start_date,
due_date,
commit,
reason,
} => 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(),
reviewer.as_deref(),
start_date.as_deref(),
due_date.as_deref(),
commit.as_deref(),
parent.as_deref(),
reason.as_deref(),
),
Command::Move {
issue,
column,
commit,
reason,
yes,
} => mv::run(
config,
store,
issue,
column,
commit.as_deref(),
reason.as_deref(),
*yes,
),
Command::View { issue, show } => view::run(store, issue, show.as_deref()),
Command::List {
status,
filter,
sort,
group,
columns,
show,
with_archived,
} => list::run(
store,
status.as_deref(),
filter.as_deref(),
sort.as_deref(),
group.as_deref(),
columns.as_deref(),
show.as_deref(),
*with_archived,
),
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,
} => status::run(
config,
store,
filter.as_deref(),
sort.as_deref(),
group.as_deref(),
),
Command::Delete { issue, yes } => delete::run(store, issue, *yes),
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::Cycle { command } => cycle::run(config, store, command),
Command::Milestone { command } => milestone::run(config, store, command),
Command::Export {
target,
dry_run,
project,
cycle,
output,
} => export::run(
config,
store,
target,
*dry_run,
project.as_deref(),
cycle.as_deref(),
output.as_deref(),
),
Command::Import {
target,
triage_policy,
} => {
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::Lint => lint::run(config, store).map(|_| ()),
}
}
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.parse::<u32>() {
return Ok(IssueSelector::Number(number));
}
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 {
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();
}
#[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 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());
}
}