use super::*;
use crate::model::now;
#[test]
fn save_renames_on_title_change_and_loads_back() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path()).unwrap();
let mut task = Task::new(3, "First title".into(), now());
store.save(&mut task).unwrap();
task.title = "Second title".into();
store.save(&mut task).unwrap();
let names: Vec<String> =
fs::read_dir(dir.path()).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
assert_eq!(names, ["0003-second-title.md"]);
let (tasks, warnings) = store.load_entries().unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(tasks[0].title, "Second title");
}
#[test]
fn saving_never_writes_over_a_file_that_didnt_parse() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path()).unwrap();
let mut task = Task::new(1, "Fix".into(), now());
store.save(&mut task).unwrap();
let copy = dir.path().join("0001-fix (conflicted copy).md");
fs::rename(dir.path().join("0001-fix.md"), ©).unwrap();
fs::write(dir.path().join("0001-fix.md"), "not an entry").unwrap();
let (mut tasks, warnings) = store.load_entries().unwrap();
assert_eq!((tasks.len(), warnings.len()), (1, 1), "{warnings:?}");
tasks[0].description = "mine".into();
let refused = store.save(&mut tasks[0]).unwrap_err().to_string();
assert!(refused.contains("couldn't be read as #1"), "{refused}");
assert_eq!(fs::read_to_string(dir.path().join("0001-fix.md")).unwrap(), "not an entry", "kept");
assert!(copy.exists(), "and the copy too");
}
#[test]
fn a_file_renamed_only_in_case_keeps_its_name_and_its_content() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path()).unwrap();
store.save(&mut Task::new(5, "Fix login".into(), now())).unwrap();
let renamed = dir.path().join("0005-Fix-Login.md");
fs::rename(dir.path().join("0005-fix-login.md"), &renamed).unwrap();
let (mut tasks, _) = store.load_entries().unwrap();
tasks[0].description = "kept".into();
store.save(&mut tasks[0]).unwrap();
let names: Vec<String> =
fs::read_dir(dir.path()).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
assert_eq!(names, ["0005-Fix-Login.md"], "saved where it is, not removed as an old name");
assert!(fs::read_to_string(renamed).unwrap().contains("kept"));
}
#[test]
fn files_an_editor_saved_with_a_byte_order_mark_read_as_without() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path()).unwrap();
let mut task = Task::new(2, "Fix login".into(), now());
store.save(&mut task).unwrap();
let path = dir.path().join("0002-fix-login.md");
fs::write(&path, format!("\u{feff}{}", fs::read_to_string(&path).unwrap())).unwrap();
fs::write(dir.path().join(LAST_ID_FILE), "\u{feff}7\n").unwrap();
fs::write(dir.path().join(TAGS_FILE), "\u{feff}- prod: #112233\n").unwrap();
let (tasks, warnings) = store.load_entries().unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(tasks[0].title, "Fix login", "the title, not untitled");
assert_eq!(store.last_id(), 7);
assert!(store.load_tag_colors().unwrap().chip("prod").is_some(), "its first color too");
let repo = dir.path().join("repo");
fs::create_dir(&repo).unwrap();
fs::write(repo.join(crate::projects::PROJECT_FILE), "\u{feff}# shop\n").unwrap();
assert_eq!(crate::projects::read_repo_card(&repo).unwrap().project, "shop");
fs::write(dir.path().join(crate::migrate::FORMAT_FILE), "\u{feff}1\n").unwrap();
assert_eq!(crate::migrate::format_of(dir.path()).unwrap(), 1);
}
#[test]
fn a_brain_made_now_says_its_format_and_one_already_there_is_left_as_it_is() {
let dir = tempfile::tempdir().unwrap();
let new = dir.path().join("new");
Store::at(&new).unwrap();
let format = fs::read_to_string(new.join(crate::migrate::FORMAT_FILE)).unwrap();
assert_eq!(format, format!("{}\n", crate::migrate::FORMAT));
Store::at(dir.path()).unwrap();
assert!(!dir.path().join(crate::migrate::FORMAT_FILE).exists(), "an older brain reads as format 1 until migrated");
}
#[test]
fn unreadable_files_become_warnings() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path()).unwrap();
fs::write(dir.path().join("README.md"), "# not a task").unwrap();
fs::write(dir.path().join(TAGS_FILE), "- prod: red").unwrap();
let (tasks, warnings) = store.load_entries().unwrap();
assert!(tasks.is_empty());
assert_eq!(warnings.len(), 1, "tags.md is not a task: {warnings:?}");
}
#[test]
fn projects_are_found_from_any_subfolder() {
let root = tempfile::tempdir().unwrap();
let nested = root.path().join("src/deep");
fs::create_dir_all(&nested).unwrap();
assert_eq!(find_project(root.path()), None, "a .brain above the temporary folders: set TMPDIR elsewhere");
assert_eq!(find_project(&nested), None);
let brain = root.path().join(PROJECT_DIR);
fs::create_dir(&brain).unwrap();
assert_eq!(find_project(&nested), Some(brain.clone()));
let name = root.path().file_name().unwrap().to_string_lossy().into_owned();
assert_eq!(Store::at(&brain).unwrap().project(), Some(name));
assert_eq!(Store::at(root.path()).unwrap().project(), None);
}
#[test]
fn files_are_written_whole_with_their_folder_and_no_temporary_left() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("projects").join("shop.md");
write_file(&path, "# shop\n").unwrap();
write_file(&path, "# shop\n\nAn online shop.\n").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "# shop\n\nAn online shop.\n");
let names: Vec<_> = fs::read_dir(path.parent().unwrap()).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(names, ["shop.md"]);
}
#[test]
fn the_hash_is_fnv1a_on_every_build() {
assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325);
assert_eq!(fnv1a(b"a"), 0xaf63_dc4c_8601_ec8c);
}
#[test]
fn a_temporary_brains_cache_goes_once_it_does() {
let (gone, kept) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let lock = |dir: &Path| drop(Store::at(dir).unwrap().lock().unwrap());
lock(gone.path());
let [gone_lock, _] = cache_of(gone.path()).unwrap();
let brain = fs::read_to_string(&gone_lock).unwrap();
assert_eq!(brain, format!("{}\n", gone.path().canonicalize().unwrap().display()), "it names its brain");
drop(gone);
lock(kept.path());
assert!(!gone_lock.exists(), "tidied as another brain there is locked");
assert!(cache_of(kept.path()).unwrap()[0].exists());
}
#[test]
fn a_thread_holding_a_brains_lock_takes_it_again_without_waiting_for_itself() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path()).unwrap();
let outer = store.lock().unwrap();
let inner = store.lock().unwrap();
drop(inner);
let path = outer.path.clone();
assert!(HELD.with(|held| held.borrow().contains(&path)), "the outer one still holds it");
drop(outer);
assert!(!HELD.with(|held| held.borrow().contains(&path)));
}
#[test]
fn a_relative_brain_is_named_after_the_current_folder() {
let here = std::env::current_dir().unwrap();
let name = here.file_name().unwrap().to_string_lossy().into_owned();
assert_eq!(Store { dir: PathBuf::from(PROJECT_DIR), journal: RefCell::new(None) }.project(), Some(name));
}
#[test]
fn what_a_crash_left_of_a_write_goes_once_the_lock_is_taken() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("reports")).unwrap();
let left = |name: &str, age: Duration| {
let path = dir.path().join(name);
let file = fs::File::create(&path).unwrap();
file.set_modified(std::time::SystemTime::now() - age).unwrap();
path
};
let crashed = left(".0001-a.md.4242-0.tmp", Duration::from_secs(2 * 60 * 60));
let in_report = left("reports/.week.md.4242-1.tmp", Duration::from_secs(2 * 60 * 60));
let writing = left(".0002-b.md.4243-0.tmp", Duration::ZERO);
let old_id = left(".last-id.tmp", Duration::from_secs(2 * 60 * 60));
let editors =
[".notes.swp.tmp", ".sync.tmp", "reports/.backup.4242-1.tmp", ".notes.md.backup-1.tmp", ".notes.md.4242-x.tmp"]
.map(|name| left(name, Duration::from_secs(2 * 60 * 60)));
drop(Store::at(dir.path()).unwrap().lock().unwrap());
assert!(!crashed.exists() && !in_report.exists() && !old_id.exists());
assert!(writing.exists(), "maybe still being written, by a write made without the lock");
assert!(editors.iter().all(|path| path.exists()), "not kanbrain's");
}
#[test]
fn a_write_that_fails_leaves_the_file_as_it_was_and_no_temporary() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path()).unwrap();
let mut task = Task::new(1, "Fix login".into(), now());
store.save(&mut task).unwrap();
let blocked = dir.path().join("0001-blocked.md");
fs::create_dir(&blocked).unwrap();
assert!(write_file(&blocked, "text").is_err());
let leftovers = |dir: &Path| {
fs::read_dir(dir)
.unwrap()
.filter(|e| e.as_ref().unwrap().file_name().to_string_lossy().ends_with(".tmp"))
.count()
};
assert_eq!(leftovers(dir.path()), 0, "no temporary left behind");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let path = dir.path().join("0001-fix-login.md");
let before = fs::read_to_string(&path).unwrap();
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o555)).unwrap();
task.description = "lost?".into();
let failed = store.save(&mut task);
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap();
if failed.is_err() {
assert_eq!(fs::read_to_string(&path).unwrap(), before, "the file as it was");
}
assert_eq!(leftovers(dir.path()), 0);
}
}
fn failing(kind: ErrorKind, fails: u32) -> (impl FnMut() -> std::io::Result<u32>, std::rc::Rc<std::cell::Cell<u32>>) {
let calls = std::rc::Rc::new(std::cell::Cell::new(0));
let counted = calls.clone();
let op = move || {
counted.set(counted.get() + 1);
if counted.get() <= fails { Err(std::io::Error::from(kind)) } else { Ok(counted.get()) }
};
(op, calls)
}
#[test]
fn a_busy_file_is_tried_again_until_it_is_let_go() {
let (op, calls) = failing(ErrorKind::PermissionDenied, 2);
assert_eq!(retry_while_busy(&[Duration::ZERO; 3], op).unwrap(), 3);
assert_eq!(calls.get(), 3);
}
#[test]
fn a_file_busy_past_the_budget_gives_the_last_error() {
let (op, calls) = failing(ErrorKind::PermissionDenied, 10);
let error = retry_while_busy(&[Duration::ZERO; 3], op).unwrap_err();
assert_eq!(error.kind(), ErrorKind::PermissionDenied);
assert_eq!(calls.get(), 4, "the first try and one per delay");
}
#[test]
fn only_a_busy_file_is_tried_again() {
let (op, calls) = failing(ErrorKind::NotFound, 1);
assert_eq!(retry_while_busy(&[Duration::ZERO; 3], op).unwrap_err().kind(), ErrorKind::NotFound);
assert_eq!(calls.get(), 1);
}
#[test]
fn only_windows_waits_for_a_busy_file() {
let waits = if cfg!(windows) { BUSY_DELAYS.len() } else { 0 };
assert_eq!(busy_delays().len(), waits, "elsewhere a denied permission is a real one");
let total: Duration = BUSY_DELAYS.iter().sum();
assert!(total < Duration::from_millis(1500), "a command waits well under two seconds: {total:?}");
}
#[test]
fn a_sharing_violation_is_a_busy_file_on_windows_only() {
let shared = std::io::Error::from_raw_os_error(32);
assert_eq!(busy(&shared), cfg!(windows));
assert!(busy(&std::io::Error::from(ErrorKind::PermissionDenied)));
assert!(!busy(&std::io::Error::from(ErrorKind::NotFound)));
}
#[cfg(windows)]
#[test]
fn a_file_another_program_holds_for_a_moment_is_replaced_and_deleted_once_it_lets_go() {
use std::os::windows::fs::OpenOptionsExt;
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path()).unwrap();
let mut task = Task::new(1, "Fix".into(), now());
store.save(&mut task).unwrap();
let path = dir.path().join("0001-fix.md");
let open_alone = |time: Duration| {
let file = fs::OpenOptions::new().read(true).share_mode(0).open(&path).unwrap();
std::thread::spawn(move || {
std::thread::sleep(time);
drop(file);
})
};
let closing = open_alone(Duration::from_millis(30));
task.description = "written".into();
store.save(&mut task).unwrap();
closing.join().unwrap();
assert!(fs::read_to_string(&path).unwrap().contains("written"));
let closing = open_alone(Duration::from_millis(30));
store.delete(&task).unwrap();
closing.join().unwrap();
assert!(!path.exists());
write_file(&path, "before").unwrap();
let closing = open_alone(BUSY_DELAYS.iter().sum::<Duration>() + Duration::from_secs(1));
let error = write_file(&path, "after").unwrap_err();
closing.join().unwrap();
let io = error.downcast_ref::<std::io::Error>().unwrap();
assert!(matches!(io.raw_os_error(), Some(5 | 32)), "{io:?}");
assert_eq!(fs::read_to_string(&path).unwrap(), "before");
let temporaries =
fs::read_dir(dir.path()).unwrap().filter(|e| e.as_ref().unwrap().path().extension() == Some("tmp".as_ref()));
assert_eq!(temporaries.count(), 0);
}
#[test]
fn a_skipped_file_says_why_as_load_always_has() {
let dir = tempfile::tempdir().unwrap();
let store = Store::at(dir.path()).unwrap();
fs::write(dir.path().join("README.md"), "# not a task").unwrap();
fs::write(dir.path().join("0002-broken.md"), "# Broken\n\n- id: 2\n\n## Sessions\n\n- nonsense\n").unwrap();
let (tasks, mut skipped) = store.load_entries().unwrap();
assert!(tasks.is_empty());
skipped.sort_by(|a, b| a.file.cmp(&b.file));
assert_eq!(
skipped,
[
Skipped { file: "0002-broken.md".into(), why: SkipReason::Broken("bad session line: nonsense".into()) },
Skipped { file: "README.md".into(), why: SkipReason::NoId },
]
);
#[expect(deprecated, reason = "the strings it still gives are under test")]
let (_, mut said) = store.load().unwrap();
said.sort();
assert_eq!(said, ["0002-broken.md: bad session line: nonsense", "README.md: missing `- id:` line"]);
assert_eq!(skipped.iter().map(ToString::to_string).collect::<Vec<_>>(), said);
}