forklaunch 1.35.1

Launch faster with forklaunch
//! Machine-owned state under an application's `.forklaunch/`.
//!
//! `manifest.toml` is the only committed file, including the `[framework]`
//! dependency selection. Everything else the CLI generates is derived from it,
//! and generated files that users may edit carry their own fingerprint (see
//! [`stamp`]).
use std::{fs, path::Path};

use anyhow::{Context, Result};
use sha2::{Digest, Sha256};

pub(crate) const MANIFEST: &str = ".forklaunch/manifest.toml";

pub(crate) fn sha256_hex(bytes: impl AsRef<[u8]>) -> String {
    Sha256::digest(bytes.as_ref())
        .iter()
        .map(|byte| format!("{byte:02x}"))
        .collect()
}

// ---------------------------------------------------------------------------
// Fingerprinted generated files

const STAMP_PREFIX: &str = "# Generated by ForkLaunch (sha256:";
const STAMP_SUFFIX: &str = "); the CLI regenerates this file until it is edited.";

/// Prefix `body` with a `#` comment holding its digest. Later commands
/// regenerate the file only while the digest still matches (see [`stamped`]).
pub(crate) fn stamp(body: &str) -> String {
    format!("{STAMP_PREFIX}{}{STAMP_SUFFIX}\n{body}", sha256_hex(body))
}

/// Whether `content` carries a ForkLaunch stamp, and if so whether the body is
/// unchanged since it was generated. `None` means the file is not generated.
pub(crate) fn stamped(content: &str) -> Option<bool> {
    let (header, body) = content.split_once('\n')?;
    let digest = header
        .strip_prefix(STAMP_PREFIX)?
        .strip_suffix(STAMP_SUFFIX)?;
    Some(digest == sha256_hex(body))
}

/// [`stamped`] for a file on disk; a missing file is `None`.
pub(crate) fn file_stamped(path: &Path) -> Option<bool> {
    stamped(&fs::read_to_string(path).ok()?)
}

/// The body of a stamped file, without its header.
pub(crate) fn unstamped(content: &str) -> &str {
    match content.split_once('\n') {
        Some((header, body)) if header.starts_with(STAMP_PREFIX) => body,
        _ => content,
    }
}

// ---------------------------------------------------------------------------
// [framework] in manifest.toml

/// The selected framework dependencies from the manifest `[framework]` table.
/// `None` means no selection has been recorded yet.
pub(crate) fn framework(root: &Path) -> Option<toml::Value> {
    let manifest = fs::read_to_string(root.join(MANIFEST))
        .ok()
        .and_then(|source| toml::from_str::<toml::Value>(&source).ok())?;
    manifest
        .get("framework")
        .filter(|table| table.is_table())
        .cloned()
}

pub(crate) fn has_framework(root: &Path) -> bool {
    framework(root).is_some()
}

/// Record the framework selection in the manifest, keeping its other content and
/// formatting.
pub(crate) fn write_framework(root: &Path, config: &toml::Table) -> Result<()> {
    let path = root.join(MANIFEST);
    let source =
        fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?;
    let mut document: toml_edit::DocumentMut = source.parse()?;
    let mut table = toml_edit::Table::new();
    for key in ["version", "blueprint_version", "path"] {
        if let Some(value) = config.get(key).and_then(toml::Value::as_str) {
            table.insert(key, toml_edit::value(value));
        }
    }
    document.insert("framework", toml_edit::Item::Table(table));
    let updated = document.to_string();
    if updated != source {
        fs::write(&path, updated)?;
    }
    Ok(())
}

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

    #[test]
    fn stamps_detect_edits_and_unstamped_files() {
        let generated = stamp("FROM node:22\nRUN true\n");
        assert_eq!(stamped(&generated), Some(true));
        assert_eq!(unstamped(&generated), "FROM node:22\nRUN true\n");
        let edited = generated.replace("RUN true", "RUN false");
        assert_eq!(stamped(&edited), Some(false));
        assert_eq!(stamped("FROM node:22\n"), None);
        assert_eq!(unstamped("FROM node:22\n"), "FROM node:22\n");
    }

    #[test]
    fn framework_selection_lives_in_the_manifest() {
        let root = tempfile::tempdir().unwrap();
        fs::create_dir_all(root.path().join(".forklaunch")).unwrap();
        fs::write(root.path().join(MANIFEST), "# keep\napp_name = \"x\"\n").unwrap();
        assert!(!has_framework(root.path()));
        let mut config = toml::Table::new();
        config.insert("version".into(), "0.1.3".into());
        write_framework(root.path(), &config).unwrap();
        let text = fs::read_to_string(root.path().join(MANIFEST)).unwrap();
        assert!(text.starts_with("# keep\n"), "{text}");
        assert_eq!(
            framework(root.path()).unwrap()["version"].as_str(),
            Some("0.1.3")
        );
    }
}