trnovel 0.18.3

Terminal reader for novel
use super::*;
use crate::config::ConfigStore;

const HISTORY: &str = r#"{"histories":[],"local_path":null}"#;
const LOCAL: &str = r#"{"chapters":[],"encoding":"UTF-8","current_chapter":1,"line_percent":0.0,"path":"book.txt"}"#;

fn write(home: &Path, relative: &str, bytes: &str) {
    let path = home.join(relative);
    fs::create_dir_all(path.parent().unwrap()).unwrap();
    fs::write(path, bytes).unwrap();
}

#[test]
fn status_transitions_cover_pending_done_and_declined() {
    let home = tempfile::tempdir().unwrap();
    let paths = AppPaths::from_home(home.path());
    assert_eq!(status_at(&paths), Status::NotNeeded);
    write(home.path(), ".novel/history.json", HISTORY);
    assert_eq!(status_at(&paths), Status::Pending);
    // 拒绝标记优先于重新询问。
    publish_declined(&paths).unwrap();
    assert_eq!(status_at(&paths), Status::Declined);
    fs::remove_file(paths.root().join(DECLINED)).unwrap();
    assert_eq!(status_at(&paths), Status::Pending);
    assert!(migrate(&paths).unwrap().is_empty());
    assert_eq!(status_at(&paths), Status::Done);
    // clear 不触碰根级标记:拒绝与完成决定都存活。
    clear(&paths).unwrap();
    assert_eq!(status_at(&paths), Status::Done);
    publish_declined(&paths).unwrap();
    clear(&paths).unwrap();
    assert_eq!(status_at(&paths), Status::Done);
    assert!(paths.root().join(DECLINED).is_file());
}

#[test]
fn survey_counts_only_regular_files_and_pending_entries() {
    let home = tempfile::tempdir().unwrap();
    let paths = AppPaths::from_home(home.path());
    write(home.path(), ".novel/history.json", HISTORY);
    write(home.path(), ".novel/local/a.json", LOCAL);
    write(home.path(), ".novel/tts/ignored.json", LOCAL);
    let (items, files, bytes) = pending_survey(&paths);
    assert_eq!(items, 2);
    assert_eq!(files, 2);
    assert_eq!(bytes, (HISTORY.len() + LOCAL.len()) as u64);
}

#[test]
fn events_report_counts_without_per_file_paths() {
    let home = tempfile::tempdir().unwrap();
    let paths = AppPaths::from_home(home.path());
    write(home.path(), ".novel/history.json", HISTORY);
    write(home.path(), ".novel/local/a.json", LOCAL);
    write(
        home.path(),
        ".trnovel/data/history.json",
        "new history wins",
    );
    let mut scan = None;
    let mut copied = 0;
    let mut kept = 0;
    let mut skipped = 0;
    let outcome = run(&paths, &AtomicBool::new(false), &mut |event| match event {
        Event::Scan { .. } => scan = Some(event),
        Event::Copied { .. } => copied += 1,
        Event::Kept => kept += 1,
        Event::Skipped => skipped += 1,
        Event::Item { .. } => {}
    });
    assert_eq!(outcome, Outcome::Done);
    assert!(matches!(
        scan,
        Some(Event::Scan {
            items: 2,
            files: 2,
            ..
        })
    ));
    assert_eq!(copied, 1);
    assert_eq!(kept, 1);
    assert_eq!(skipped, 0);
    assert_eq!(
        fs::read_to_string(paths.history()).unwrap(),
        "new history wins"
    );
    assert!(paths.local().join("a.json").is_file());
}

#[test]
fn cooperative_cancel_keeps_partial_copies_retryable() {
    let home = tempfile::tempdir().unwrap();
    let paths = AppPaths::from_home(home.path());
    for index in 0..8 {
        write(home.path(), &format!(".novel/local/{index}.json"), LOCAL);
    }
    // 首个文件拷贝后立即取消:停止后续拷贝、不发布完成标记。
    let cancel = AtomicBool::new(false);
    let outcome = run(&paths, &cancel, &mut |event| {
        if matches!(event, Event::Copied { .. }) {
            cancel.store(true, Ordering::SeqCst);
        }
    });
    assert_eq!(outcome, Outcome::Cancelled);
    assert!(!paths.root().join(MARKER).exists());
    let copied = fs::read_dir(paths.local()).unwrap().count();
    assert!(
        (1..8).contains(&copied),
        "partial copy expected, got {copied}"
    );
    // 续迁:已拷贝文件按「保留已有」计数,不重复拷贝,最终完成。
    let mut kept = 0;
    assert_eq!(
        run(&paths, &AtomicBool::new(false), &mut |event| {
            if matches!(event, Event::Kept) {
                kept += 1;
            }
        }),
        Outcome::Done
    );
    assert_eq!(kept, copied);
    assert!(paths.root().join(MARKER).is_file());
    assert_eq!(fs::read_dir(paths.local()).unwrap().count(), 8);
}

