quinjet 0.0.50

A fast, live, keyboard-first Git source-control interface for the terminal
#[cfg(test)]
use std::path::Path;
use std::path::PathBuf;
use std::{env, fs};

use serde::{Deserialize, Serialize};

#[cfg(test)]
use crate::git::Repository;

mod project_picker;
mod projects;
mod remote;
pub(crate) mod session;

pub(crate) use project_picker::load_collapsed_project_groups;
#[cfg(not(test))]
pub(crate) use project_picker::record_collapsed_project_groups;
#[cfg(test)]
use projects::recent_entries_with_current;
pub(crate) use projects::{
    forget_recent_project, load_recent_projects, load_stored_projects, record_recent_project,
};
pub(crate) use remote::{
    forget_recent_remote, load_recent_remotes, load_recent_ssh_machines,
    load_recent_ssh_machines_with_current, record_recent_remote,
};

const MAX_RECENT_PROJECTS: usize = 20;
const RECENT_PROJECTS_FILE: &str = "recent-projects.json";

#[cfg(test)]
thread_local! {
    static STATE_ROOT_OVERRIDE: std::cell::RefCell<Option<PathBuf>> =
        const { std::cell::RefCell::new(None) };
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RecentEntry {
    path: PathBuf,
    common_dir: PathBuf,
}

fn read_entries() -> Vec<RecentEntry> {
    let Some(path) = recent_projects_path() else {
        return Vec::new();
    };
    let Ok(data) = fs::read(&path) else {
        return Vec::new();
    };
    serde_json::from_slice(&data).unwrap_or_default()
}

fn write_entries(entries: &[RecentEntry]) {
    let Some(path) = recent_projects_path() else {
        return;
    };
    if let Some(parent) = path.parent() {
        drop(fs::create_dir_all(parent));
    }
    let Ok(data) = serde_json::to_vec_pretty(entries) else {
        return;
    };
    let staging = path.with_extension("json.tmp");
    if fs::write(&staging, data).is_ok() {
        drop(fs::rename(staging, path));
    }
}

fn recent_projects_path() -> Option<PathBuf> {
    Some(state_root()?.join(RECENT_PROJECTS_FILE))
}

fn state_root() -> Option<PathBuf> {
    #[cfg(test)]
    {
        let override_root = STATE_ROOT_OVERRIDE.with(|cell| cell.borrow().clone());
        if override_root.is_some() {
            return override_root;
        }
    }
    if let Some(path) = env::var_os("QUINJET_STATE_DIR").filter(|path| !path.is_empty()) {
        return Some(PathBuf::from(path));
    }
    #[cfg(windows)]
    {
        if let Some(path) = env::var_os("LOCALAPPDATA").filter(|path| !path.is_empty()) {
            return Some(PathBuf::from(path).join("Quinjet").join("state"));
        }
    }
    let home = env::var_os("HOME")
        .or_else(|| env::var_os("USERPROFILE"))
        .filter(|path| !path.is_empty())?;
    let home = PathBuf::from(home);
    Some(
        env::var_os("XDG_STATE_HOME")
            .filter(|path| !path.is_empty())
            .map_or_else(|| home.join(".local/state"), PathBuf::from)
            .join("quinjet"),
    )
}

#[cfg(test)]
mod tests {
    use std::process::Command;

    use super::*;
    use crate::git::support::same_path;

    struct StateRootGuard {
        previous: Option<PathBuf>,
    }

    impl StateRootGuard {
        fn new(root: &Path) -> Self {
            let previous = STATE_ROOT_OVERRIDE.with(|cell| cell.replace(Some(root.to_path_buf())));
            Self { previous }
        }
    }

    impl Drop for StateRootGuard {
        fn drop(&mut self) {
            let previous = self.previous.take();
            drop(STATE_ROOT_OVERRIDE.with(|cell| cell.replace(previous)));
        }
    }

    #[test]
    fn invalid_state_documents_are_treated_as_empty() {
        let (state, _guard) = isolated_state();
        let documents: [&[u8]; 4] = [b"", b"{", br#"{"path":"repo","commonDir":"git"}"#, b"null"];
        for document in documents {
            write_state_bytes(state.path(), document);
            assert_eq!(read_entries(), Vec::<RecentEntry>::new());
        }
    }

    #[test]
    fn partially_valid_state_is_treated_as_empty() {
        let (state, _guard) = isolated_state();
        write_state_bytes(
            state.path(),
            br#"[{"path":"valid","commonDir":"common"},{"path":3,"commonDir":"broken"}]"#,
        );
        assert_eq!(read_entries(), Vec::<RecentEntry>::new());
    }

    #[test]
    fn current_repository_is_inserted_first_and_deduplicated() {
        let (_state, _guard) = isolated_state();
        let repo = git_repo();
        let common_dir = repository_common_dir(repo.path());
        let first = fake_entry(1);
        let last = fake_entry(2);
        let stored = vec![
            first.clone(),
            RecentEntry {
                path: PathBuf::from("old-checkout"),
                common_dir: common_dir.clone(),
            },
            last.clone(),
            RecentEntry {
                path: PathBuf::from("older-checkout"),
                common_dir: common_dir.clone(),
            },
        ];
        write_entries(&stored);

        let entries = recent_entries_with_current(repo.path());

        assert_eq!(entries.len(), 3);
        assert_eq!(entries[0].common_dir, common_dir);
        assert!(same_path(&entries[0].path, repo.path()));
        assert_eq!(entries[1], first);
        assert_eq!(entries[2], last);
        assert_eq!(read_entries(), stored);
    }

    #[test]
    fn recording_preserves_order_and_caps_entries() {
        let (_state, _guard) = isolated_state();
        let repo = git_repo();
        let older = (0..MAX_RECENT_PROJECTS + 5)
            .map(fake_entry)
            .collect::<Vec<_>>();
        write_entries(&older);

        record_recent_project(repo.path());

        let entries = read_entries();
        assert_eq!(entries.len(), MAX_RECENT_PROJECTS);
        assert!(same_path(&entries[0].path, repo.path()));
        assert_eq!(
            entries.get(1..),
            older.get(..MAX_RECENT_PROJECTS.saturating_sub(1))
        );
    }

    #[test]
    fn recording_non_repository_paths_preserves_state() {
        let (_state, _guard) = isolated_state();
        let entries = vec![fake_entry(1), fake_entry(2)];
        write_entries(&entries);
        let plain = tempfile::tempdir().unwrap();

        record_recent_project(plain.path());
        record_recent_project(&plain.path().join("deleted"));

        assert_eq!(read_entries(), entries);
    }

    #[test]
    fn loading_skips_deleted_and_non_repository_entries() {
        let (_state, _guard) = isolated_state();
        let active = git_repo();
        let active_common = repository_common_dir(active.path());
        let deleted = git_repo();
        let deleted_entry = RecentEntry {
            path: deleted.path().to_path_buf(),
            common_dir: repository_common_dir(deleted.path()),
        };
        drop(deleted);
        let plain = tempfile::tempdir().unwrap();
        let session = tempfile::tempdir().unwrap();
        write_entries(&[
            RecentEntry {
                path: session.path().join("missing-checkout"),
                common_dir: active_common.clone(),
            },
            RecentEntry {
                path: active.path().to_path_buf(),
                common_dir: active_common.clone(),
            },
            deleted_entry,
            RecentEntry {
                path: plain.path().to_path_buf(),
                common_dir: plain.path().to_path_buf(),
            },
        ]);

        let groups = load_recent_projects(session.path());

        assert_eq!(groups.len(), 1);
        assert_eq!(groups[0].common_dir, active_common);
        assert_eq!(groups[0].worktrees.len(), 1);
        assert!(same_path(&groups[0].worktrees[0].path, active.path()));
    }

    #[test]
    fn forgetting_existing_missing_and_only_entries_is_stable() {
        let (_state, _guard) = isolated_state();
        let first = fake_entry(1);
        let only = fake_entry(2);
        write_entries(&[first.clone(), only.clone()]);

        forget_recent_project(&first.common_dir);
        assert_eq!(read_entries(), vec![only.clone()]);
        forget_recent_project(Path::new("missing-common-dir"));
        assert_eq!(read_entries(), vec![only.clone()]);
        forget_recent_project(&only.common_dir);
        assert_eq!(read_entries(), Vec::<RecentEntry>::new());
    }

    #[test]
    fn writing_creates_the_state_directory() {
        let base = tempfile::tempdir().unwrap();
        let root = base.path().join("nested").join("state");
        let _guard = StateRootGuard::new(&root);
        let entries = vec![fake_entry(1)];

        write_entries(&entries);

        assert!(root.is_dir());
        assert_eq!(read_entries(), entries);
        assert!(!root.join("recent-projects.json.tmp").exists());
    }

    #[test]
    fn regular_file_state_root_rejects_writes() {
        let base = tempfile::tempdir().unwrap();
        let root = base.path().join("state");
        fs::write(&root, "sentinel").unwrap();
        let _guard = StateRootGuard::new(&root);

        write_entries(&[fake_entry(1)]);

        assert_eq!(fs::read_to_string(&root).unwrap(), "sentinel");
        assert_eq!(read_entries(), Vec::<RecentEntry>::new());
    }

    #[test]
    fn unwritable_staging_path_preserves_existing_state() {
        let (state, _guard) = isolated_state();
        let original = vec![fake_entry(1)];
        write_entries(&original);
        fs::create_dir_all(state.path().join("recent-projects.json.tmp")).unwrap();

        write_entries(&[fake_entry(2)]);

        assert_eq!(read_entries(), original);
    }

    #[test]
    fn records_one_project_per_common_directory() {
        let (_state, _guard) = isolated_state();
        let repo = git_repo();
        let linked_root = tempfile::tempdir().unwrap();
        let linked = linked_root.path().join("topic");
        let linked_display = linked.display().to_string();
        run_git(
            repo.path(),
            &["worktree", "add", "-b", "topic", &linked_display],
        );
        record_recent_project(repo.path());
        record_recent_project(&linked);
        let entries = read_entries();
        assert_eq!(entries.len(), 1);
        let groups = load_recent_projects(&linked);
        assert_eq!(groups.len(), 1);
        assert_eq!(groups[0].worktrees.len(), 2);
        assert!(
            groups[0]
                .worktrees
                .iter()
                .any(|tree| tree.current && same_path(&tree.path, &linked))
        );
        forget_recent_project(&entries[0].common_dir);
        assert_eq!(read_entries(), Vec::<RecentEntry>::new());
    }

    #[test]
    fn stored_projects_load_without_a_current_repository() {
        let (_state, _guard) = isolated_state();
        let repo = git_repo();
        record_recent_project(repo.path());

        let groups = load_stored_projects();

        assert_eq!(groups.len(), 1);
        assert!(same_path(&groups[0].worktrees[0].path, repo.path()));
        assert!(groups[0].worktrees[0].current);
    }

    fn isolated_state() -> (tempfile::TempDir, StateRootGuard) {
        let state = tempfile::tempdir().unwrap();
        let guard = StateRootGuard::new(state.path());
        (state, guard)
    }

    fn fake_entry(index: usize) -> RecentEntry {
        RecentEntry {
            path: PathBuf::from(format!("project-{index}")),
            common_dir: PathBuf::from(format!("common-{index}")),
        }
    }

    fn repository_common_dir(path: &Path) -> PathBuf {
        Repository::discover(path)
            .unwrap()
            .git_common_dir()
            .unwrap()
    }

    fn write_state_bytes(state: &Path, data: &[u8]) {
        fs::write(state.join(RECENT_PROJECTS_FILE), data).unwrap();
    }

    fn git_repo() -> tempfile::TempDir {
        let path = tempfile::tempdir().unwrap();
        run_git(path.path(), &["init", "--initial-branch=main"]);
        fs::write(path.path().join("README.md"), "test\n").unwrap();
        run_git(path.path(), &["add", "README.md"]);
        run_git(
            path.path(),
            &[
                "-c",
                "user.name=Quinjet Test",
                "-c",
                "user.email=quinjet@example.com",
                "commit",
                "--message=initial",
            ],
        );
        path
    }

    fn run_git(path: &Path, args: &[&str]) {
        let output = Command::new("git")
            .arg("-C")
            .arg(path)
            .args(args)
            .env("LC_ALL", "C")
            .output()
            .unwrap();
        assert!(
            output.status.success(),
            "git failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }
}