use std::io::IsTerminal;
use crate::assignee::{AuthorIdentity, git_identity, resolve_me};
use crate::commands::parse_priority;
use crate::config::Config;
use crate::editor::read_tty_line;
use crate::error::{Result, TixError};
use crate::model::{Issue, Priority, Status, today};
use crate::storage::Store;
#[allow(clippy::too_many_arguments)]
pub fn run(
config: &Config,
store: &Store,
title: Option<&str>,
body: Option<&str>,
status: Option<&str>,
priority: Option<&str>,
assignee: Option<&str>,
labels: Option<&str>,
links: Option<&str>,
files: Option<&str>,
estimate: Option<&str>,
) -> Result<()> {
let title = match title {
Some(t) => {
let trimmed = t.trim().to_string();
if trimmed.is_empty() {
return Err(TixError::InvalidInput("--title must not be empty".into()));
}
trimmed
}
None => {
if !std::io::stdin().is_terminal() {
return Err(TixError::InvalidInput(
"title is required (stdin is not a TTY); pass --title".into(),
));
}
let t = read_tty_line("Title: ")?;
if t.is_empty() {
return Err(TixError::InvalidInput("title must not be empty".into()));
}
t
}
};
let status = match status {
None => None,
Some(s) if s.trim().is_empty() => {
return Err(TixError::InvalidInput("--status must not be empty".into()));
}
Some(s) => Some(Status::new(s.to_string())),
};
let priority = match priority {
Some(p) => Some(parse_priority(p)?),
None => None,
};
let issue = create(
config, store, &title, body, status, priority, assignee, labels, links, files, estimate,
)?;
println!("Created {}", issue.filename);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn create(
config: &Config,
store: &Store,
title: &str,
body: Option<&str>,
status: Option<Status>,
priority: Option<Priority>,
assignee: Option<&str>,
labels: Option<&str>,
links: Option<&str>,
files: Option<&str>,
estimate: Option<&str>,
) -> Result<Issue> {
let title = title.trim().to_string();
if title.is_empty() {
return Err(TixError::InvalidInput("--title must not be empty".into()));
}
let status = match status {
None => Status::new(
config
.default_config()
.columns
.first()
.cloned()
.unwrap_or_else(|| "todo".to_string()),
),
Some(s) if s.0.trim().is_empty() => {
return Err(TixError::InvalidInput("--status must not be empty".into()));
}
Some(s) => s,
};
let priority = priority.unwrap_or_default();
let assignee = resolve_assignee(assignee, git_identity().as_ref());
let labels: Vec<String> = match labels {
None => vec![],
Some(l) => l
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
};
let links: Vec<u32> = match links {
None => vec![],
Some(l) => crate::commands::parse_link_numbers(l)?,
};
let files: Vec<String> = match files {
None => vec![],
Some(f) => crate::commands::parse_csv_list(f),
};
let mut issue = Issue {
number: 0,
title,
status,
labels,
priority,
assignee,
estimate: estimate.map(String::from),
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
links: links.iter().map(u32::to_string).collect(),
files,
created: today(),
updated: today(),
filename: String::new(),
body: String::new(),
};
store.create(&mut issue, body.unwrap_or(""), None)?;
if !links.is_empty() {
let linked = store.set_links(&issue.filename, links)?;
issue.links = linked.links;
}
Ok(issue)
}
fn resolve_assignee(explicit: Option<&str>, identity: Option<&AuthorIdentity>) -> Option<String> {
match explicit {
Some(value) => resolve_me(value, identity),
None => identity.map(|id| id.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,
},
)
}
#[test]
fn explicit_assignee_wins_over_identity() {
let identity = AuthorIdentity {
name: "Herb".into(),
email: Some("herb@example.com".into()),
};
assert_eq!(
resolve_assignee(Some("@alice"), Some(&identity)),
Some("alice".into())
);
assert_eq!(
resolve_assignee(Some(""), Some(&identity)),
None,
"an explicit empty assignee means unassigned"
);
}
#[test]
fn me_assignee_resolves_to_git_identity() {
let identity = AuthorIdentity {
name: "Herb".into(),
email: Some("herb@example.com".into()),
};
assert_eq!(
resolve_assignee(Some("@me"), Some(&identity)),
Some("Herb <herb@example.com>".into())
);
assert_eq!(
resolve_assignee(Some("me"), Some(&identity)),
Some("Herb <herb@example.com>".into())
);
}
#[test]
fn no_explicit_assignee_uses_full_identity_verbatim() {
let identity = AuthorIdentity {
name: "Herb".into(),
email: Some("herb@example.com".into()),
};
assert_eq!(
resolve_assignee(None, Some(&identity)),
Some("Herb <herb@example.com>".into())
);
let name_only = AuthorIdentity {
name: "Herb".into(),
email: None,
};
assert_eq!(
resolve_assignee(None, Some(&name_only)),
Some("Herb".into())
);
}
#[test]
fn no_explicit_and_no_identity_means_unassigned() {
assert_eq!(resolve_assignee(None, None), None);
}
#[test]
fn create_defaults_status_priority_labels_and_assignee() {
let (tmp, store) = make_store();
let _ = tmp;
let issue = create(
&store.config,
&store,
" Hello World ",
None,
None,
None,
Some(""),
None,
None,
None,
None,
)
.unwrap();
assert_eq!(issue.title, "Hello World", "title is trimmed");
assert_eq!(issue.status.display(), "todo", "default = first column");
assert_eq!(issue.priority, Priority::Medium, "default priority");
assert!(issue.labels.is_empty());
assert_eq!(issue.assignee, None, "empty assignee → unassigned");
assert_eq!(issue.estimate, None);
assert!(
issue.filename.starts_with("unassigned/"),
"no assignee segment"
);
assert!(issue.filename.ends_with(".md"));
let reloaded = store
.get(crate::storage::IssueSelector::Number(issue.number))
.unwrap();
assert_eq!(reloaded.title, "Hello World");
}
#[test]
fn create_passes_explicit_arguments_through() {
let (tmp, store) = make_store();
let _ = tmp;
let issue = create(
&store.config,
&store,
"Explicit",
Some("the body"),
Some(Status::new("in progress")),
Some(Priority::High),
Some("@alice"),
Some("bug, docs, "),
None,
Some("specs/a.md, specs/b.md"),
Some("3d"),
)
.unwrap();
assert_eq!(issue.title, "Explicit");
assert_eq!(issue.body, "the body");
assert_eq!(issue.status.display(), "in progress");
assert_eq!(issue.priority, Priority::High);
assert_eq!(issue.assignee.as_deref(), Some("alice"));
assert_eq!(issue.estimate.as_deref(), Some("3d"));
assert_eq!(
issue.labels,
vec!["bug".to_string(), "docs".to_string()],
"label list is split on commas and empty entries dropped"
);
assert_eq!(
issue.files,
vec!["specs/a.md".to_string(), "specs/b.md".to_string()]
);
}
#[test]
fn create_seeds_links_and_back_references() {
let (tmp, store) = make_store();
let _ = tmp;
let target_one = create(
&store.config,
&store,
"Target one",
None,
None,
None,
Some(""),
None,
None,
None,
None,
)
.expect("seed 1");
let target_two = create(
&store.config,
&store,
"Target two",
None,
None,
None,
Some(""),
None,
None,
None,
None,
)
.expect("seed 2");
let targets = format!(
"{},{},{}",
target_one.number, target_one.number, target_two.number
);
let issue = create(
&store.config,
&store,
"Linked",
Some("body"),
None,
None,
Some(""),
None,
Some(&targets),
Some("specs/a.md"),
None,
)
.unwrap();
assert_eq!(
issue.links,
[target_one.number.to_string(), target_two.number.to_string()],
"deduped link list"
);
assert_eq!(issue.files, ["specs/a.md"]);
let reread_one = store
.get(crate::storage::IssueSelector::Number(target_one.number))
.unwrap();
assert_eq!(reread_one.links, [issue.number.to_string()]);
let reread_two = store
.get(crate::storage::IssueSelector::Number(target_two.number))
.unwrap();
assert_eq!(reread_two.links, [issue.number.to_string()]);
}
#[test]
fn create_rejects_blank_title_and_blank_status() {
let (tmp, store) = make_store();
let _ = tmp;
assert!(
create(
&store.config,
&store,
" ",
None,
None,
None,
None,
None,
None,
None,
None
)
.is_err()
);
assert!(
create(
&store.config,
&store,
"T",
None,
Some(Status::new("")),
None,
None,
None,
None,
None,
None
)
.is_err(),
"a caller-supplied blank status is rejected like --status ''"
);
}
}