use super::*;
use crate::store::Store;
use crate::task::{NewTaskOpts, Status, Task};
use chrono::Utc;
use std::path::Path;
use tempfile::tempdir;
fn raw_git(dir: &Path, args: &[&str]) {
assert!(
clean_git_command(dir).args(args).output().unwrap().status.success(),
"git {args:?}"
);
}
fn init_store(dir: &Path) -> Store {
raw_git(dir, &["init", "-q", "-b", "main"]);
raw_git(dir, &["config", "user.email", "t@e.x"]);
raw_git(dir, &["config", "user.name", "t"]);
raw_git(dir, &["config", "commit.gpgsign", "false"]);
Store::init(dir, false, None).unwrap()
}
fn unavailable_store(dir: &Path) -> Store {
let td = dir.join("tasks").to_string_lossy().into_owned();
let s = Store::init(dir, false, Some(td)).unwrap();
assert!(s.no_git && s.stealth);
s
}
fn archive(store: &Store, id: &str, title: &str) {
let mut t = Task::new(
NewTaskOpts { title: title.into(), ..Default::default() },
id.into(),
);
t.status = Status::Review;
store.save_task(&t).unwrap();
store.commit_task(id, &format!("state: review {id}")).unwrap();
let mut closed = t.clone();
closed.status = Status::Closed;
closed.closed_at = Some(Utc::now());
store
.close_and_archive(&closed, &format!("state: close {id} - {title}"))
.unwrap();
}
#[test]
fn available_true_for_normal_store_false_for_no_git() {
let td = tempdir().unwrap();
assert!(available(&init_store(td.path())));
let plain = tempdir().unwrap();
assert!(!available(&unavailable_store(plain.path())));
}
#[test]
fn git_out_spawn_failure_is_none() {
assert!(git_out(Path::new("/no/such/dir/xyz"), &["status"]).is_none());
}
#[test]
fn git_out_success_and_nonzero() {
let td = tempdir().unwrap();
let store = init_store(td.path());
let sw = store.state_worktree_dir();
assert!(git_out(&sw, &["rev-parse", "HEAD"]).is_some());
assert!(git_out(&sw, &["cat-file", "-e", "deadbeefdeadbeef"]).is_none());
}
#[test]
fn split_sha_date_cases() {
let (sha, _) = split_sha_date("ab\t2026-05-18T00:00:00+00:00").unwrap();
assert_eq!(sha, "ab");
assert!(split_sha_date("notab").is_none());
assert!(split_sha_date("ab\tnotadate").is_none());
}
#[test]
fn id_from_path_cases() {
assert_eq!(id_from_path(".balls/tasks/bl-aaaa.json"), Some("bl-aaaa"));
assert_eq!(id_from_path(".balls/tasks/bl-aaaa.notes.jsonl"), None);
assert_eq!(id_from_path("src/main.rs"), None);
assert_eq!(id_from_path(".balls/tasks/notbl.json"), None);
}
#[test]
fn parse_deletions_dedupes_skips_notes_and_sorts() {
let log = "\u{1}s1\t2026-05-02T00:00:00+00:00\n\
.balls/tasks/bl-bbbb.json\n\
.balls/tasks/bl-bbbb.notes.jsonl\n\
\u{1}s2\t2026-05-01T00:00:00+00:00\n\
.balls/tasks/bl-aaaa.json\n\
.balls/tasks/bl-bbbb.json\n\
.balls/tasks/.gitkeep\n\
\u{1}s3\t2026-05-01T00:00:00+00:00\n\
.balls/tasks/bl-cccc.json\n";
let got = parse_deletions(log, |_| false);
let ids: Vec<&str> = got.iter().map(|(i, ..)| i.as_str()).collect();
let shas: Vec<&str> = got.iter().map(|(_, s, _)| s.as_str()).collect();
assert_eq!(ids, ["bl-aaaa", "bl-cccc", "bl-bbbb"]);
assert_eq!(shas, ["s2", "s3", "s1"]);
}
#[test]
fn parse_deletions_skips_path_without_valid_header() {
let log = ".balls/tasks/bl-early.json\n\
\u{1}badheader-no-tab\n\
.balls/tasks/bl-xxxx.json\n\
\u{1}s9\tnotadate\n\
.balls/tasks/bl-yyyy.json\n";
assert!(parse_deletions(log, |_| false).is_empty());
}
#[test]
fn parse_deletions_skips_live_ids() {
let log = "\u{1}s1\t2026-05-01T00:00:00+00:00\n\
.balls/tasks/bl-live.json\n\
.balls/tasks/bl-keep.json\n";
let got = parse_deletions(log, |id| id == "bl-live");
assert_eq!(got.iter().map(|(i, ..)| i.as_str()).collect::<Vec<_>>(), ["bl-keep"]);
}
#[test]
fn task_at_predeletion_bogus_sha_is_none() {
let td = tempdir().unwrap();
let store = init_store(td.path());
let zero = "0".repeat(40);
assert!(
task_at_predeletion(&store.state_worktree_dir(), "bl-aaaa", &zero, Utc::now())
.is_none()
);
}
#[test]
fn recover_one_reconstructs_closed_task() {
let td = tempdir().unwrap();
let store = init_store(td.path());
archive(&store, "bl-aaaa", "Title A");
let t = recover_one(&store, "bl-aaaa").unwrap();
assert_eq!(t.id, "bl-aaaa");
assert_eq!(t.title, "Title A");
assert_eq!(t.status, Status::Closed);
assert!(t.closed_at.is_some());
}
#[test]
fn recover_one_none_for_never_closed_and_unavailable() {
let td = tempdir().unwrap();
let store = init_store(td.path());
assert!(recover_one(&store, "bl-ffff").is_none());
let plain = tempdir().unwrap();
assert!(recover_one(&unavailable_store(plain.path()), "bl-aaaa").is_none());
}
#[test]
fn recover_all_empty_when_unavailable() {
let plain = tempdir().unwrap();
assert!(recover_all(&unavailable_store(plain.path())).is_empty());
}
#[test]
fn recover_all_lists_closed_and_drops_unparseable() {
let td = tempdir().unwrap();
let store = init_store(td.path());
archive(&store, "bl-aaaa", "A");
archive(&store, "bl-bbbb", "B");
let sw = store.state_worktree_dir();
std::fs::write(sw.join(".balls/tasks/bl-junk.json"), "not json").unwrap();
raw_git(&sw, &["add", ".balls/tasks/bl-junk.json"]);
raw_git(&sw, &["commit", "-m", "add junk", "--no-verify"]);
raw_git(&sw, &["rm", "-f", ".balls/tasks/bl-junk.json"]);
raw_git(&sw, &["commit", "-m", "state: close bl-junk", "--no-verify"]);
let got = recover_all(&store);
let ids: Vec<&str> = got.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, ["bl-aaaa", "bl-bbbb"]);
assert!(got.iter().all(|t| t.status == Status::Closed));
}