wtui 0.1.2

A terminal UI and CLI for managing Git worktrees across repositories
use std::collections::BTreeSet;
#[cfg(not(test))]
use std::env;
use std::fs;
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use tempfile::NamedTempFile;
use thiserror::Error;

use crate::model::CanonicalPullRequestId;

#[cfg(not(test))]
pub const STATE_PATH_ENV: &str = "WT_STATE_PATH";
const STATE_VERSION: u32 = 1;

#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct PersistentState {
    pub version: u32,
    #[serde(default)]
    pub backburner: BTreeSet<CanonicalPullRequestId>,
}

impl Default for PersistentState {
    fn default() -> Self {
        Self {
            version: STATE_VERSION,
            backburner: BTreeSet::new(),
        }
    }
}

#[derive(Debug, Error)]
pub enum StateError {
    #[error("cannot read state {path}: {source}")]
    Read { path: PathBuf, source: io::Error },
    #[error("cannot parse state {path}: {source}")]
    Parse {
        path: PathBuf,
        source: serde_json::Error,
    },
    #[error("state schema version {found} is newer than supported version {supported}")]
    FutureVersion { found: u32, supported: u32 },
    #[error("cannot write state {path}: {source}")]
    Write { path: PathBuf, source: io::Error },
    #[error("cannot encode state: {0}")]
    Encode(#[from] serde_json::Error),
}

pub fn path(catalog_path: &Path) -> PathBuf {
    #[cfg(test)]
    {
        catalog_path.with_extension("state.json")
    }
    #[cfg(not(test))]
    {
        if let Some(path) = env::var_os(STATE_PATH_ENV) {
            return PathBuf::from(path);
        }
        env::var_os("XDG_STATE_HOME")
            .map(PathBuf::from)
            .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")))
            .map(|root| root.join("wt/state.json"))
            .unwrap_or_else(|| catalog_path.with_extension("state.json"))
    }
}

pub fn load(path: &Path) -> Result<PersistentState, StateError> {
    let contents = match fs::read(path) {
        Ok(contents) => contents,
        Err(error) if error.kind() == io::ErrorKind::NotFound => {
            return Ok(PersistentState::default());
        }
        Err(source) => {
            return Err(StateError::Read {
                path: path.to_owned(),
                source,
            });
        }
    };
    let state = serde_json::from_slice::<PersistentState>(&contents).map_err(|source| {
        StateError::Parse {
            path: path.to_owned(),
            source,
        }
    })?;
    if state.version > STATE_VERSION {
        return Err(StateError::FutureVersion {
            found: state.version,
            supported: STATE_VERSION,
        });
    }
    Ok(state)
}

pub fn save(path: &Path, state: &PersistentState) -> Result<(), StateError> {
    let parent = path.parent().unwrap_or_else(|| Path::new("."));
    fs::create_dir_all(parent).map_err(|source| StateError::Write {
        path: path.to_owned(),
        source,
    })?;
    let temporary = NamedTempFile::new_in(parent).map_err(|source| StateError::Write {
        path: path.to_owned(),
        source,
    })?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        temporary
            .as_file()
            .set_permissions(fs::Permissions::from_mode(0o600))
            .map_err(|source| StateError::Write {
                path: path.to_owned(),
                source,
            })?;
    }
    let mut writer = BufWriter::new(temporary.as_file());
    serde_json::to_writer_pretty(&mut writer, state)?;
    writer.flush().map_err(|source| StateError::Write {
        path: path.to_owned(),
        source,
    })?;
    drop(writer);
    temporary
        .as_file()
        .sync_all()
        .map_err(|source| StateError::Write {
            path: path.to_owned(),
            source,
        })?;
    temporary.persist(path).map_err(|error| StateError::Write {
        path: path.to_owned(),
        source: error.error,
    })?;
    fs::File::open(parent)
        .and_then(|directory| directory.sync_all())
        .map_err(|source| StateError::Write {
            path: path.to_owned(),
            source,
        })?;
    Ok(())
}

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

    fn identity(host: &str) -> CanonicalPullRequestId {
        CanonicalPullRequestId {
            repository: GitHubRepositoryIdentity::canonical(host, "team", "project"),
            number: 7,
        }
    }

    #[test]
    fn round_trip_is_atomic_host_aware_and_creates_parents() {
        let directory = tempfile::tempdir().unwrap();
        let path = directory.path().join("nested/state.json");
        let state = PersistentState {
            version: STATE_VERSION,
            backburner: BTreeSet::from([identity("github.com"), identity("github.example.com")]),
        };
        save(&path, &state).unwrap();
        assert_eq!(load(&path).unwrap(), state);
        let replacement = PersistentState::default();
        save(&path, &replacement).unwrap();
        assert_eq!(load(&path).unwrap(), replacement);
        assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1);
    }

    #[test]
    fn missing_is_empty_and_corrupt_is_reported() {
        let directory = tempfile::tempdir().unwrap();
        let path = directory.path().join("state.json");
        assert_eq!(load(&path).unwrap(), PersistentState::default());
        fs::write(&path, b"not json").unwrap();
        assert!(matches!(load(&path), Err(StateError::Parse { .. })));
    }
}