dotling 0.9.0

A dotfiles management CLI — track, link, and sync your config files across machines
Documentation
use std::{
    fs,
    path::{Path, PathBuf},
};

use crate::{
    error::{Error, Result},
    path,
};

const STATE_FILE: &str = "state.toml";
const FINGERPRINTS_FILE: &str = "fingerprints.toml";
const SNAPSHOTS_DIR: &str = "snapshots";
const VARS_FILE: &str = "vars.toml";

/// Global state directory: `~/.dotling/`
pub fn state_dir() -> Result<PathBuf> {
    Ok(path::home_dir()?.join(".dotling"))
}

/// Path to the global state file: `~/.dotling/state.toml`
fn state_path() -> Result<PathBuf> {
    Ok(state_dir()?.join(STATE_FILE))
}

/// Path to the fingerprint store: `~/.dotling/fingerprints.toml`
pub fn fingerprint_path() -> Result<PathBuf> {
    Ok(state_dir()?.join(FINGERPRINTS_FILE))
}

/// Path to the machine-local variable store: `~/.dotling/vars.toml`
pub fn vars_path() -> Result<PathBuf> {
    Ok(state_dir()?.join(VARS_FILE))
}

/// Root directory for plaintext snapshots: `~/.dotling/snapshots/`
///
/// Each file is stored at `<snapshot_dir>/<source>` where `source` is the
/// repo-relative path of the entry (e.g. `shell/fish/config.fish`).
pub fn snapshot_dir() -> Result<PathBuf> {
    Ok(state_dir()?.join(SNAPSHOTS_DIR))
}

/// Full path for a plaintext snapshot of `source`.
pub fn snapshot_path(source: &str) -> Result<PathBuf> {
    Ok(snapshot_dir()?.join(source))
}

/// Get the currently registered repo root.
///
/// Returns `None` if no repo has been initialized.
pub fn get_repo_root() -> Result<Option<PathBuf>> {
    let path = state_path()?;
    if !path.exists() {
        return Ok(None);
    }

    let content = fs::read_to_string(&path).map_err(|e| Error::io(&path, "read state", e))?;

    for line in content.lines() {
        let line = line.split('#').next().unwrap_or("").trim();
        if let Some((key, value)) = line.split_once('=') {
            if key.trim() == "repo" {
                let value = value.trim().trim_matches('"');
                let expanded = path::expand_tilde(Path::new(value))?;
                return Ok(Some(expanded));
            }
        }
    }

    Ok(None)
}

/// Register a repo root in the global state.
pub fn set_repo_root(repo_root: &Path) -> Result<()> {
    let dir = state_dir()?;
    fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, "create state directory", e))?;

    let display_path = path::collapse_tilde(repo_root);
    let content = format!(
        "# dotling global state — managed by dotling\nrepo = \"{}\"\n",
        display_path.display()
    );

    crate::fs::atomic_write(&state_path()?, content.as_bytes())
}

/// Require a repo root to be configured. Returns an error with a helpful
/// message if no repo is registered.
pub fn require_repo_root() -> Result<PathBuf> {
    get_repo_root()?.ok_or_else(|| {
        Error::User("no dotfiles repository found — run `dotling init <path>` first".into())
    })
}

/// Path to `dotling.toml` within a repo.
pub fn config_path(repo_root: &Path) -> PathBuf {
    repo_root.join("dotling.toml")
}

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

    #[test]
    fn state_dir_is_under_home() {
        let dir = state_dir().unwrap();
        assert!(dir.ends_with(".dotling"));
    }
}