use std::io::IsTerminal;
use crate::cli::{ExitCode, ProjectCommand};
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::model::Issue;
use crate::projects::{self, NewProject, Patch, Project, ProjectEdit, ProjectError};
use crate::storage::{BulkAssociationOutcome, Store};
pub fn run(config: &mut Config, store: &Store, command: &ProjectCommand) -> Result<ExitCode> {
match command {
ProjectCommand::Create {
name,
description,
status,
lead,
start_date,
due_date,
} => create(
config,
store,
name,
description.as_deref(),
status.as_deref(),
lead.as_deref(),
start_date.as_deref(),
due_date.as_deref(),
),
ProjectCommand::Edit {
reference,
name,
description,
status,
lead,
start_date,
due_date,
} => edit(
config,
store,
reference,
name.as_deref(),
description.as_deref(),
status.as_deref(),
lead.as_deref(),
start_date.as_deref(),
due_date.as_deref(),
),
ProjectCommand::List => list(config, store),
ProjectCommand::Show { reference } => show(config, store, reference),
ProjectCommand::Delete { reference, yes } => delete(config, store, reference, *yes),
ProjectCommand::Add { reference, issues }
| ProjectCommand::BulkAdd { reference, issues } => {
associate(config, store, reference, issues)
}
ProjectCommand::Remove { reference, issues } => {
dissociate(config, store, reference, issues)
}
}
}
fn save(config: &mut Config, store: &Store) -> Result<()> {
config.validate()?;
let path = crate::commands::views::config_path(store);
config
.save(&path)
.map_err(|error| TixError::Config(format!("could not save `{}`: {error}", path.display())))
}
#[allow(clippy::too_many_arguments)]
fn create(
config: &mut Config,
store: &Store,
name: &str,
description: Option<&str>,
status: Option<&str>,
lead: Option<&str>,
start_date: Option<&str>,
due_date: Option<&str>,
) -> Result<ExitCode> {
let new = NewProject {
name: name.to_string(),
description: description.map(String::from),
status: status.map(String::from),
lead: lead.map(String::from),
start_date: trim_optional(start_date),
due_date: trim_optional(due_date),
};
let project = projects::create_project(config, &new)?;
save(config, store)?;
println!(
"Created {}",
projects::project_display(&project.id, &project.name)
);
Ok(ExitCode::Success)
}
#[allow(clippy::too_many_arguments)]
fn edit(
config: &mut Config,
store: &Store,
reference: &str,
name: Option<&str>,
description: Option<&str>,
status: Option<&str>,
lead: Option<&str>,
start_date: Option<&str>,
due_date: Option<&str>,
) -> Result<ExitCode> {
let edit = ProjectEdit {
name: name.map_or(Patch::Unchanged, |value| Patch::Set(value.to_string())),
description: text_patch(description),
status: text_patch(status),
lead: text_patch(lead),
start_date: date_patch(start_date),
due_date: date_patch(due_date),
};
if edit.name.is_unchanged()
&& edit.description.is_unchanged()
&& edit.status.is_unchanged()
&& edit.lead.is_unchanged()
&& edit.start_date.is_unchanged()
&& edit.due_date.is_unchanged()
{
return Err(crate::commands::project_edit_requires_a_field());
}
let project = projects::edit_project(config, reference, &edit)?;
save(config, store)?;
println!(
"Updated {}",
projects::project_display(&project.id, &project.name)
);
Ok(ExitCode::Success)
}
fn list(config: &Config, store: &Store) -> Result<ExitCode> {
for line in listing_lines(config, &store.list_all()?) {
println!("{line}");
}
Ok(ExitCode::Success)
}
fn listing_lines(config: &Config, issues: &[Issue]) -> Vec<String> {
projects::list_projects(config, issues)
.iter()
.map(|project| format!("{}\t{}\t{}", project.id, project.name, project.member_count))
.collect()
}
fn show(config: &Config, store: &Store, reference: &str) -> Result<ExitCode> {
let issues = store.list_all()?;
let project = projects::resolve(config, &issues, reference)?;
let (_index, reports) = crate::commands::view::project_reports(store, &issues)?;
for line in record_lines(&project, reports.get(&project.id)) {
println!("{line}");
}
Ok(ExitCode::Success)
}
pub fn record_lines(
project: &Project,
report: Option<&crate::projects::ProjectHealthReport>,
) -> Vec<String> {
let mut lines = vec![
format!("id: {}", project.id),
format!("name: {}", project.name),
format!(
"description: {}",
project.description.as_deref().unwrap_or("-")
),
format!("status: {}", project.status.as_deref().unwrap_or("-")),
format!("lead: {}", project.lead.as_deref().unwrap_or("-")),
format!(
"start-date: {}",
project.start_date.as_deref().unwrap_or("-")
),
format!("due-date: {}", project.due_date.as_deref().unwrap_or("-")),
format!("members: {}", project.member_count),
];
if let Some(report) = report {
lines.push(format!(
"progress: {}/{}",
report.progress.done, report.progress.total
));
lines.push(format!("overdue: {}", report.health.overdue));
lines.push(format!("blocked: {}", report.health.blocked));
lines.push(format!("unassigned: {}", report.health.unassigned));
lines.push(format!(
"missing-references: {}",
report.health.missing_references.len()
));
}
lines.extend(
project
.members
.iter()
.map(|member| format!(" {}", member.display)),
);
lines
}
fn delete(config: &mut Config, store: &Store, reference: &str, yes: bool) -> Result<ExitCode> {
let issues = store.list_all()?;
let project = projects::resolve(config, &issues, reference)?;
let mut confirmed = yes || !std::io::stdin().is_terminal();
if !confirmed {
eprint!(
"Delete project '{}'? [y/N] ",
projects::project_display(&project.id, &project.name)
);
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(TixError::Io)?;
confirmed = line.trim().eq_ignore_ascii_case("y");
if !confirmed {
eprintln!("aborted");
}
}
if !confirmed {
return Ok(ExitCode::Success);
}
eprint!("{}", impact_notice(&project));
let deletion = projects::delete_project(config, reference, &issues)?;
save(config, store)?;
println!(
"Deleted project {}",
projects::project_display(&deletion.id, &deletion.name)
);
Ok(ExitCode::Success)
}
fn impact_notice(project: &Project) -> String {
if project.members.is_empty() {
return String::new();
}
let plural = if project.members.len() == 1 {
"issue"
} else {
"issues"
};
let mut out = format!(
"Note: {} {plural} still reference this project and keep the reference as a \
missing project reference.\nThey are not deleted or modified; `tix lint` reports \
the dangling reference until it is repaired.\n",
project.members.len()
);
for member in &project.members {
out.push_str(&format!(" {}\n", member.display));
}
out
}
fn associate(
config: &Config,
store: &Store,
reference: &str,
selectors: &[String],
) -> Result<ExitCode> {
let id = projects::resolve_project_id(config, reference)?;
let display = display_for(config, &id);
let outcomes = store.bulk_associate(id.as_str(), selectors)?;
report(&display, "associated", "with", outcomes)
}
fn dissociate(
config: &Config,
store: &Store,
reference: &str,
selectors: &[String],
) -> Result<ExitCode> {
let resolved = projects::resolve_project_id(config, reference).ok();
let needle = resolved
.as_ref()
.map_or_else(|| reference.to_string(), |id| id.to_string());
let display = resolved
.as_ref()
.map_or_else(|| format!("`{reference}`"), |id| display_for(config, id));
let mut code = ExitCode::Success;
let mut changed = 0usize;
let mut unchanged = 0usize;
let mut failed = 0usize;
for selector in selectors {
match store.dissociate(selector, &needle) {
Ok(applied) if applied.changed => {
changed += 1;
println!("dissociated {selector}");
}
Ok(_) => {
unchanged += 1;
println!("dissociated {selector} (was not associated)");
}
Err(error) => {
failed += 1;
eprintln!("{}", crate::error::render_error(&error));
if code == ExitCode::Success {
code = crate::cli::exit_code_for(&error);
}
}
}
}
println!(
"dissociated {changed} of {} issue(s) from {display}{}{}",
selectors.len(),
unchanged_note(unchanged),
failure_note(failed)
);
Ok(code)
}
fn report(
display: &str,
verb: &str,
preposition: &str,
outcomes: Vec<BulkAssociationOutcome>,
) -> Result<ExitCode> {
let total = outcomes.len();
let mut code = ExitCode::Success;
let mut changed = 0usize;
let mut unchanged = 0usize;
let mut failed = 0usize;
for outcome in outcomes {
match outcome.outcome {
Ok(applied) if applied.changed => {
changed += 1;
println!("{verb} {}", outcome.selector);
}
Ok(_) => {
unchanged += 1;
println!("{verb} {} (already {verb})", outcome.selector);
}
Err(error) => {
failed += 1;
eprintln!("{}", crate::error::render_error(&error));
if code == ExitCode::Success {
code = crate::cli::exit_code_for(&error);
}
}
}
}
println!(
"{verb} {changed} of {total} issue(s) {preposition} {display}{}{}",
unchanged_note(unchanged),
failure_note(failed)
);
Ok(code)
}
fn unchanged_note(unchanged: usize) -> String {
if unchanged == 0 {
String::new()
} else {
format!(" ({unchanged} unchanged)")
}
}
fn failure_note(failed: usize) -> String {
if failed == 0 {
String::new()
} else {
format!("; {failed} failed")
}
}
fn display_for(config: &Config, id: &crate::config::ProjectId) -> String {
match config.projects.get(id.as_str()) {
Some(record) => projects::project_display(id, &record.name),
None => format!("`{id}`"),
}
}
pub fn resolve_projects(config: &Config, raw: &str) -> Result<Vec<String>> {
let mut out: Vec<String> = Vec::new();
for reference in crate::commands::parse_csv_list(raw) {
let id = projects::resolve_project_id(config, &reference)?.to_string();
if !out.contains(&id) {
out.push(id);
}
}
Ok(out)
}
pub fn associate_created(store: &Store, issue: &Issue, ids: &[String]) -> Result<()> {
for id in ids {
store.associate(&issue.number.to_string(), id)?;
}
Ok(())
}
#[derive(Debug)]
struct RemoveRef {
needle: String,
resolved: bool,
}
#[derive(Debug)]
pub struct EditAssociationPlan {
add_ids: Vec<String>,
remove_refs: Vec<RemoveRef>,
clear: bool,
}
pub fn plan_edit_associations(
config: &Config,
store: &Store,
selector: &str,
add: &[String],
remove: &[String],
clear: bool,
) -> Result<EditAssociationPlan> {
let mut add_ids: Vec<String> = Vec::new();
for reference in add {
let id = projects::resolve_project_id(config, reference)?.to_string();
if !add_ids.contains(&id) {
add_ids.push(id);
}
}
let remove_refs: Vec<RemoveRef> = remove
.iter()
.map(
|reference| match projects::resolve_project_id(config, reference) {
Ok(id) => RemoveRef {
needle: id.to_string(),
resolved: true,
},
Err(_) => RemoveRef {
needle: reference.clone(),
resolved: false,
},
},
)
.collect();
let stored = store.resolve_issue(selector)?.projects.unwrap_or_default();
for reference in remove_refs.iter().filter(|reference| !reference.resolved) {
if !stored.iter().any(|id| id == &reference.needle) {
return Err(ProjectError::UnknownProject {
reference: reference.needle.clone(),
}
.into());
}
}
Ok(EditAssociationPlan {
add_ids,
remove_refs,
clear,
})
}
pub fn apply_edit_associations(
store: &Store,
selector: &str,
plan: &EditAssociationPlan,
) -> Result<bool> {
let mut changed = false;
if plan.clear {
let stored = store.resolve_issue(selector)?.projects.unwrap_or_default();
for id in &stored {
changed |= store.dissociate(selector, id)?.changed;
}
}
for reference in &plan.remove_refs {
changed |= store.dissociate(selector, &reference.needle)?.changed;
}
for id in &plan.add_ids {
changed |= store.associate(selector, id)?.changed;
}
Ok(changed)
}
fn text_patch(value: Option<&str>) -> Patch<String> {
match value {
None => Patch::Unchanged,
Some(text) if text.trim().is_empty() => Patch::Clear,
Some(text) => Patch::Set(text.to_string()),
}
}
fn date_patch(value: Option<&str>) -> Patch<String> {
match value {
None => Patch::Unchanged,
Some(text) if text.trim().is_empty() => Patch::Clear,
Some(text) => Patch::Set(text.trim().to_string()),
}
}
fn trim_optional(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|text| !text.is_empty())
.map(String::from)
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::config::ProjectConfiguration;
use std::fs;
use tempfile::TempDir;
fn store_with(projects: &[(&str, &str)], issues: usize) -> (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 = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.layout.directory = "{assignee}".to_string();
config.layout.move_on_change = false;
for (id, name) in projects {
config.projects.insert(
(*id).to_string(),
ProjectConfiguration {
name: (*name).to_string(),
..ProjectConfiguration::default()
},
);
}
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir.clone(),
config,
};
for number in 1..=issues {
write_issue(
&store,
u32::try_from(number).unwrap(),
&format!("Issue {number}"),
&[],
);
}
(tmp, store)
}
fn write_issue(store: &Store, number: u32, title: &str, projects: &[&str]) {
let mut frontmatter =
format!("---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\n");
if !projects.is_empty() {
frontmatter.push_str("projects:\n");
for id in projects {
frontmatter.push_str(&format!("- {id}\n"));
}
}
frontmatter.push_str("---\n");
fs::write(
store
.issues_dir
.join(format!("2026-01-{number:02}-issue-{number}.md")),
frontmatter,
)
.unwrap();
}
fn registry(config_path: &std::path::Path) -> String {
fs::read_to_string(config_path).unwrap()
}
fn registry_path(store: &Store) -> std::path::PathBuf {
Config::project_root(&store.issues_dir)
.unwrap()
.join("tix.toml")
}
fn issue_bytes(store: &Store, number: u32) -> String {
fs::read_to_string(
store
.issues_dir
.join(format!("2026-01-{number:02}-issue-{number}.md")),
)
.unwrap()
}
#[test]
fn the_listing_shows_each_project_id_and_name_alongside_its_member_count() {
let (_tmp, store) = store_with(&[("p-1", "Platform"), ("p-2", "Infra")], 1);
write_issue(&store, 1, "Issue 1", &["p-1"]);
let lines = listing_lines(&store.config, &store.list_all().unwrap());
assert_eq!(
lines[0], "p-1\tPlatform\t1",
"id, name and count: {lines:?}"
);
assert_eq!(lines[1], "p-2\tInfra\t0");
assert!(lines[0].contains("p-1"));
assert!(lines[0].contains("Platform"));
}
#[test]
fn show_prints_the_record_and_its_members_with_ids() {
let (_tmp, store) = store_with(&[("p-1", "Platform")], 2);
write_issue(&store, 1, "Issue 1", &["p-1"]);
let project = projects::resolve(&store.config, &store.list_all().unwrap(), "p-1").unwrap();
let lines = record_lines(&project, None);
assert!(lines.contains(&"id: p-1".to_string()), "{lines:?}");
assert!(lines.contains(&"name: Platform".to_string()), "{lines:?}");
assert!(lines.contains(&"members: 1".to_string()), "{lines:?}");
assert!(
lines
.iter()
.any(|line| line.contains("#1") && line.contains("Issue 1")),
"a member names its issue: {lines:?}"
);
}
#[test]
fn create_allocates_an_id_and_refuses_a_duplicate_name() {
let (_tmp, store) = store_with(&[], 0);
let mut config = store.config.clone();
create(
&mut config,
&store,
"Platform",
None,
None,
Some("alice"),
None,
None,
)
.unwrap();
assert_eq!(config.projects.len(), 1);
let (id, record) = config.projects.iter().next().unwrap();
assert_eq!(record.name, "Platform");
assert_eq!(record.lead.as_deref(), Some("alice"));
assert!(id.starts_with("p-"), "an allocated id: {id}");
let before = registry(®istry_path(&store));
let error = create(
&mut config,
&store,
"platform",
None,
None,
None,
None,
None,
)
.expect_err("a duplicate name is refused");
assert!(
error.to_string().contains("duplicate_name"),
"the named diagnostic: {error}"
);
assert_eq!(config.projects.len(), 1);
assert_eq!(registry(®istry_path(&store)), before, "nothing written");
}
#[test]
fn edit_renames_the_label_and_keeps_the_id() {
let (_tmp, store) = store_with(&[("p-1", "Platform")], 1);
write_issue(&store, 1, "Issue 1", &["p-1"]);
let mut config = store.config.clone();
edit(
&mut config,
&store,
"p-1",
Some("Platform hardening"),
None,
None,
None,
None,
None,
)
.unwrap();
assert!(config.projects.contains_key("p-1"));
assert_eq!(config.projects["p-1"].name, "Platform hardening");
let project = projects::resolve(&config, &store.list_all().unwrap(), "p-1").unwrap();
assert_eq!(project.name, "Platform hardening");
assert_eq!(project.member_count, 1);
}
#[test]
fn edit_with_no_fields_is_a_usage_error() {
let (_tmp, store) = store_with(&[("p-1", "Platform")], 0);
let mut config = store.config.clone();
let error = edit(
&mut config,
&store,
"p-1",
None,
None,
None,
None,
None,
None,
)
.expect_err("a fieldless edit is refused");
assert!(error.to_string().contains("no fields to change"), "{error}");
}
#[test]
fn delete_removes_only_the_registry_entry_and_reports_the_members() {
let (_tmp, store) = store_with(&[("p-1", "Platform"), ("p-2", "Infra")], 1);
write_issue(&store, 1, "Issue 1", &["p-1"]);
let mut config = store.config.clone();
let issues = store.list_all().unwrap();
let before = issue_bytes(&store, 1);
let notice = impact_notice(&projects::resolve(&config, &issues, "p-1").unwrap());
assert!(
notice.contains("Issue 1"),
"the notice names the issue: {notice}"
);
assert!(notice.contains("not deleted"));
delete(&mut config, &store, "p-1", true).unwrap();
assert!(!config.projects.contains_key("p-1"));
assert!(config.projects.contains_key("p-2"));
assert!(!registry(®istry_path(&store)).contains("p-1"));
assert_eq!(
issue_bytes(&store, 1),
before,
"the issue file is unchanged"
);
assert!(issue_bytes(&store, 1).contains("p-1"));
let missing = projects::validate_references(&config, &store.list_all().unwrap());
assert_eq!(missing.len(), 1, "{missing:?}");
assert_eq!(missing[0].project_id, "p-1");
}
#[test]
fn delete_refuses_an_unknown_id_and_writes_nothing() {
let (_tmp, store) = store_with(&[("p-1", "Platform")], 0);
let mut config = store.config.clone();
let before = registry(®istry_path(&store));
let error = delete(&mut config, &store, "bogus", true).expect_err("unknown id");
assert!(error.to_string().contains("unknown_project"), "{error}");
assert!(config.projects.contains_key("p-1"));
assert_eq!(registry(®istry_path(&store)), before);
}
#[test]
fn associate_reports_each_selector_independently() {
let (_tmp, store) = store_with(&[("p-1", "Platform")], 2);
let code = associate(
&store.config,
&store,
"p-1",
&["1".to_string(), "99".to_string()],
)
.unwrap();
assert_ne!(code, ExitCode::Success, "a partial batch exits non-zero");
assert!(issue_bytes(&store, 1).contains("p-1"));
let events = store.resolve_issue("1").unwrap().history.events().len();
assert_eq!(
events, 1,
"exactly one History entry for the accepted change"
);
}
#[test]
fn associate_refuses_an_unknown_project_before_writing_anything() {
let (_tmp, store) = store_with(&[("p-1", "Platform")], 1);
let before = issue_bytes(&store, 1);
let error = associate(&store.config, &store, "bogus", &["1".to_string()])
.expect_err("an unknown project refuses up front");
assert!(error.to_string().contains("unknown_project"), "{error}");
assert_eq!(issue_bytes(&store, 1), before, "nothing written");
}
#[test]
fn associating_twice_appends_one_history_entry() {
let (_tmp, store) = store_with(&[("p-1", "Platform")], 1);
associate(&store.config, &store, "p-1", &["1".to_string()]).unwrap();
let after_first = issue_bytes(&store, 1);
let code = associate(&store.config, &store, "p-1", &["1".to_string()]).unwrap();
assert_eq!(
code,
ExitCode::Success,
"an idempotent no-op is not a failure"
);
assert_eq!(
store.resolve_issue("1").unwrap().history.events().len(),
1,
"no second entry for a duplicate association"
);
assert_eq!(
issue_bytes(&store, 1),
after_first,
"a no-op writes nothing"
);
}
#[test]
fn dissociate_drops_only_the_association() {
let (_tmp, store) = store_with(&[("p-1", "Platform")], 1);
write_issue(&store, 1, "Issue 1", &["p-1"]);
let code = dissociate(&store.config, &store, "p-1", &["1".to_string()]).unwrap();
assert_eq!(code, ExitCode::Success);
let issue = store.resolve_issue("1").unwrap();
assert_eq!(issue.projects, None, "the association is dropped");
assert!(
store.issues_dir.join(&issue.filename).is_file(),
"the issue itself is kept at {}",
issue.filename
);
let code = dissociate(&store.config, &store, "p-1", &["1".to_string()]).unwrap();
assert_eq!(code, ExitCode::Success);
}
#[test]
fn dissociate_can_still_remove_a_stale_id_by_name_reference() {
let (_tmp, store) = store_with(&[], 1);
write_issue(&store, 1, "Issue 1", &["p-gone"]);
let code = dissociate(&store.config, &store, "p-gone", &["1".to_string()]).unwrap();
assert_eq!(code, ExitCode::Success);
assert_eq!(store.resolve_issue("1").unwrap().projects, None);
}
#[test]
fn resolve_projects_collapses_duplicates_and_refuses_an_unknown_id() {
let (_tmp, store) = store_with(&[("p-1", "Platform")], 0);
let ids = resolve_projects(&store.config, "Platform,p-1").unwrap();
assert_eq!(
ids,
vec!["p-1".to_string()],
"a name and an id are one project"
);
let error = resolve_projects(&store.config, "p-1,bogus").expect_err("unknown id");
assert!(error.to_string().contains("unknown_project"), "{error}");
}
#[test]
fn associate_created_writes_one_entry_per_project() {
let (_tmp, store) = store_with(&[("p-1", "Platform"), ("p-2", "Infra")], 1);
let issue = store.resolve_issue("1").unwrap();
associate_created(&store, &issue, &["p-1".to_string(), "p-2".to_string()]).unwrap();
let after = store.resolve_issue("1").unwrap();
assert_eq!(
after.projects,
Some(vec!["p-1".to_string(), "p-2".to_string()])
);
assert_eq!(after.history.events().len(), 2, "one entry per association");
}
#[test]
fn a_plan_refuses_an_unknown_remove_reference_that_is_not_stored() {
let (_tmp, store) = store_with(&[("p-1", "Platform")], 1);
let error = plan_edit_associations(
&store.config,
&store,
"1",
&[],
&["bogus".to_string()],
false,
)
.expect_err("an unresolvable, unstored reference is a typo");
assert!(error.to_string().contains("unknown_project"), "{error}");
}
#[test]
fn a_plan_accepts_removing_a_stale_stored_id() {
let (_tmp, store) = store_with(&[], 1);
write_issue(&store, 1, "Issue 1", &["p-gone"]);
let plan = plan_edit_associations(
&store.config,
&store,
"1",
&[],
&["p-gone".to_string()],
false,
)
.unwrap();
assert!(
apply_edit_associations(&store, "1", &plan).unwrap(),
"removing a stored id changed the issue"
);
assert_eq!(store.resolve_issue("1").unwrap().projects, None);
}
#[test]
fn clearing_removes_every_association_and_keeps_the_issue() {
let (_tmp, store) = store_with(&[("p-1", "Platform"), ("p-2", "Infra")], 1);
write_issue(&store, 1, "Issue 1", &["p-1", "p-2"]);
let plan = plan_edit_associations(&store.config, &store, "1", &[], &[], true).unwrap();
assert!(apply_edit_associations(&store, "1", &plan).unwrap());
let issue = store.resolve_issue("1").unwrap();
assert_eq!(issue.projects, None);
assert_eq!(issue.history.events().len(), 2, "one entry per removal");
}
#[test]
fn a_noop_association_plan_reports_no_change() {
let (_tmp, store) = store_with(&[("p-1", "Platform")], 1);
write_issue(&store, 1, "Issue 1", &["p-1"]);
let plan =
plan_edit_associations(&store.config, &store, "1", &["p-1".to_string()], &[], false)
.unwrap();
assert!(
!apply_edit_associations(&store, "1", &plan).unwrap(),
"adding an existing association is a no-op"
);
let (_tmp2, store2) = store_with(&[("p-1", "Platform")], 1);
write_issue(&store2, 1, "Issue 1", &[]);
let plan = plan_edit_associations(&store2.config, &store2, "1", &[], &[], true).unwrap();
assert!(
!apply_edit_associations(&store2, "1", &plan).unwrap(),
"clearing nothing is a no-op"
);
}
#[test]
fn an_applied_association_plan_reports_a_change() {
let (_tmp, store) = store_with(&[("p-1", "Platform")], 1);
let plan =
plan_edit_associations(&store.config, &store, "1", &["p-1".to_string()], &[], false)
.unwrap();
assert!(apply_edit_associations(&store, "1", &plan).unwrap());
}
}