quinjet 0.0.48

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

use serde::{Deserialize, Serialize};

const PROJECT_SESSION_FILE: &str = "project-session.json";

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProjectSession {
    pub(crate) roots: Vec<PathBuf>,
    pub(crate) active: Option<PathBuf>,
}

pub(crate) fn load_project_session() -> Option<ProjectSession> {
    let path = super::state_root()?.join(PROJECT_SESSION_FILE);
    let data = fs::read(path).ok()?;
    let session = serde_json::from_slice::<ProjectSession>(&data).ok()?;
    normalize(session)
}

pub(crate) fn record_project_session(session: ProjectSession) {
    let Some(session) = normalize(session) else {
        return;
    };
    let Some(path) = super::state_root().map(|root| root.join(PROJECT_SESSION_FILE)) else {
        return;
    };
    if let Some(parent) = path.parent() {
        drop(fs::create_dir_all(parent));
    }
    let Ok(data) = serde_json::to_vec_pretty(&session) else {
        return;
    };
    let staging = path.with_extension("json.tmp");
    if fs::write(&staging, data).is_ok() {
        drop(fs::rename(staging, path));
    }
}

fn normalize(mut session: ProjectSession) -> Option<ProjectSession> {
    let mut seen = HashSet::new();
    session.roots.retain(|root| seen.insert(root.clone()));
    session.roots.truncate(super::MAX_RECENT_PROJECTS);
    if session
        .active
        .as_ref()
        .is_some_and(|active| !session.roots.iter().any(|root| root == active))
    {
        session.active = None;
    }
    (!session.roots.is_empty()).then_some(session)
}

#[cfg(test)]
mod tests {
    use tempfile::TempDir;

    use super::*;

    struct StateRootGuard {
        previous: Option<PathBuf>,
    }

    impl StateRootGuard {
        fn new(root: &std::path::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 project_session_round_trips() {
        let state = TempDir::new().expect("state directory");
        let _guard = StateRootGuard::new(state.path());
        let session = ProjectSession {
            roots: vec![PathBuf::from("/one"), PathBuf::from("/two")],
            active: Some(PathBuf::from("/two")),
        };

        record_project_session(session.clone());

        assert_eq!(load_project_session(), Some(session));
    }

    #[test]
    fn project_session_deduplicates_roots_and_rejects_unknown_active_root() {
        let state = TempDir::new().expect("state directory");
        let _guard = StateRootGuard::new(state.path());
        record_project_session(ProjectSession {
            roots: vec![PathBuf::from("/one"), PathBuf::from("/one")],
            active: Some(PathBuf::from("/missing")),
        });

        assert_eq!(
            load_project_session(),
            Some(ProjectSession {
                roots: vec![PathBuf::from("/one")],
                active: None,
            })
        );
    }

    #[test]
    fn empty_and_invalid_sessions_are_ignored() {
        let state = TempDir::new().expect("state directory");
        let _guard = StateRootGuard::new(state.path());
        record_project_session(ProjectSession {
            roots: Vec::new(),
            active: None,
        });
        assert_eq!(load_project_session(), None);

        fs::write(state.path().join(PROJECT_SESSION_FILE), b"{").expect("invalid state");
        assert_eq!(load_project_session(), None);
    }
}