etz 0.3.0

CLI for coordinating git worktrees across multi-repo parent directories.
Documentation
use std::{
    fs,
    path::Component,
    path::{Path, PathBuf},
};

use anyhow::{Context, Result, bail};
use serde::{Serialize, de::DeserializeOwned};

use crate::{
    errors::{self, EXIT_NOT_FOUND},
    model::{ConfigFile, ManifestFile, StateFile},
};

#[derive(Debug, Clone)]
pub struct AppPaths {
    pub root: PathBuf,
    pub etz_dir: PathBuf,
    pub config_path: PathBuf,
    pub manifest_path: PathBuf,
    pub state_path: PathBuf,
}

impl AppPaths {
    pub fn from_root(root: &Path) -> Result<Self> {
        let root = root
            .canonicalize()
            .with_context(|| format!("failed to canonicalize root {}", root.display()))?;
        let etz_dir = root.join(".etz");

        Ok(Self {
            root,
            etz_dir: etz_dir.clone(),
            config_path: etz_dir.join("config.toml"),
            manifest_path: etz_dir.join("manifest.lock.toml"),
            state_path: etz_dir.join("state.toml"),
        })
    }

    pub fn ensure_exists(&self) -> Result<()> {
        if !self.etz_dir.exists() {
            return errors::err(
                errors::EXIT_NOT_INITIALIZED,
                format!(
                    "{} is not initialized. Run `etz init` first.",
                    self.root.display()
                ),
            );
        }
        Ok(())
    }

    pub fn workspaces_dir(&self, config: &ConfigFile) -> PathBuf {
        self.etz_dir.join(&config.workspace_dir)
    }

    pub fn workspace_dir(&self, config: &ConfigFile, workspace: &str) -> PathBuf {
        self.workspaces_dir(config).join(workspace)
    }

    pub fn is_initialized(&self) -> bool {
        self.config_path.exists() && self.manifest_path.exists() && self.state_path.exists()
    }

    pub fn initialize(&self) -> Result<()> {
        fs::create_dir_all(&self.etz_dir)
            .with_context(|| format!("failed to create {}", self.etz_dir.display()))
    }
}

pub fn read_toml<T: DeserializeOwned>(path: &Path) -> Result<T> {
    let raw =
        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
    let value =
        toml::from_str::<T>(&raw).with_context(|| format!("invalid TOML in {}", path.display()))?;
    Ok(value)
}

pub fn write_toml<T: Serialize>(path: &Path, value: &T) -> Result<()> {
    let serialized = toml::to_string_pretty(value)
        .with_context(|| format!("failed to serialize TOML for {}", path.display()))?;
    fs::write(path, serialized).with_context(|| format!("failed to write {}", path.display()))
}

pub fn load_config(paths: &AppPaths) -> Result<ConfigFile> {
    paths.ensure_exists()?;
    if !paths.config_path.exists() {
        bail!(
            "missing config at {}. Run `etz init`.",
            paths.config_path.display()
        );
    }
    read_toml(&paths.config_path)
}

pub fn load_manifest(paths: &AppPaths) -> Result<ManifestFile> {
    paths.ensure_exists()?;
    if !paths.manifest_path.exists() {
        bail!(
            "missing manifest at {}. Run `etz init`.",
            paths.manifest_path.display()
        );
    }
    read_toml(&paths.manifest_path)
}

pub fn load_state(paths: &AppPaths) -> Result<StateFile> {
    paths.ensure_exists()?;
    if !paths.state_path.exists() {
        bail!(
            "missing state at {}. Run `etz init`.",
            paths.state_path.display()
        );
    }
    read_toml(&paths.state_path)
}

pub fn save_config(paths: &AppPaths, config: &ConfigFile) -> Result<()> {
    write_toml(&paths.config_path, config)
}

pub fn save_manifest(paths: &AppPaths, manifest: &ManifestFile) -> Result<()> {
    write_toml(&paths.manifest_path, manifest)
}

pub fn save_state(paths: &AppPaths, state: &StateFile) -> Result<()> {
    write_toml(&paths.state_path, state)
}

pub fn assert_workspace_name_valid(name: &str) -> Result<()> {
    if name.trim().is_empty() {
        bail!("workspace name cannot be empty");
    }
    if name.contains('/') || name.contains('\\') {
        bail!("workspace name cannot include path separators");
    }
    if name == "." || name == ".." {
        bail!("workspace name cannot be . or ..");
    }
    if name.starts_with('.') {
        bail!("workspace name cannot start with '.'");
    }
    Ok(())
}

pub fn resolve_existing_workspace<'a>(
    state: &'a StateFile,
    workspace: &str,
) -> Result<&'a crate::model::WorkspaceState> {
    state.workspaces.get(workspace).ok_or_else(|| {
        errors::exit_error(
            EXIT_NOT_FOUND,
            format!("workspace '{}' not found", workspace),
        )
    })
}

pub fn find_etz_root(start: &Path) -> Option<PathBuf> {
    let canonical_start = start.canonicalize().ok()?;

    for ancestor in canonical_start.ancestors() {
        let etz_dir = ancestor.join(".etz");
        if etz_dir.join("config.toml").exists()
            && etz_dir.join("manifest.lock.toml").exists()
            && etz_dir.join("state.toml").exists()
        {
            return Some(ancestor.to_path_buf());
        }
    }

    None
}

pub fn infer_workspace_from_cwd(root: &Path, cwd: &Path) -> Result<Option<String>> {
    let paths = AppPaths::from_root(root)?;
    if !paths.config_path.exists() {
        return Ok(None);
    }

    let config: ConfigFile = read_toml(&paths.config_path)?;
    let workspaces_dir = paths.workspaces_dir(&config);
    if !workspaces_dir.exists() {
        return Ok(None);
    }

    let canonical_cwd = cwd
        .canonicalize()
        .with_context(|| format!("failed to canonicalize cwd {}", cwd.display()))?;
    let canonical_workspaces_dir = workspaces_dir
        .canonicalize()
        .with_context(|| format!("failed to canonicalize {}", workspaces_dir.display()))?;

    let rel = match canonical_cwd.strip_prefix(&canonical_workspaces_dir) {
        Ok(rel) => rel,
        Err(_) => return Ok(None),
    };

    let workspace_name = match rel.components().next() {
        Some(Component::Normal(name)) => name.to_string_lossy().to_string(),
        _ => return Ok(None),
    };

    Ok(Some(workspace_name))
}