use std::fs;
use std::path::Path;
use tempfile::TempDir;
use tix::config::{Config, ProjectConfiguration, ProjectId};
use tix::model::Issue;
use tix::projects::{
IssueKey, NewProject, Patch, ProjectDiagnosticKind, ProjectEdit, associate, create_project,
delete_project, edit_project, membership, membership_index, resolve_project_id,
validate_references,
};
const PROJECTS_TOML: &str = "issues_dir = \"docs/issues\"\n\n\
[projects.\"p-platform\"]\nname = \"Platform hardening\"\n\n\
[projects.\"p-docs\"]\nname = \"Docs refresh\"\n";
fn repo() -> TempDir {
let dir = TempDir::new().unwrap();
write_toml(&dir, PROJECTS_TOML);
dir
}
fn repo_without_projects() -> TempDir {
let dir = TempDir::new().unwrap();
write_toml(&dir, "issues_dir = \"docs/issues\"\n");
dir
}
fn write_toml(dir: &TempDir, raw: &str) {
fs::write(dir.path().join("tix.toml"), raw).unwrap();
fs::create_dir_all(dir.path().join("docs/issues")).unwrap();
}
fn toml_path(dir: &TempDir) -> std::path::PathBuf {
dir.path().join("tix.toml")
}
fn issue_path(dir: &TempDir, number: u32) -> std::path::PathBuf {
dir.path().join("docs/issues").join(format!("{number}.md"))
}
fn write_issue(dir: &TempDir, number: u32, title: &str, ids: &[&str]) {
let mut raw = format!("---\nnumber: {number}\ntitle: {title}\nstatus: todo\n");
if !ids.is_empty() {
raw.push_str("projects:\n");
for id in ids {
raw.push_str(&format!("- {id}\n"));
}
}
raw.push_str("---\n\nBody.\n");
fs::write(issue_path(dir, number), raw).unwrap();
}
fn read_issues(root: &Path) -> Vec<Issue> {
let mut out: Vec<Issue> = fs::read_dir(root.join("docs/issues"))
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| path.extension().is_some_and(|ext| ext == "md"))
.map(|path| Issue::parse(&fs::read_to_string(&path).unwrap()).unwrap())
.collect();
out.sort_by_key(|issue| issue.number);
out
}
#[test]
fn identity_survives_a_rename_through_a_real_config() {
let dir = repo();
write_issue(&dir, 2, "Two", &["p-platform"]);
let path = toml_path(&dir);
let mut config = Config::load(dir.path()).unwrap();
let before = read_issues(dir.path());
assert_eq!(
membership(&config, &before, "p-platform")
.unwrap()
.member_count,
1
);
let edited = edit_project(
&mut config,
"p-platform",
&ProjectEdit {
name: Patch::Set("Core platform".into()),
..ProjectEdit::default()
},
)
.unwrap();
assert_eq!(edited.id.as_str(), "p-platform");
config.save(&path).unwrap();
let reloaded = Config::load(dir.path()).unwrap();
let after = read_issues(dir.path());
assert!(
reloaded.projects.contains_key("p-platform"),
"the id is the key and survives the rename"
);
let membership = membership(&reloaded, &after, "p-platform").unwrap();
assert_eq!(membership.member_count, 1);
assert_eq!(membership.members[0].issue.number(), 2);
assert_eq!(
resolve_project_id(&reloaded, "Core platform")
.unwrap()
.as_str(),
"p-platform"
);
assert_eq!(
after[0].projects.as_deref(),
Some(["p-platform".to_string()].as_slice())
);
}
#[test]
fn associate_returns_a_change_and_writes_nothing() {
let dir = repo();
write_issue(&dir, 1, "One", &[]);
let config = Config::load(dir.path()).unwrap();
let issues = read_issues(dir.path());
let before_toml = fs::read_to_string(toml_path(&dir)).unwrap();
let before_issue = fs::read_to_string(issue_path(&dir, 1)).unwrap();
let change = associate(&config, &issues, 1, "p-platform").unwrap();
assert_eq!(change.project_ids, vec!["p-platform".to_string()]);
let event = change.event.expect("an accepted change is audited");
assert_eq!(event.detail, "project `p-platform` associated");
assert_eq!(fs::read_to_string(toml_path(&dir)).unwrap(), before_toml);
assert_eq!(
fs::read_to_string(issue_path(&dir, 1)).unwrap(),
before_issue
);
}
#[test]
fn multi_membership_resolves_through_a_real_issue_tree() {
let dir = repo();
write_issue(&dir, 5, "Five", &["p-platform", "p-docs"]);
write_issue(&dir, 2, "Two", &["p-platform"]);
let config = Config::load(dir.path()).unwrap();
let issues = read_issues(dir.path());
let platform = membership(&config, &issues, "p-platform").unwrap();
assert_eq!(
platform
.members
.iter()
.map(|member| member.issue.number())
.collect::<Vec<_>>(),
vec![2, 5],
"members are ordered by issue number"
);
assert_eq!(
membership(&config, &issues, "p-docs").unwrap().member_count,
1
);
let buckets = membership_index(&config, &issues);
let id = ProjectId::new("p-platform").unwrap();
assert_eq!(
buckets[&id],
vec![IssueKey::new(2), IssueKey::new(5)],
"a filter is a bucket lookup"
);
let docs = ProjectId::new("p-docs").unwrap();
assert_eq!(buckets[&docs], vec![IssueKey::new(5)]);
}
#[test]
fn a_missing_reference_is_reported_end_to_end_and_retained() {
let dir = repo();
write_issue(&dir, 3, "Three", &["p-platform"]);
let mut config = Config::load(dir.path()).unwrap();
let issues = read_issues(dir.path());
let impact = delete_project(&mut config, "p-platform", &issues).unwrap();
assert_eq!(impact.id.as_str(), "p-platform");
assert_eq!(
impact
.affected
.iter()
.map(|member| member.issue.number())
.collect::<Vec<_>>(),
vec![3]
);
config.save(&toml_path(&dir)).unwrap();
let reloaded = Config::load(dir.path()).unwrap();
let after = read_issues(dir.path());
let missing = validate_references(&reloaded, &after);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0].issue.number(), 3);
assert_eq!(missing[0].project_id, "p-platform");
assert!(missing[0].reason.contains("not in the registry"));
let raw = fs::read_to_string(issue_path(&dir, 3)).unwrap();
assert!(
raw.contains("p-platform"),
"the reference must survive: {raw}"
);
}
#[test]
fn successive_create_draws_after_a_delete_are_distinct_end_to_end() {
let dir = repo_without_projects();
let path = toml_path(&dir);
let mut config = Config::load(dir.path()).unwrap();
let first = create_project(
&mut config,
&NewProject {
name: "One".into(),
..NewProject::default()
},
)
.unwrap();
assert!(!config.projects.is_empty());
config.save(&path).unwrap();
let mut config = Config::load(dir.path()).unwrap();
delete_project(&mut config, first.id.as_str(), &read_issues(dir.path())).unwrap();
config.save(&path).unwrap();
assert!(Config::load(dir.path()).unwrap().projects.is_empty());
let mut config = Config::load(dir.path()).unwrap();
let second = create_project(
&mut config,
&NewProject {
name: "One".into(),
..NewProject::default()
},
)
.unwrap();
config.save(&path).unwrap();
assert_ne!(first.id, second.id, "two created ids must be distinct");
let reloaded = Config::load(dir.path()).unwrap();
assert!(reloaded.projects.contains_key(second.id.as_str()));
assert!(!reloaded.projects.contains_key(first.id.as_str()));
}
#[test]
fn service_and_loader_agree_on_project_name_rules() {
let dir = repo_without_projects();
let mut config = Config::load(dir.path()).unwrap();
let created = create_project(
&mut config,
&NewProject {
name: "Platform hardening".into(),
..NewProject::default()
},
)
.unwrap();
config.save(&toml_path(&dir)).unwrap();
assert!(
Config::load(dir.path()).is_ok(),
"a name the service accepted must load"
);
assert_eq!(
Config::load(dir.path())
.unwrap()
.projects
.get(created.id.as_str())
.unwrap()
.name,
"Platform hardening"
);
let padded = "issues_dir = \"docs/issues\"\n\n[projects.\"p-1\"]\nname = \" One \"\n";
fs::write(toml_path(&dir), padded).unwrap();
assert!(
Config::load(dir.path()).is_err(),
"the loader refuses padding"
);
let err = create_project(
&mut Config::default(),
&NewProject {
name: " One ".into(),
..NewProject::default()
},
)
.unwrap_err();
assert_eq!(err.kind(), ProjectDiagnosticKind::DuplicateName);
let duplicate = "issues_dir = \"docs/issues\"\n\n\
[projects.\"p-1\"]\nname = \"Platform\"\n\n\
[projects.\"p-2\"]\nname = \"platform\"\n";
fs::write(toml_path(&dir), duplicate).unwrap();
assert!(
Config::load(dir.path()).is_err(),
"the loader refuses a case-folded duplicate"
);
let mut config = Config::default();
config.projects.insert(
"p-1".to_string(),
ProjectConfiguration {
name: "Platform".into(),
..ProjectConfiguration::default()
},
);
let err = create_project(
&mut config,
&NewProject {
name: "platform".into(),
..NewProject::default()
},
)
.unwrap_err();
assert_eq!(err.kind(), ProjectDiagnosticKind::DuplicateName);
}