use std::collections::HashSet;
use std::io::IsTerminal;
use crate::assignee::{AuthorIdentity, git_identity, resolve_me};
use crate::commands::{parse_csv_list, parse_priority};
use crate::config::Config;
use crate::editor::read_tty_line;
use crate::error::{Result, TixError};
use crate::hierarchy;
use crate::model::{HistoryEventKind, Issue, Priority, Status, today};
use crate::planning::Planner;
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>,
files: Option<&str>,
estimate: Option<&str>,
cycles: Option<&str>,
milestones: Option<&str>,
projects: Option<&str>,
epics: Option<&str>,
reviewer: Option<&str>,
start_date: Option<&str>,
due_date: Option<&str>,
parent: Option<&str>,
related_issues: Option<&str>,
type_: 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 project_ids = match projects {
None => Vec::new(),
Some(raw) => crate::commands::project::resolve_projects(config, raw)?,
};
let epic_ids = match epics {
None => Vec::new(),
Some(raw) => crate::commands::epic::resolve_epics(config, raw)?,
};
let issue = create(
config,
store,
&title,
body,
status,
priority,
assignee,
labels,
files,
estimate,
cycles,
milestones,
reviewer,
start_date,
due_date,
parent,
related_issues,
type_,
)?;
crate::commands::project::associate_created(store, &issue, &project_ids)?;
crate::commands::epic::reference_created(store, &issue, &epic_ids)?;
println!("Created {}", issue.filename);
Ok(())
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_lines)]
pub fn create(
config: &Config,
store: &Store,
title: &str,
body: Option<&str>,
status: Option<Status>,
priority: Option<Priority>,
assignee: Option<&str>,
labels: Option<&str>,
files: Option<&str>,
estimate: Option<&str>,
cycles: Option<&str>,
milestones: Option<&str>,
reviewer: Option<&str>,
start_date: Option<&str>,
due_date: Option<&str>,
parent: Option<&str>,
related_issues: Option<&str>,
type_: 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) => {
let (_, resolution) = crate::workflow::active_resolution(config);
if resolution.resolve(s.0.trim()).is_none() {
return Err(TixError::Workflow(crate::workflow::WorkflowDiagnostic {
kind: crate::workflow::DiagnosticKind::UnknownColumn,
subject: format!("create -> {}", s.0.trim()),
message: format!(
"`{}` is not a column on board `{}`; valid columns: {}",
s.0.trim(),
resolution.board,
resolution.column_list()
),
}));
}
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 files: Vec<String> = match files {
None => vec![],
Some(f) => crate::commands::parse_csv_list(f),
};
let related_issues: Vec<String> = match related_issues {
None => vec![],
Some(r) => crate::commands::parse_csv_list(r),
};
let type_ = type_
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from);
let cycles = parse_planning(cycles).or_else(|| Some(vec![default_cycle_name(config)]));
let milestones = parse_planning(milestones);
let reviewer = reviewer
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let start = parse_date(start_date);
let due = parse_date(due_date);
validate_planning_inputs(config, cycles.as_deref(), milestones.as_deref())?;
let (parent_ref, parent_number) = match parent.map(str::trim).filter(|p| !p.is_empty()) {
None => (None, None),
Some(raw) => {
let target = store.resolve_issue(raw)?;
let (index, _) = store.hierarchy_index()?;
hierarchy::validate_new_parent(&index, target.number)?;
(Some(crate::id::canonical_ref(&target)), Some(target.number))
}
};
let mut issue = Issue {
projects: None,
id: None,
number: 0,
type_,
title,
status,
labels,
priority,
assignee,
estimate: estimate.map(String::from),
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues,
files,
cycles,
milestones,
reviewer,
start_date: None,
due_date: None,
created: today(),
updated: today(),
filename: String::new(),
body: String::new(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: parent_ref,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
issue.set_dates(start, due)?;
if let Some(number) = parent_number {
issue.history.append_now(
&crate::storage::actor_identity(),
HistoryEventKind::ParentSet,
&hierarchy::change_detail(None::<u32>, Some(number)),
);
}
crate::triage::record_entry_if_inbox(config, &mut issue, "new")?;
store.create(&mut issue, body.unwrap_or(""), None)?;
Ok(issue)
}
pub fn default_cycle_name(config: &Config) -> String {
let planner = planner_for(config);
if let Some(running) = planner.running_cycle(&crate::model::today()) {
return running;
}
config
.planning
.default_cycle
.clone()
.filter(|name| planner.cycle(name).is_some())
.unwrap_or_else(|| crate::cycle_store::DEFAULT_CYCLE_NAME.to_string())
}
pub(crate) fn parse_planning(raw: Option<&str>) -> Option<Vec<String>> {
let items: Vec<String> = parse_csv_list(raw.unwrap_or(""))
.into_iter()
.fold((Vec::new(), HashSet::new()), |(mut out, mut seen), item| {
if seen.insert(item.clone()) {
out.push(item);
}
(out, seen)
})
.0;
if items.is_empty() { None } else { Some(items) }
}
pub(crate) fn parse_date(raw: Option<&str>) -> Option<String> {
let trimmed = raw.map(str::trim).unwrap_or("");
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub(crate) fn planner_for(config: &Config) -> Planner {
let root = if config.milestones_dir().is_absolute() {
std::path::PathBuf::new()
} else {
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
};
Planner::from_config(config.clone(), root)
}
pub fn validate_planning_inputs(
config: &Config,
cycles: Option<&[String]>,
milestones: Option<&[String]>,
) -> Result<()> {
let planner = planner_for(config);
if let Some(cycles) = cycles {
for name in cycles {
if planner.cycle(name).is_none() {
let known = planner.cycle_names();
let list = if known.is_empty() {
"none configured".to_string()
} else {
known.join(", ")
};
return Err(TixError::InvalidInput(format!(
"unknown cycle `{name}` — valid cycles: {list}"
)));
}
}
}
if let Some(milestones) = milestones {
for name in milestones {
if planner.milestone(name).is_err() {
let known = planner.milestone_names().unwrap_or_default();
let list = if known.is_empty() {
"none configured".to_string()
} else {
known.join(", ")
};
return Err(TixError::InvalidInput(format!(
"unknown milestone `{name}` — valid milestones: {list}"
)));
}
}
}
Ok(())
}
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 {
#[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(),
milestones_dir: Some(tmp.path().join("docs/milestones")),
..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_milestone(store: &Store, name: &str) {
crate::planning::Planner::from_config(store.config.clone(), std::path::PathBuf::new())
.create_milestone_file(name, None, None)
.unwrap();
}
#[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 issue = create(
&store.config,
&store,
" Hello World ",
None,
None,
None,
Some(""),
None,
None,
None,
None,
None,
None,
None,
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_eq!(issue.labels, [] as [std::string::String; 0]);
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, mut store) = make_store();
store
.config
.cycles
.insert("Cycle 1".into(), crate::config::PlanningCycle::default());
store
.config
.cycles
.insert("Sprint 9".into(), crate::config::PlanningCycle::default());
seed_milestone(&store, "v1.0");
let issue = create(
&store.config,
&store,
"Explicit",
Some("the body"),
Some(Status::new("in progress")),
Some(Priority::High),
Some("@alice"),
Some("bug, docs, "),
Some("specs/a.md, specs/b.md"),
Some("3d"),
Some("Cycle 1, Sprint 9"),
Some("v1.0"),
Some("Bob <bob@example.com>"),
Some("2026-09-07"),
Some("2026-09-18"),
None,
None,
None,
)
.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()]
);
assert_eq!(
issue.cycles,
Some(vec!["Cycle 1".to_string(), "Sprint 9".to_string()]),
"cycle CSV parsed into a deduplicated list"
);
assert_eq!(issue.milestones, Some(vec!["v1.0".to_string()]));
assert_eq!(issue.reviewer.as_deref(), Some("Bob <bob@example.com>"));
assert_eq!(issue.start_date.as_deref(), Some("2026-09-07"));
assert_eq!(issue.due_date.as_deref(), Some("2026-09-18"));
}
#[test]
fn create_rejects_blank_title_and_blank_status() {
let (_tmp, store) = make_store();
assert!(
create(
&store.config,
&store,
" ",
None,
None,
None,
None,
None,
None,
None,
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,
None,
None,
None,
None,
None,
None,
None,
)
.is_err(),
"a caller-supplied blank status is rejected like --status ''"
);
}
#[test]
fn parse_planning_dedupes_and_normalizes() {
assert_eq!(
parse_planning(Some("Cycle 1, Sprint 9, Cycle 1")),
Some(vec!["Cycle 1".to_string(), "Sprint 9".to_string()]),
"first-seen order preserved, duplicates collapsed"
);
assert_eq!(parse_planning(Some("")), None);
assert_eq!(parse_planning(Some(", , ,")), None);
assert_eq!(parse_planning(None), None);
assert_eq!(
parse_date(Some("2026-09-07")),
Some("2026-09-07".to_string())
);
assert_eq!(
parse_date(Some(" 2026-09-07 ")),
Some("2026-09-07".to_string())
);
assert_eq!(parse_date(Some("")), None);
assert_eq!(parse_date(None), None);
}
#[test]
fn create_rejects_unknown_cycle_with_valid_list() {
let (_tmp, mut store) = make_store();
store
.config
.cycles
.insert("Cycle 1".into(), crate::config::PlanningCycle::default());
let err = create(
&store.config,
&store,
"T",
None,
None,
None,
None,
None,
None,
None,
Some("Nope, Cycle 1"),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
assert!(
err.to_string().contains("unknown cycle `Nope`"),
"got {err}"
);
assert!(
err.to_string().contains("valid cycles: Cycle 1"),
"must name the valid set, got {err}"
);
}
#[test]
fn create_rejects_unknown_milestone_with_valid_list() {
let (_tmp, store) = make_store();
seed_milestone(&store, "v1.0");
let err = create(
&store.config,
&store,
"T",
None,
None,
None,
None,
None,
None,
None,
None,
Some("v1.0, nope-milestone"),
None,
None,
None,
None,
None,
None,
)
.unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
assert!(
err.to_string()
.contains("unknown milestone `nope-milestone`"),
"got {err}"
);
assert!(
err.to_string().contains("valid milestones: v1.0"),
"must name the valid set, got {err}"
);
assert!(
store.list().unwrap().is_empty(),
"a rejected create must not write an issue file"
);
}
#[test]
fn create_rejects_due_before_start() {
let (_tmp, store) = make_store();
let err = create(
&store.config,
&store,
"T",
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some("2026-09-18"),
Some("2026-09-07"),
None,
None,
None,
)
.unwrap_err();
assert_eq!(
err.to_string(),
"config error: due_date \"2026-09-07\" must be `>= start_date` \"2026-09-18\""
);
assert_eq!(store.list().unwrap(), [] as [crate::model::Issue; 0]);
}
#[test]
fn create_rejects_malformed_date() {
let (_tmp, store) = make_store();
let err = create(
&store.config,
&store,
"T",
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some("2026-13-99"),
None,
None,
None,
None,
)
.unwrap_err();
assert!(
err.to_string()
.contains("start_date \"2026-13-99\" must be a `YYYY-MM-DD` calendar date"),
"got {err}"
);
assert_eq!(store.list().unwrap(), [] as [crate::model::Issue; 0]);
}
#[test]
fn create_without_a_cycle_selection_joins_the_default_cycle() {
let (_tmp, store) = make_store();
let issue = create(
&store.config,
&store,
"Plain",
None,
None,
None,
Some(""),
None,
None,
None,
Some(""),
Some(""),
Some(""),
Some(""),
Some(""),
None,
None,
None,
)
.unwrap();
assert_eq!(issue.cycles, Some(vec!["Default".to_string()]));
assert_eq!(issue.milestones, None);
assert_eq!(issue.reviewer, None);
assert_eq!(issue.start_date, None);
assert_eq!(issue.due_date, None);
let reloaded = store
.get(crate::storage::IssueSelector::Number(issue.number))
.unwrap();
assert_eq!(reloaded.cycles, Some(vec!["Default".to_string()]));
}
#[test]
fn create_uses_the_configured_default_cycle_when_it_exists() {
let (_tmp, mut store) = make_store();
store
.config
.cycles
.insert("Sprint 1".into(), crate::config::PlanningCycle::default());
store.config.planning.default_cycle = Some("Sprint 1".into());
let issue = create(
&store.config,
&store,
"Configured",
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(issue.cycles, Some(vec!["Sprint 1".to_string()]));
}
fn cycle_with(start: Option<&str>, due: Option<&str>) -> crate::config::PlanningCycle {
crate::config::PlanningCycle {
description: None,
start_date: start.map(str::to_string),
due_date: due.map(str::to_string),
}
}
#[test]
fn create_without_a_selection_assigns_the_single_running_cycle() {
let (_tmp, mut store) = make_store();
store.config.cycles.insert(
"October 2026".into(),
cycle_with(Some("2020-01-01"), Some("2999-12-31")),
);
let issue = create(
&store.config,
&store,
"Runs",
None,
None,
None,
Some(""),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(
issue.cycles,
Some(vec!["October 2026".to_string()]),
"exactly one running cycle is assigned automatically"
);
}
#[test]
fn default_cycle_selection_falls_back_when_running_is_ambiguous() {
let mut config = crate::config::Config::default();
config.cycles.insert(
"Future".into(),
cycle_with(Some("2999-01-01"), Some("2999-12-31")),
);
assert_eq!(default_cycle_name(&config), "Default");
config.cycles.clear();
config
.cycles
.insert("Ongoing".into(), cycle_with(Some("2020-01-01"), None));
assert_eq!(default_cycle_name(&config), "Ongoing");
config.cycles.clear();
config.cycles.insert(
"A".into(),
cycle_with(Some("2020-01-01"), Some("2999-12-31")),
);
config
.cycles
.insert("B".into(), cycle_with(Some("2020-01-01"), None));
assert_eq!(default_cycle_name(&config), "Default");
config.planning.default_cycle = Some("B".into());
assert_eq!(default_cycle_name(&config), "B");
}
#[test]
fn create_planning_fields_round_trip_through_frontmatter() {
let (_tmp, mut store) = make_store();
store
.config
.cycles
.insert("Cycle 1".into(), crate::config::PlanningCycle::default());
seed_milestone(&store, "v1.0");
let issue = create(
&store.config,
&store,
"Planned",
None,
None,
None,
Some(""),
None,
None,
None,
Some("Cycle 1"),
Some("v1.0"),
Some("Alice"),
Some("2026-09-07"),
Some("2026-09-18"),
None,
None,
None,
)
.unwrap();
let reloaded = store
.get(crate::storage::IssueSelector::Number(issue.number))
.unwrap();
assert_eq!(reloaded.cycles, Some(vec!["Cycle 1".to_string()]));
assert_eq!(reloaded.milestones, Some(vec!["v1.0".to_string()]));
assert_eq!(reloaded.reviewer.as_deref(), Some("Alice"));
assert_eq!(reloaded.start_date.as_deref(), Some("2026-09-07"));
assert_eq!(reloaded.due_date.as_deref(), Some("2026-09-18"));
let raw = std::fs::read_to_string(store.issues_dir.join(&reloaded.filename)).unwrap();
assert!(raw.contains("cycles:\n- Cycle 1"), "{raw}");
assert!(raw.contains("milestones:\n- v1.0"), "{raw}");
assert!(raw.contains("reviewer: Alice"), "{raw}");
}
fn create_bare(store: &Store, title: &str) -> crate::error::Result<Issue> {
create(
&store.config,
store,
title,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
}
fn create_with_parent(store: &Store, title: &str, parent: &str) -> crate::error::Result<Issue> {
create(
&store.config,
store,
title,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(parent),
None,
None,
)
}
fn snapshot(store: &Store) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = fs::read_dir(&store.issues_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
.filter(|(_, p)| p.extension().is_some_and(|x| x == "md"))
.map(|(n, p)| (n, fs::read_to_string(p).unwrap()))
.collect();
out.sort();
out
}
#[test]
fn t010_create_with_a_valid_parent_stores_the_number_and_one_history_entry() {
let (_tmp, store) = make_store();
let parent = create_bare(&store, "Epic").unwrap();
let child = create_with_parent(&store, "Task", &parent.number.to_string()).unwrap();
assert_eq!(
child.parent,
Some(crate::id::canonical_ref(&parent)),
"the parent is stored as a resolved number, never the selector text"
);
assert_eq!(child.history.len(), 1, "C-004: exactly one entry");
let events = child.history.events();
assert_eq!(events[0].kind, HistoryEventKind::ParentSet);
assert!(
events[0].detail.contains(&format!("#{}", parent.number)),
"the entry must name the parent, got {:?}",
events[0].detail
);
assert!(
!events[0].actor.is_empty(),
"an entry with no actor is not an audit record"
);
let written = fs::read_to_string(store.issues_dir.join(&child.filename)).unwrap();
let reparsed = Issue::parse(&written).unwrap();
assert_eq!(reparsed.parent, Some(crate::id::canonical_ref(&parent)));
assert_eq!(reparsed.to_file_contents(), written);
}
#[test]
fn t010_create_with_a_title_fragment_parent_stores_the_number() {
let (_tmp, store) = make_store();
let parent = create_bare(&store, "Epic").unwrap();
let child = create_with_parent(&store, "Task", "Epic").unwrap();
assert_eq!(child.parent, Some(crate::id::canonical_ref(&parent)));
let written = fs::read_to_string(store.issues_dir.join(&child.filename)).unwrap();
assert!(
!written.contains("parent: Epic"),
"the selector text must not reach the file:\n{written}"
);
}
#[test]
fn t010_create_with_an_unknown_parent_creates_nothing() {
let (_tmp, store) = make_store();
let before = snapshot(&store);
let err = create_with_parent(&store, "Orphan", "999")
.expect_err("a parent that resolves to nothing must be refused");
assert!(
err.to_string().contains("999"),
"the refusal must name what was not found: {err}"
);
assert_eq!(snapshot(&store), before, "SC-004: no file was created");
}
#[test]
fn t010_the_unknown_parent_refusal_comes_from_the_resolver_not_the_service() {
let (_tmp, store) = make_store();
let err = create_with_parent(&store, "Orphan", "999").unwrap_err();
let msg = err.to_string();
assert!(
!msg.contains("unknown_parent"),
"the service is not consulted for a non-resolving selector: {msg}"
);
assert!(msg.contains("not found"), "{msg}");
}
#[test]
fn t010_a_refused_create_does_not_burn_an_issue_number() {
let (_tmp, store) = make_store();
assert!(create_with_parent(&store, "Orphan", "999").is_err());
let real = create_bare(&store, "Real").unwrap();
let another = create_bare(&store, "Another").unwrap();
assert!(
another.number > real.number,
"numbers must still advance monotonically: {} then {}",
real.number,
another.number
);
}
#[test]
fn t010_create_without_the_flag_leaves_the_field_absent() {
let (_tmp, store) = make_store();
let issue = create_bare(&store, "Plain").unwrap();
assert_eq!(issue.parent, None);
assert_eq!(issue.history.len(), 0, "no parent, no History entry");
let written = fs::read_to_string(store.issues_dir.join(&issue.filename)).unwrap();
assert!(
!written.contains("parent:"),
"a file that never had a parent must not grow the key:\n{written}"
);
}
#[test]
fn t010_the_parent_is_not_stored_on_the_parents_file() {
let (_tmp, store) = make_store();
let parent = create_bare(&store, "Epic").unwrap();
let parent_file = parent.filename.clone();
let before = fs::read_to_string(store.issues_dir.join(&parent_file)).unwrap();
create_with_parent(&store, "Task", &parent.number.to_string()).unwrap();
assert_eq!(
fs::read_to_string(store.issues_dir.join(&parent_file)).unwrap(),
before,
"the parent's file must be byte-identical after a child is created"
);
}
#[test]
fn t010_a_blank_parent_flag_is_the_same_as_omitting_it() {
let (_tmp, store) = make_store();
let issue = create_with_parent(&store, "Plain", " ").unwrap();
assert_eq!(issue.parent, None);
assert_eq!(issue.history.len(), 0);
}
}
#[cfg(test)]
mod project_tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::config::ProjectConfiguration;
use std::fs;
use tempfile::TempDir;
fn store_with_projects() -> (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 mut config = crate::config::Config {
issues_dir: dir.clone(),
milestones_dir: Some(tmp.path().join("docs/milestones")),
..crate::config::Config::default()
};
for (id, name) in [("p-1", "Platform"), ("p-2", "Infra")] {
config.projects.insert(
id.to_string(),
ProjectConfiguration {
name: name.to_string(),
..ProjectConfiguration::default()
},
);
}
config.save(&config_path).unwrap();
let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn create_with(store: &Store, title: &str, projects: Option<&str>) -> Result<()> {
run(
&store.config,
store,
Some(title),
None,
None,
None,
None,
None,
None,
None,
None,
None,
projects,
None,
None,
None,
None,
None,
None,
None,
)
}
#[test]
fn a_multi_value_projects_flag_associates_every_project() {
let (_tmp, store) = store_with_projects();
create_with(&store, "Fix login", Some("p-1,Infra")).unwrap();
let issue = store.resolve_issue("1").unwrap();
assert_eq!(
issue.projects,
Some(vec!["p-1".to_string(), "p-2".to_string()])
);
assert_eq!(
issue.history.events().len(),
2,
"one History entry per association"
);
}
#[test]
fn an_unknown_project_id_refuses_the_whole_create() {
let (_tmp, store) = store_with_projects();
let error = create_with(&store, "Fix login", Some("p-1,bogus")).unwrap_err();
assert!(error.to_string().contains("unknown_project"), "{error}");
assert!(
store.list_all().unwrap().is_empty(),
"a half-associated issue is worse than a failed command"
);
}
#[test]
fn a_duplicate_project_id_collapses_to_one_association() {
let (_tmp, store) = store_with_projects();
create_with(&store, "Fix login", Some("p-1,p-1,Platform")).unwrap();
let issue = store.resolve_issue("1").unwrap();
assert_eq!(issue.projects, Some(vec!["p-1".to_string()]));
assert_eq!(issue.history.events().len(), 1);
}
#[test]
fn a_create_without_the_flag_writes_no_association() {
let (_tmp, store) = store_with_projects();
create_with(&store, "Fix login", None).unwrap();
let issue = store.resolve_issue("1").unwrap();
assert_eq!(issue.projects, None);
assert_eq!(
issue.history.events(),
[] as [&crate::model::HistoryEvent; 0]
);
}
}