wsb 0.291.8591

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! What a project tells the stamp about itself: `<scan root>/.wsb/stamp.json`.
//!
//! Two things a template can ask for are not facts about any project and not
//! environment variables, and the stamp cannot discover them: which file holds
//! the worktree's own declaration (`{{ declared.* }}`), and where the
//! worktree builds (`{{ build.root_from_here }}`). And `wsb local` needs to
//! know which variable selects a template's local branch. The worktree names
//! them here, committed, so every worktree renders the same way.
//!
//! ```json
//! {
//!   "declared": "workspace.json",
//!   "build_root": ".build",
//!   "local": {
//!     "variable": "LOCAL_LIBS",
//!     "prepare": ["make", "local-deps"],
//!     "release": ["make", "release-local-deps"]
//!   }
//! }
//! ```
//!
//! `declared` and `build_root` are paths relative to the scan root. A template
//! that asks for one the file does not name is refused, naming the file — a
//! stamp that guessed would render a path nobody chose. A build root inside
//! `.wsb/` is refused too: everything there is committed, and a build is not.

use crate::prelude::*;

/// Where the configuration lives, relative to the scan root.
pub const FILE: &str = ".wsb/stamp.json";

#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StampConfig {
    /// The JSON file `{{ declared.* }}` reads.
    pub declared: Option<PathBuf>,
    /// The directory `{{ build.root_from_here }}` names.
    pub build_root: Option<PathBuf>,
    /// How this worktree renders against its own working copies.
    pub local: Option<LocalRendering>,
}

/// The local render: `wsb local on` renders every template with `variable`
/// set, so a template's `{% if env.<variable> %}` branch names the working
/// copy next door instead of a published version.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LocalRendering {
    /// The environment variable whose presence selects the local branch.
    pub variable: String,
    /// What to run, in the scan root, when a local render names something
    /// under the build root — the build that produces what it names. Run after
    /// the render, and the render is taken back if it fails. One that declares
    /// its inputs runs only when they changed ([`crate::state::local_prepare`]).
    #[serde(default)]
    pub prepare: Option<Hook>,
    /// What to run, in the scan root, when the tree stops being local — so
    /// what `prepare` built may be swept again.
    #[serde(default)]
    pub release: Option<Vec<String>>,
}

/// A declared command: its argv alone, run every time, or with what it reads
/// and what it leaves behind.
///
/// ```json
/// "prepare": {
///   "run": ["make", "local-deps"],
///   "inputs": { "repositories": ["lib"], "files": ["deps.lock"], "programs": ["python3"] },
///   "produces": [".build/lib-dist"]
/// }
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(untagged)]
pub enum Hook {
    Command(Vec<String>),
    Declared(DeclaredHook),
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeclaredHook {
    pub run: Vec<String>,
    /// What it builds from. Without them it runs every time: nothing is
    /// skipped on a guess.
    #[serde(default)]
    pub inputs: Option<HookInputs>,
    /// What it leaves behind, relative to the scan root. One that is gone
    /// makes it run, whatever the inputs say.
    #[serde(default)]
    pub produces: Vec<PathBuf>,
}

/// What a hook builds from.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HookInputs {
    /// Repositories, relative to the scan root, whose committed state and
    /// working changes it builds from.
    #[serde(default)]
    pub repositories: Vec<PathBuf>,
    /// Files it reads, relative to the scan root.
    #[serde(default)]
    pub files: Vec<PathBuf>,
    /// Programs it installs with or into, found as the hook's own program is:
    /// another interpreter on the PATH is another environment.
    #[serde(default)]
    pub programs: Vec<String>,
}

impl Hook {
    pub fn argv(&self) -> &[String] {
        match self {
            Self::Command(argv) => argv,
            Self::Declared(declared) => &declared.run,
        }
    }

    pub fn inputs(&self) -> Option<&HookInputs> {
        match self {
            Self::Command(_) => None,
            Self::Declared(declared) => declared.inputs.as_ref(),
        }
    }

    pub fn produces(&self) -> &[PathBuf] {
        match self {
            Self::Command(_) => &[],
            Self::Declared(declared) => &declared.produces,
        }
    }
}

impl StampConfig {
    /// The scan root's configuration, or `None` when it has none.
    pub fn load(scan_root: &Path) -> Result<Option<Self>> {
        let path = scan_root.join(FILE);
        let text = match std::fs::read_to_string(&path) {
            Ok(text) => text,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(error) => return Err(error).with_context(|| format!("cannot read {}", path.display())),
        };
        serde_json::from_str(&text)
            .map(Some)
            .with_context(|| format!("{} is not a valid stamp configuration", path.display()))
    }