#[test]
fn startup_imports_legacy_fields_and_preserves_old_files() {
    let home = tempfile::tempdir().unwrap();
    let paths = AppPaths::from_home(home.path());
    write(
        home.path(),
        ".novel/appearance.json",
        r#"{"themeSlug":"dracula","background":"theme","showHeader":false}"#,
    );
    write(
        home.path(),
        ".novel/reader-display.json",
        r#"{"followTts":false,"showTitle":false,"pageOverlap":99,"paragraphSpacing":true}"#,
    );
    write(home.path(), ".novel/browser_assist.on", "on");
    write(home.path(), ".novel/history.json", HISTORY);
    write(home.path(), ".novel/local/a.json", LOCAL);
    write(
        home.path(),
        ".novel/browser-profile/Cookies",
        "binary profile",
    );
    assert!(migrate(&paths).unwrap().is_empty());
    let config = ConfigStore::new(paths.config()).load().unwrap();
    assert_eq!(config.appearance.theme_slug, "dracula");
    assert!(!config.appearance.show_header);
    assert!(!config.reader.follow_tts);
    assert!(!config.reader.show_title);
    assert_eq!(config.reader.page_overlap, 10);
    assert!(config.reader.paragraph_spacing);
    assert!(config.browser.always_allow);
    assert_eq!(
        fs::read(paths.local().join("a.json")).unwrap(),
        fs::read(home.path().join(".novel/local/a.json")).unwrap()
    );
    assert_eq!(
        fs::read(paths.browser_profile().join("Cookies")).unwrap(),
        b"binary profile"
    );
    assert!(paths.root().join(MARKER).is_file());
    assert!(home.path().join(".novel/appearance.json").is_file());
    paths.clear().unwrap();
    assert!(migrate(&paths).unwrap().is_empty());
    assert!(!paths.history().exists());
    assert!(!paths.local().exists());
    assert!(paths.root().join(MARKER).is_file());
}

#[test]
fn existing_new_configuration_and_files_win_including_invalid_toml() {
    let home = tempfile::tempdir().unwrap();
    let paths = AppPaths::from_home(home.path());
    write(
        home.path(),
        ".novel/appearance.json",
        "corrupt legacy preference",
    );
    write(home.path(), ".novel/history.json", "corrupt legacy history");
    write(home.path(), ".novel/local/only-old.json", LOCAL);
    write(home.path(), ".trnovel/config.toml", "[do not rewrite");
    write(
        home.path(),
        ".trnovel/data/history.json",
        "new history wins",
    );
    assert!(migrate(&paths).unwrap().is_empty());
    assert_eq!(
        fs::read_to_string(paths.config()).unwrap(),
        "[do not rewrite"
    );
    assert_eq!(
        fs::read_to_string(paths.history()).unwrap(),
        "new history wins"
    );
    assert!(paths.local().join("only-old.json").exists());
}

#[test]
fn corrupt_preferences_and_history_are_reported_without_publication_or_source_deletion() {
    let home = tempfile::tempdir().unwrap();
    let paths = AppPaths::from_home(home.path());
    write(home.path(), ".novel/appearance.json", "invalid JSON");
    write(home.path(), ".novel/history.json", "invalid JSON");
    write(home.path(), ".novel/keybindings.toml", "valid copied bytes");
    let messages = migrate(&paths).unwrap();
    assert_eq!(messages.len(), 2);
    assert!(!paths.config().exists());
    assert!(!paths.history().exists());
    assert!(!paths.root().join(MARKER).exists());
    assert_eq!(
        fs::read_to_string(home.path().join(".novel/history.json")).unwrap(),
        "invalid JSON"
    );
    assert!(paths.keybindings().exists());
    write(
        home.path(),
        ".novel/appearance.json",
        r#"{"themeSlug":"dracula","background":"terminal"}"#,
    );
    write(home.path(), ".novel/history.json", HISTORY);
    assert!(migrate(&paths).unwrap().is_empty());
    assert!(paths.root().join(MARKER).exists());
}

