use super::*;
use serde_json::json;
use tempfile::TempDir;
#[test]
fn next_free_id_returns_first_when_free() {
let ts = chrono::Utc::now();
let got = next_free_id("hello", ts, 4, |_| false).unwrap();
assert_eq!(got, Task::generate_id("hello", ts, 4));
}
#[test]
fn next_free_id_bumps_past_collision() {
let ts = chrono::Utc::now();
let first = Task::generate_id("hello", ts, 4);
let got = next_free_id("hello", ts, 4, |id| id == first).unwrap();
assert_ne!(got, first);
}
#[test]
fn next_free_id_exhausts() {
let err = next_free_id("x", chrono::Utc::now(), 4, |_| true).unwrap_err();
match err {
BallError::Other(s) => assert!(s.contains("1000 tries")),
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn fresh_id_rejects_bad_json() {
let used = BTreeSet::new();
let err = fresh_id("not json", 4, &used).unwrap_err();
assert!(matches!(err, BallError::InvalidTask(_)));
}
#[test]
fn fresh_id_from_valid_task() {
let json = serde_json::to_string(&json!({
"id": "bl-dead", "title": "t", "type": "task", "priority": 3,
"status": "open", "parent": null, "created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z", "closed_at": null,
"claimed_by": null, "branch": null
}))
.unwrap();
let used = BTreeSet::new();
assert!(fresh_id(&json, 4, &used).unwrap().starts_with("bl-"));
}
#[test]
fn read_local_tasks_missing_dir_is_empty() {
let dir = TempDir::new().unwrap();
let got = read_local_tasks(&dir.path().join("nope")).unwrap();
assert!(got.is_empty());
}
#[test]
fn read_local_tasks_skips_scaffolding() {
let dir = TempDir::new().unwrap();
let t = dir.path();
std::fs::write(t.join("bl-aaaa.json"), "{}").unwrap();
std::fs::write(t.join("bl-aaaa.notes.jsonl"), "x\n").unwrap();
std::fs::write(t.join(".gitattributes"), "").unwrap();
std::fs::write(t.join(".gitkeep"), "").unwrap();
let got = read_local_tasks(t).unwrap();
assert_eq!(got.keys().collect::<Vec<_>>(), vec!["bl-aaaa"]);
assert_eq!(got["bl-aaaa"].notes, "x\n");
}
#[test]
fn json_rel_is_under_tasks_dir() {
assert_eq!(json_rel("bl-abcd"), ".balls/tasks/bl-abcd.json");
}
#[test]
fn write_task_rejects_non_json() {
let dir = TempDir::new().unwrap();
let lt = LocalTask {
json: "not json".into(),
notes: String::new(),
};
let err = write_task(dir.path(), "bl-x", <, &BTreeMap::new()).unwrap_err();
assert!(matches!(err, BallError::InvalidTask(_)));
}
#[test]
fn write_task_rejects_valid_json_that_is_not_a_task() {
let dir = TempDir::new().unwrap();
let lt = LocalTask {
json: "{}".into(),
notes: String::new(),
};
let err = write_task(dir.path(), "bl-x", <, &BTreeMap::new()).unwrap_err();
assert!(matches!(err, BallError::InvalidTask(_)));
}
#[test]
fn write_task_replays_with_notes() {
let dir = TempDir::new().unwrap();
let json = serde_json::to_string(&json!({
"id": "bl-aaaa", "title": "t", "type": "task", "priority": 3,
"status": "open", "parent": null, "created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z", "closed_at": null,
"claimed_by": null, "branch": null
}))
.unwrap();
let lt = LocalTask {
json,
notes: "{\"x\":1}\n".into(),
};
write_task(dir.path(), "bl-aaaa", <, &BTreeMap::new()).unwrap();
assert!(dir.path().join("bl-aaaa.json").exists());
assert_eq!(
std::fs::read_to_string(dir.path().join("bl-aaaa.notes.jsonl")).unwrap(),
"{\"x\":1}\n"
);
}
fn rn(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(a, b)| ((*a).to_string(), (*b).to_string()))
.collect()
}
#[test]
fn remap_refs_non_object_is_noop() {
let mut v = json!(42);
remap_refs(&mut v, "bl-new", &rn(&[]));
assert_eq!(v, json!(42));
}
#[test]
fn remap_refs_rewrites_id_and_all_reference_shapes() {
let rename = rn(&[("bl-old", "bl-new"), ("bl-dep", "bl-dep2")]);
let mut v = json!({
"id": "bl-old",
"parent": "bl-old",
"depends_on": ["bl-dep", "bl-keep", 7],
"links": [{"target": "bl-old"}, {"note": "x"}, {"target": 9}],
"closed_children": [{"id": "bl-dep"}],
});
remap_refs(&mut v, "bl-new", &rename);
assert_eq!(v["id"], json!("bl-new"));
assert_eq!(v["parent"], json!("bl-new"));
assert_eq!(v["depends_on"], json!(["bl-dep2", "bl-keep", 7]));
assert_eq!(v["links"][0]["target"], json!("bl-new"));
assert_eq!(v["links"][2]["target"], json!(9));
assert_eq!(v["closed_children"][0]["id"], json!("bl-dep2"));
}
#[test]
fn remap_refs_handles_absent_and_nonstring_parent() {
let mut absent = json!({"id": "bl-x"});
remap_refs(&mut absent, "bl-y", &rn(&[]));
assert_eq!(absent["id"], json!("bl-y"));
assert!(absent.get("parent").is_none());
let mut nonstr = json!({"id": "bl-x", "parent": null});
remap_refs(&mut nonstr, "bl-y", &rn(&[]));
assert_eq!(nonstr["parent"], json!(null));
}
#[test]
fn remap_array_ignores_missing_and_non_array() {
let map = |s: &str| s.to_string();
remap_array(None, &map);
let mut not_arr = json!("scalar");
remap_array(Some(&mut not_arr), &map);
assert_eq!(not_arr, json!("scalar"));
}
#[test]
fn remap_field_array_ignores_missing_non_array_and_non_object_elems() {
let map = |s: &str| s.to_string();
remap_field_array(None, "target", &map);
let mut not_arr = json!(1);
remap_field_array(Some(&mut not_arr), "target", &map);
assert_eq!(not_arr, json!(1));
let mut arr = json!(["bare", {"other": 1}]);
remap_field_array(Some(&mut arr), "target", &map);
assert_eq!(arr, json!(["bare", {"other": 1}]));
}