    /// The file `{{ declared.* }}` reads.
    pub fn declared_file(scan_root: &Path) -> Result<PathBuf> {
        Self::named(scan_root, "declared", |config| config.declared.as_ref())
    }

    /// The directory `{{ build.root_from_here }}` names.
    pub fn build_root(scan_root: &Path) -> Result<PathBuf> {
        let root = Self::named(scan_root, "build_root", |config| config.build_root.as_ref())?;
        if root.starts_with(scan_root.join(wsb_prj::settings::DIR)) {
            bail!(
                "{} names {} as the build root, inside .wsb/, which is committed whole; \
                 name a folder outside it",
                scan_root.join(FILE).display(),
                root.display()
            );
        }
        Ok(root)
    }

    /// How this worktree renders locally.
    pub fn local(scan_root: &Path) -> Result<LocalRendering> {
        let path = scan_root.join(FILE);
        match Self::load(scan_root)? {
            None => bail!(
                "a local render needs the variable that selects it, declared as \"local\" in {}, \
                 and there is no such file",
                path.display()
            ),
            Some(config) => config.local.ok_or_else(|| {
                anyhow!(
                    "a local render needs the variable that selects it, and {} declares no \"local\"",
                    path.display()
                )
            }),
        }
    }

    fn named(
        scan_root: &Path,
        key: &str,
        pick: impl Fn(&Self) -> Option<&PathBuf>,
    ) -> Result<PathBuf> {
        let path = scan_root.join(FILE);
        match Self::load(scan_root)? {
            None => bail!(
                "a template asks for what {} would name as \"{key}\", and there is no {}",
                FILE,
                path.display()
            ),
            Some(config) => match pick(&config) {
                Some(relative) => Ok(scan_root.join(relative)),
                None => bail!(
                    "a template asks for what {} names as \"{key}\", and it names none",
                    path.display()
                ),
            },
        }
    }
}

/// The program a declared `prepare` or `release` runs, found from the name
/// it is declared with.
///
/// A name is not a program: on macOS `/usr/bin/sdx` is an unrelated system
/// tool, and a hook declared as `sdx` ran it whenever the PATH the command
/// was started with put `/usr/bin` first — printing its own usage, and
/// exiting as if the hook had done its work. So:
///
/// * a path (`~/bin/x`, `tools/x`, `/opt/x`) is that file, relative to the
///   scan root when it is relative;
/// * a plugin enabled on this host is the program `wsb plugin add`
///   recorded for it, and one with no program recorded is refused;
/// * any other name is the first program of that name on the PATH.
pub fn hook_program(named: &str, scan_root: &Path, host_dir: Option<&Path>) -> Result<PathBuf> {
    let is_path = named.starts_with("~/") || named.contains('/') || (cfg!(windows) && named.contains('\\'));
    if is_path {
        let path = match named.strip_prefix("~/") {
            Some(rest) => std::env::var_os("HOME")
                .or_else(|| std::env::var_os("USERPROFILE"))
                .filter(|home| !home.is_empty())
                .map(|home| PathBuf::from(home).join(rest))
                .ok_or_else(|| anyhow!("{named} names the home folder, and HOME is not set"))?,
            None => scan_root.join(named),
        };
        if !crate::platform::is_executable(&path) {
            bail!("{named} is declared in {} and {} is no program", scan_root.join(FILE).display(), path.display());
        }
        return Ok(path);
    }
    if let Some(host_dir) = host_dir {
        match crate::plugin::program(host_dir, named).map_err(|e| anyhow!(e))? {
            Some(Some(program)) if crate::platform::is_executable(&program) => return Ok(program),
            Some(Some(program)) => bail!(
                "{named} is a plugin enabled on this host, and its program {} is not there; \
                 name it again: wsb plugin add {named} --program <path>",
                program.display()
            ),
            Some(None) => bail!(
                "{named} is a plugin enabled on this host with no program recorded, so which {named} \
                 to run is not known; name it: wsb plugin add {named} --program <path>"
            ),
            None => {}
        }
    }
    crate::report::which(named)
        .map(PathBuf::from)
        .ok_or_else(|| anyhow!("no {named} is on the PATH (declared in {})", scan_root.join(FILE).display()))
}

#[cfg(test)]
#[path = "stamp_config_tests.rs"]
mod tests;