ferrflow 7.21.0

Universal semantic versioning for monorepos and classic repos
Documentation
use anyhow::{Context, Result};
use std::path::Path;

use crate::error_code::{self, ErrorCodeExt};

use super::Config;

pub(crate) const JS_CONFIG_FILENAME: &str = "ferrflow.js";
pub(crate) const TS_CONFIG_FILENAME: &str = "ferrflow.ts";

pub(crate) fn path_to_file_url(path: &Path) -> Result<String> {
    let canonical = path
        .canonicalize()
        .with_context(|| format!("Failed to resolve path: {}", path.display()))
        .error_code(error_code::CONFIG_RESOLVE_PATH)?;

    let path_str = canonical.to_string_lossy().to_string();

    let normalized = path_str
        .strip_prefix(r"\\?\")
        .unwrap_or(&path_str)
        .replace('\\', "/");

    let encoded = percent_encode_path(&normalized);

    if encoded.starts_with('/') {
        Ok(format!("file://{encoded}"))
    } else {
        Ok(format!("file:///{encoded}"))
    }
}

fn percent_encode_path(path: &str) -> String {
    let mut out = String::with_capacity(path.len());
    for byte in path.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
                out.push(byte as char);
            }
            _ => out.push_str(&format!("%{byte:02X}")),
        }
    }
    out
}

const LOADER_SCRIPT: &str = r#"
const SHELL_SAFE_JS_ESCAPES = { '\\': '\\\\\\\\', "'": "\\'", '"': '\\"', '`': '\\`', '$': '\\$' };

