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 create;
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 mv;
pub mod search;
pub mod status;
pub mod view;
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::Mcp => unreachable!("mcp is handled before dispatch"),
Command::Create {
title,
body,
status,
priority,
assignee,
labels,
links,
files,
estimate,
} => create::run(
config,
store,
title.as_deref(),
body.as_deref(),
status.as_deref(),
priority.as_deref(),
assignee.as_deref(),
labels.as_deref(),
links.as_deref(),
files.as_deref(),
estimate.as_deref(),
),
Command::Link { a, b } => link::run(store, a, b),
Command::Unlink { a, b } => link::unlink_cli(store, a, b),
Command::Edit {
issue,
title,
body,
status,
priority,
assignee,
labels,
links,
files,
estimate,
commit,
} => edit::run(
config,
store,
issue,
title.as_deref(),
body.as_deref(),
status.as_deref(),
priority.as_deref(),
assignee.as_deref(),
labels.as_deref(),
links.as_deref(),
files.as_deref(),
estimate.as_deref(),
commit.as_deref(),
),
Command::Move {
issue,
column,
commit,
} => mv::run(config, store, issue, column, commit.as_deref()),
Command::View { issue } => view::run(store, issue),
Command::List { status } => list::run(store, status.as_deref()),
Command::Status => status::run(config, store),
Command::Delete { issue, yes } => delete::run(store, issue, *yes),
Command::Export {
target,
dry_run,
project,
} => export::run(config, store, target, *dry_run, project.as_deref()),
Command::Import { target } => import::run(config, store, target),
Command::Search { string } => search::run(store, string),
Command::Lint => lint::run(config, store).map(|_| ()),
}
}
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()?;
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),
_ => Err(TixError::InvalidInput(format!(
"invalid priority '{raw}' — use 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 parse_link_numbers(raw: &str) -> Result<Vec<u32>> {
let mut out = Vec::new();
for entry in parse_csv_list(raw) {
match entry.parse::<u32>() {
Ok(n) => out.push(n),
Err(_) => {
return Err(TixError::InvalidInput(format!(
"invalid link '{entry}' — links must be issue numbers"
)));
}
}
}
Ok(out)
}
#[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 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_only_three_values() {
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("").is_err());
}
}