quinjet 0.0.47

A fast, live, keyboard-first Git source-control interface for the terminal
use std::fs;
use std::path::Path;

use serde::{Deserialize, Serialize};

use super::{MAX_RECENT_PROJECTS, state_root};
use crate::ssh::{MAX_SSH_MACHINES, SshMachine};

const RECENT_REMOTES_FILE: &str = "recent-remotes.json";

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RecentRemote {
    pub target: String,
    pub folder: String,
    #[serde(default = "initial_uses")]
    pub uses: u64,
}

const fn initial_uses() -> u64 {
    1
}

pub(crate) fn record_recent_remote(target: &str, folder: &Path) {
    let folder = folder.to_string_lossy().into_owned();
    let mut entries = load_recent_remotes();
    let uses = entries
        .iter()
        .find(|entry| entry.target == target && entry.folder == folder)
        .map_or(1, |entry| entry.uses.saturating_add(1));
    entries.retain(|entry| entry.target != target || entry.folder != folder);
    entries.insert(
        0,
        RecentRemote {
            target: target.to_owned(),
            folder,
            uses,
        },
    );
    entries.truncate(MAX_RECENT_PROJECTS);
    write_entries(&entries);
}

pub(crate) fn load_recent_ssh_machines() -> Vec<SshMachine> {
    grouped_ssh_machines()
}

pub(crate) fn load_recent_ssh_machines_with_current(
    current: &str,
    folder: &Path,
) -> Vec<SshMachine> {
    let mut machines = grouped_ssh_machines();
    if !machines.iter().any(|machine| machine.target == current) {
        machines.push(SshMachine {
            target: current.to_owned(),
            folder: folder.to_path_buf(),
            accessible: true,
            uses: 0,
            local: false,
        });
        machines.sort_by_key(|machine| std::cmp::Reverse(machine.uses));
    }
    if let Some(current_index) = machines
        .iter()
        .position(|machine| machine.target == current)
        .filter(|index| *index >= MAX_SSH_MACHINES)
    {
        let current_machine = machines.remove(current_index);
        machines.insert(MAX_SSH_MACHINES.saturating_sub(1), current_machine);
    }
    machines.truncate(MAX_SSH_MACHINES);
    machines
}

fn grouped_ssh_machines() -> Vec<SshMachine> {
    let mut machines = Vec::<SshMachine>::new();
    for entry in load_recent_remotes() {
        if let Some(machine) = machines
            .iter_mut()
            .find(|machine| machine.target == entry.target)
        {
            machine.uses = machine.uses.saturating_add(entry.uses);
            let folder = Path::new(&entry.folder);
            if machine.folder.is_relative() && folder.is_absolute() {
                machine.folder = folder.to_path_buf();
            }
        } else {
            machines.push(SshMachine {
                target: entry.target,
                folder: Path::new(&entry.folder).to_path_buf(),
                accessible: false,
                uses: entry.uses,
                local: false,
            });
        }
    }
    machines.sort_by_key(|machine| std::cmp::Reverse(machine.uses));
    machines.truncate(MAX_SSH_MACHINES);
    machines
}

pub(crate) fn forget_recent_remote(target: &str, folder: Option<&str>) {
    let mut entries = load_recent_remotes();
    entries.retain(|entry| {
        entry.target != target || folder.is_some_and(|folder| entry.folder != folder)
    });
    write_entries(&entries);
}

pub(crate) fn load_recent_remotes() -> Vec<RecentRemote> {
    let Some(path) = state_root().map(|root| root.join(RECENT_REMOTES_FILE)) 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: &[RecentRemote]) {
    let Some(path) = state_root().map(|root| root.join(RECENT_REMOTES_FILE)) 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));
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct StateRootGuard {
        previous: Option<std::path::PathBuf>,
    }

    impl StateRootGuard {
        fn new(root: &Path) -> Self {
            let previous = super::super::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(super::super::STATE_ROOT_OVERRIDE.with(|cell| cell.replace(previous)));
        }
    }

    #[test]
    fn remote_recents_deduplicate_reorder_and_forget() {
        let state = tempfile::tempdir().unwrap();
        let _guard = StateRootGuard::new(state.path());
        record_recent_remote("first", Path::new("/one"));
        record_recent_remote("second", Path::new("/two"));
        record_recent_remote("first", Path::new("/one"));
        assert_eq!(
            load_recent_remotes(),
            vec![
                RecentRemote {
                    target: "first".to_owned(),
                    folder: "/one".to_owned(),
                    uses: 2,
                },
                RecentRemote {
                    target: "second".to_owned(),
                    folder: "/two".to_owned(),
                    uses: 1,
                },
            ]
        );
        forget_recent_remote("first", Some("/other"));
        assert_eq!(load_recent_remotes().len(), 2);
        forget_recent_remote("first", Some("/one"));
        assert_eq!(load_recent_remotes().len(), 1);
        forget_recent_remote("second", None);
        assert_eq!(load_recent_remotes(), Vec::new());
    }

    #[test]
    fn machines_are_grouped_and_sorted_by_total_usage() {
        let state = tempfile::tempdir().unwrap();
        let _guard = StateRootGuard::new(state.path());
        record_recent_remote("occasional", Path::new("/one"));
        record_recent_remote("frequent", Path::new("/first"));
        record_recent_remote("frequent", Path::new("/second"));
        record_recent_remote("frequent", Path::new("/first"));
        let machines = load_recent_ssh_machines_with_current("new", Path::new("/repo"));
        assert_eq!(machines[0].target, "frequent");
        assert_eq!(machines[0].uses, 3);
        assert_eq!(machines[0].folder, Path::new("/first"));
        assert_eq!(machines[1].target, "occasional");
        assert_eq!(machines[1].uses, 1);
        assert_eq!(machines[2].target, "new");
        assert_eq!(machines[2].uses, 0);
    }

    #[test]
    fn machine_folder_prefers_an_absolute_recent_project() {
        let state = tempfile::tempdir().unwrap();
        let _guard = StateRootGuard::new(state.path());
        let project = state.path().join("work").join("project");
        record_recent_remote("remote", &project);
        record_recent_remote("remote", Path::new("."));

        let machines = load_recent_ssh_machines();

        assert_eq!(machines[0].folder, project);
        assert_eq!(machines[0].uses, 2);
    }

    #[test]
    fn current_machine_remains_visible_at_the_limit() {
        let state = tempfile::tempdir().unwrap();
        let _guard = StateRootGuard::new(state.path());
        for index in 0..MAX_SSH_MACHINES {
            record_recent_remote(&format!("host-{index}"), Path::new("/repo"));
        }
        let machines = load_recent_ssh_machines_with_current("new-current", Path::new("/current"));
        assert_eq!(machines.len(), MAX_SSH_MACHINES);
        assert!(
            machines
                .iter()
                .any(|machine| machine.target == "new-current")
        );
        assert_eq!(
            machines.last().map(|machine| machine.target.as_str()),
            Some("new-current")
        );
    }

    #[test]
    fn local_machine_list_contains_only_recorded_ssh_targets() {
        let state = tempfile::tempdir().unwrap();
        let _guard = StateRootGuard::new(state.path());
        record_recent_remote("remote-host", Path::new("/repo"));
        assert_eq!(
            load_recent_ssh_machines()
                .iter()
                .map(|machine| machine.target.as_str())
                .collect::<Vec<_>>(),
            vec!["remote-host"]
        );
    }
}