function shellSafeJsString(value) {
  const body = String(value).replace(/[\\'"`$]/g, (ch) => SHELL_SAFE_JS_ESCAPES[ch]);
  return `'${body}'`;
}

function reifyHooks(hooks, fileUrl, runtime, hookPath) {
  if (!hooks || typeof hooks !== 'object') return hooks;
  const ctx = `{ package: process.env.FERRFLOW_PACKAGE, oldVersion: process.env.FERRFLOW_OLD_VERSION, newVersion: process.env.FERRFLOW_NEW_VERSION, bumpType: process.env.FERRFLOW_BUMP_TYPE, tag: process.env.FERRFLOW_TAG, dryRun: process.env.FERRFLOW_DRY_RUN === 'true', packagePath: process.env.FERRFLOW_PACKAGE_PATH, channel: process.env.FERRFLOW_CHANNEL || null, isPrerelease: process.env.FERRFLOW_IS_PRERELEASE === 'true', monorepo: process.env.FERRFLOW_MONOREPO === 'true', changelog: process.env.FERRFLOW_CHANGELOG || '', commits: JSON.parse(process.env.FERRFLOW_COMMITS_JSON || '[]'), bumpedFiles: JSON.parse(process.env.FERRFLOW_BUMPED_FILES_JSON || '[]'), allPackages: JSON.parse(process.env.FERRFLOW_ALL_PACKAGES_JSON || '[]'), releaseUrl: process.env.FERRFLOW_RELEASE_URL || null }`;
  const result = {};
  for (const [key, value] of Object.entries(hooks)) {
    if (typeof value === 'function') {
      const cmd = `${runtime} --input-type=module -e "const m = await import('${fileUrl}'); const cfg = typeof m.default === 'function' ? await m.default() : m.default; const hooks = ${hookPath}; await hooks[${shellSafeJsString(key)}](${ctx});"`;
      result[key] = cmd;
    } else {
      result[key] = value;
    }
  }
  return result;
}
"#;

fn loader_body(file_url: &str, runtime: &str) -> String {
    format!(
        r#"{LOADER_SCRIPT}
const m = await import('{file_url}');
const cfg = typeof m.default === 'function' ? await m.default() : m.default;
if (cfg.workspace && cfg.workspace.hooks) {{
  cfg.workspace.hooks = reifyHooks(cfg.workspace.hooks, '{file_url}', '{runtime}', 'cfg.workspace.hooks');
}}
if (cfg.package) {{
  for (const pkg of cfg.package) {{
    if (pkg.hooks) {{
      pkg.hooks = reifyHooks(pkg.hooks, '{file_url}', '{runtime}', 'cfg.package.find(p=>p.name===process.env.FERRFLOW_PACKAGE).hooks');
    }}
  }}
}}
process.stdout.write(JSON.stringify(cfg));"#
    )
}

pub(crate) fn load_js_ts_config(path: &Path) -> Result<Config> {
    use std::process::Command;

    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
    let filename = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("ferrflow config");
    let file_url = path_to_file_url(path)?;

    let output = if ext == "ts" {
        let wrapper_tempdir = tempfile::tempdir()
            .with_context(|| "Failed to create temporary directory for TS loader")
            .error_code(error_code::CONFIG_WRITE_LOADER)?;
        let wrapper_path = wrapper_tempdir.path().join("loader.mjs");
        let tsx_available = Command::new("tsx").arg("--version").output().is_ok();
        let runtime = if tsx_available { "tsx" } else { "npx tsx" };

        let script = loader_body(&file_url, runtime);
        std::fs::write(&wrapper_path, &script)
            .with_context(|| "Failed to write temporary loader file")
            .error_code(error_code::CONFIG_WRITE_LOADER)?;

        let result = Command::new("tsx")
            .arg(&wrapper_path)
            .current_dir(wrapper_tempdir.path())
            .output()
            .or_else(|e| {
                if e.kind() == std::io::ErrorKind::NotFound {
                    Command::new("npx")
                        .args(["tsx"])
                        .arg(&wrapper_path)
                        .current_dir(wrapper_tempdir.path())
                        .output()
                } else {
                    Err(e)
                }
            })
            .map_err(|e| {
                if e.kind() == std::io::ErrorKind::NotFound {
                    anyhow::anyhow!(
                        "{filename} requires tsx but neither 'tsx' nor 'npx tsx' was found.\n\
                         Install with: npm install -g tsx"
                    )
                } else {
                    anyhow::anyhow!("Failed to execute tsx: {e}")
                }
            })
            .error_code(error_code::CONFIG_EVAL_TS);

        drop(wrapper_tempdir);
        result?
    } else {
        let script = loader_body(&file_url, "node");

        let parent = path.parent().unwrap_or(Path::new("."));
        Command::new("node")
            .args(["--input-type=module", "-e", &script])
            .current_dir(parent)
            .output()
            .map_err(|e| {
                if e.kind() == std::io::ErrorKind::NotFound {
                    anyhow::anyhow!(
                        "{filename} requires Node.js but 'node' was not found in PATH.\n\
                         Install Node.js from https://nodejs.org/"
                    )
                } else {
                    anyhow::anyhow!("Failed to execute node: {e}")
                }
            })
            .error_code(error_code::CONFIG_EVAL_NODE)?
    };

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        Err(anyhow::anyhow!("Failed to evaluate {filename}:\n{stderr}"))
            .error_code(error_code::CONFIG_EVAL_FAILED)?;
    }

    let stdout = String::from_utf8(output.stdout)
        .with_context(|| format!("{filename} produced invalid UTF-8 output"))
        .error_code(error_code::CONFIG_INVALID_OUTPUT)?;

    serde_json::from_str::<Config>(&stdout)
        .with_context(|| format!("{filename} did not produce valid JSON config"))
        .error_code(error_code::CONFIG_INVALID_JSON)
}

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

    #[test]
    fn a_path_with_shell_metacharacters_is_percent_encoded() {
        // The URL is interpolated into a single-quoted JS string that itself
        // sits in a double-quoted shell argument, so any of these reaching the
        // URL verbatim breaks out of one layer or the other.
        let encoded = percent_encode_path("/home/o'brien/a b/$HOME/`x`/\"q\"/repo");
        for ch in ['\'', '"', '$', '`', ' '] {
            assert!(!encoded.contains(ch), "{ch:?} survived encoding: {encoded}");
        }
        assert!(encoded.starts_with("/home/o%27brien/a%20b/%24HOME/"));
    }

    #[test]
    fn percent_encoding_round_trips_back_to_the_path() {
        let path = "/tmp/it's a $test/`b`/répertoire/ferrflow.js";
        let encoded = percent_encode_path(path);

        let mut decoded = Vec::new();
        let bytes = encoded.as_bytes();
        let mut i = 0;
        while i < bytes.len() {
            if bytes[i] == b'%' {
                let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap();
                decoded.push(u8::from_str_radix(hex, 16).unwrap());
                i += 3;
            } else {
                decoded.push(bytes[i]);
                i += 1;
            }
        }
        assert_eq!(String::from_utf8(decoded).unwrap(), path);
    }

    #[test]
    fn the_loader_never_interpolates_a_package_name_into_the_script() {
        let script = loader_body("file:///tmp/ferrflow.js", "node");
        assert!(
            script.contains("p.name===process.env.FERRFLOW_PACKAGE"),
            "the package must be identified from the environment the hook runner \
             already sets, not spliced into the script: {script}"
        );
        assert!(
            !script.contains("pkg.name"),
            "a config-supplied package name is still reaching the script: {script}"
        );
    }

    #[test]
    fn a_hook_is_looked_up_by_key_rather_than_property_access() {
        let script = loader_body("file:///tmp/ferrflow.js", "node");
        assert!(
            script.contains("hooks[${shellSafeJsString(key)}]"),
            "a hook key that is not a bare identifier must still resolve: {script}"
        );
        assert!(!script.contains("hooks.${key}"), "{script}");
    }
}