use std::io::IsTerminal;
use crate::commands::parse_selector;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::model::{Issue, Status};
use crate::storage::Store;
pub fn run(
config: &Config,
store: &Store,
raw: &str,
column: &str,
commit: Option<&str>,
) -> Result<()> {
let board = config.default_config();
if !board.columns.iter().any(|c| c == column) {
return Err(TixError::InvalidInput(format!(
"unknown column '{column}' — valid columns: {}",
board.columns.join(", ")
)));
}
let commit = if board.effective_done_column() == Some(column) && commit.is_none() {
if std::io::stdin().is_terminal() {
eprint!("Commit hash (or leave empty): ");
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(TixError::Io)?;
let trimmed = line.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
} else {
return Err(TixError::InvalidInput(format!(
"moving to '{column}' requires a commit hash — pass --commit <hash>"
)));
}
} else {
commit.map(str::to_string)
};
let issue = apply(config, store, raw, column, commit.as_deref())?;
println!("Moved {} to {column}", issue.filename);
Ok(())
}
pub fn apply(
config: &Config,
store: &Store,
raw: &str,
column: &str,
commit: Option<&str>,
) -> Result<Issue> {
let board = config.default_config();
if !board.columns.iter().any(|c| c == column) {
return Err(TixError::InvalidInput(format!(
"unknown column '{column}' — valid columns: {}",
board.columns.join(", ")
)));
}
let selector = parse_selector(store, raw)?;
let mut issue = store.get(selector)?;
let done = board.effective_done_column();
let was_done = done.is_some_and(|d| issue.status.is(d));
issue.status = Status::new(column.to_string());
if Some(column) == done {
issue.commit = commit.map(str::to_string);
} else if was_done {
issue.commit = None;
}
issue.bump_updated();
store.update(&issue.filename, &issue)?;
Ok(issue)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::create::create;
use crate::model::Issue;
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 seed(store: &Store) -> Issue {
create(
&store.config,
store,
"Move me",
Some("body"),
None,
None,
Some(""),
None,
None,
)
.unwrap()
}
#[test]
fn apply_into_done_records_commit() {
let (tmp, store) = make_store();
let _ = tmp;
let seeded = seed(&store);
let moved = apply(
&store.config,
&store,
&seeded.number.to_string(),
"done",
Some("abc123"),
)
.unwrap();
assert_eq!(moved.status.display(), "done");
assert_eq!(moved.commit.as_deref(), Some("abc123"));
assert_eq!(moved.number, seeded.number);
let reloaded = store
.get(crate::storage::IssueSelector::Filename(
moved.filename.clone(),
))
.unwrap();
assert_eq!(reloaded.commit.as_deref(), Some("abc123"));
}
#[test]
fn apply_out_of_done_clears_commit() {
let (tmp, store) = make_store();
let _ = tmp;
let seeded = seed(&store);
apply(
&store.config,
&store,
&seeded.number.to_string(),
"done",
Some("abc123"),
)
.unwrap();
let moved_out = apply(
&store.config,
&store,
&seeded.number.to_string(),
"in progress",
None,
)
.unwrap();
assert_eq!(moved_out.status.display(), "in progress");
assert_eq!(
moved_out.commit, None,
"moving out of done clears the commit"
);
}
#[test]
fn apply_into_done_without_commit_leaves_none() {
let (tmp, store) = make_store();
let _ = tmp;
let seeded = seed(&store);
let moved = apply(
&store.config,
&store,
&seeded.number.to_string(),
"done",
None,
)
.unwrap();
assert_eq!(moved.status.display(), "done");
assert_eq!(moved.commit, None);
}
#[test]
fn apply_rejects_unknown_column() {
let (tmp, store) = make_store();
let _ = tmp;
let seeded = seed(&store);
let err = apply(
&store.config,
&store,
&seeded.number.to_string(),
"bogus",
None,
)
.unwrap_err();
assert_eq!(
err.to_string(),
"invalid input: unknown column 'bogus' — valid columns: todo, in progress, in review, done"
);
}
#[test]
fn apply_invalid_selector_is_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
let _ = seed(&store);
let err = apply(&store.config, &store, "999", "todo", None).unwrap_err();
assert!(matches!(err, TixError::NotFound(_)));
}
}