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()
}
const STAMP_PREFIX: &str = "# Generated by ForkLaunch (sha256:";
const STAMP_SUFFIX: &str = "); the CLI regenerates this file until it is edited.";
pub(crate) fn stamp(body: &str) -> String {
format!("{STAMP_PREFIX}{}{STAMP_SUFFIX}\n{body}", sha256_hex(body))
}
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))
}
pub(crate) fn file_stamped(path: &Path) -> Option<bool> {
stamped(&fs::read_to_string(path).ok()?)
}
pub(crate) fn unstamped(content: &str) -> &str {
match content.split_once('\n') {
Some((header, body)) if header.starts_with(STAMP_PREFIX) => body,
_ => content,
}
}
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()
}
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"));
}
}