#[test]
fn clear_after_partial_migration_prevents_reimport_but_keeps_preferences_retryable() {
    let home = tempfile::tempdir().unwrap();
    let paths = AppPaths::from_home(home.path());
    write(home.path(), ".novel/appearance.json", "invalid JSON");
    write(home.path(), ".novel/history.json", HISTORY);
    write(home.path(), ".novel/local/book.json", LOCAL);
    assert_eq!(migrate(&paths).unwrap().len(), 1);
    clear(&paths).unwrap();
    assert!(paths.root().join(CLEARED).is_file());
    assert_eq!(migrate(&paths).unwrap().len(), 1);
    assert!(!paths.history().exists());
    assert!(!paths.local().exists());
    write(
        home.path(),
        ".novel/appearance.json",
        r#"{"themeSlug":"dracula","background":"terminal"}"#,
    );
    assert!(migrate(&paths).unwrap().is_empty());
    assert!(paths.config().exists());
    assert!(!paths.history().exists());
}

#[test]
fn malformed_cache_does_not_prevent_other_files_in_the_same_directory() {
    let home = tempfile::tempdir().unwrap();
    let paths = AppPaths::from_home(home.path());
    write(home.path(), ".novel/local/bad.json", "{}");
    write(home.path(), ".novel/local/good.json", LOCAL);
    assert_eq!(migrate(&paths).unwrap().len(), 1);
    assert!(!paths.local().join("bad.json").exists());
    assert!(paths.local().join("good.json").is_file());
}

#[test]
fn missing_legacy_home_creates_nothing_and_concurrent_migrations_do_not_clobber() {
    let home = tempfile::tempdir().unwrap();
    let paths = AppPaths::from_home(home.path());
    assert!(migrate(&paths).unwrap().is_empty());
    assert!(!paths.root().exists());
    write(home.path(), ".novel/history.json", HISTORY);
    std::thread::scope(|scope| {
        for _ in 0..4 {
            let paths = &paths;
            scope.spawn(move || assert!(migrate(paths).unwrap().is_empty()));
        }
    });
    assert_eq!(fs::read_to_string(paths.history()).unwrap(), HISTORY);
}

#[cfg(unix)]
#[test]
fn source_links_are_skipped_and_destination_directory_links_are_not_followed() {
    use std::os::unix::fs::symlink;
    let home = tempfile::tempdir().unwrap();
    let external = tempfile::tempdir().unwrap();
    let paths = AppPaths::from_home(home.path());
    write(home.path(), ".novel/local/book.json", LOCAL);
    symlink(external.path(), home.path().join(".novel/browser-profile")).unwrap();
    fs::create_dir_all(paths.root()).unwrap();
    symlink(external.path(), paths.data()).unwrap();
    assert!(!migrate(&paths).unwrap().is_empty());
    assert!(!external.path().join("local").exists());
    fs::remove_file(paths.data()).unwrap();
    assert!(migrate(&paths).unwrap().is_empty());
    assert!(!paths.browser_profile().exists());
    assert!(paths.local().join("book.json").exists());
}

#[cfg(unix)]
#[test]
fn legacy_home_link_is_not_followed() {
    let home = tempfile::tempdir().unwrap();
    let external = tempfile::tempdir().unwrap();
    std::os::unix::fs::symlink(external.path(), home.path().join(".novel")).unwrap();
    let paths = AppPaths::from_home(home.path());
    assert!(migrate(&paths).is_err());
    assert!(!paths.root().exists());
}

#[cfg(unix)]
#[test]
fn unreadable_legacy_data_is_preserved_and_reported_for_retry() {
    use std::os::unix::fs::PermissionsExt;
    let home = tempfile::tempdir().unwrap();
    let paths = AppPaths::from_home(home.path());
    write(home.path(), ".novel/history.json", HISTORY);
    let source = home.path().join(".novel/history.json");
    let permissions = fs::metadata(&source).unwrap().permissions();
    fs::set_permissions(&source, fs::Permissions::from_mode(0o000)).unwrap();
    let unreadable = File::open(&source).is_err();
    let result = migrate(&paths);
    fs::set_permissions(&source, permissions).unwrap();
    if unreadable {
        assert_eq!(result.unwrap().len(), 1);
        assert!(!paths.history().exists());
        assert!(!paths.root().join(MARKER).exists());
        assert!(migrate(&paths).unwrap().is_empty());
    }
    assert_eq!(fs::read_to_string(&source).unwrap(), HISTORY);
}