quinjet 0.0.50

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};

pub(super) const PROJECT_PICKER_FILE: &str = "project-picker.json";

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProjectPickerState {
    collapsed: Vec<PathBuf>,
}

pub(crate) fn load_collapsed_project_groups() -> HashSet<PathBuf> {
    let Some(path) = super::state_root().map(|root| root.join(PROJECT_PICKER_FILE)) else {
        return HashSet::new();
    };
    let Ok(data) = fs::read(path) else {
        return HashSet::new();
    };
    serde_json::from_slice::<ProjectPickerState>(&data)
        .map(|state| state.collapsed.into_iter().collect())
        .unwrap_or_default()
}

pub(crate) fn record_collapsed_project_groups(collapsed: &HashSet<PathBuf>) {
    let Some(path) = super::state_root().map(|root| root.join(PROJECT_PICKER_FILE)) else {
        return;
    };
    if let Some(parent) = path.parent() {
        drop(fs::create_dir_all(parent));
    }
    let mut collapsed = collapsed.iter().cloned().collect::<Vec<_>>();
    collapsed.sort();
    let Ok(data) = serde_json::to_vec_pretty(&ProjectPickerState { collapsed }) 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<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 folds_round_trip_and_ignore_invalid_state() {
        let state = tempfile::tempdir().unwrap();
        let _guard = StateRootGuard::new(state.path());
        let collapsed = HashSet::from([
            PathBuf::from("/work/one/.git"),
            PathBuf::from("/work/two/.git"),
        ]);

        record_collapsed_project_groups(&collapsed);

        assert_eq!(load_collapsed_project_groups(), collapsed);
        fs::write(state.path().join(PROJECT_PICKER_FILE), b"{").unwrap();
        assert!(load_collapsed_project_groups().is_empty());
    }
}