use super::*;
use crate::core::{
cargo_toml::{
self, BLUEPRINT_VERSION as BLUEPRINT_RELEASE_VERSION, FRAMEWORK_VERSION as RELEASE_VERSION,
},
forklaunch_state,
};
pub(crate) fn configuration(matches: &ArgMatches) -> Result<toml::Table> {
let selected_version = matches
.try_get_one::<String>("framework-version")
.ok()
.flatten();
let path = matches
.try_get_one::<String>("framework-path")
.ok()
.flatten();
let version = selected_version.map(String::as_str).or_else(|| {
if path.is_none() {
Some(RELEASE_VERSION)
} else {
None
}
});
let mut config = toml::Table::new();
if let Some(version) = version {
anyhow::ensure!(
valid_version(version),
"--framework-version requires an exact numeric MAJOR.MINOR.PATCH version"
);
config.insert("version".into(), version.into());
}
config.insert("blueprint_version".into(), BLUEPRINT_RELEASE_VERSION.into());
if let Some(path) = path {
let path =
std::fs::canonicalize(path).context("Framework override directory does not exist")?;
anyhow::ensure!(
path.join("crates/axum/Cargo.toml").is_file(),
"--framework-path must identify the framework-rs workspace"
);
config.insert("path".into(), path.to_string_lossy().into_owned().into());
}
Ok(config)
}
fn default_configuration() -> toml::Table {
let mut config = toml::Table::new();
config.insert("version".into(), RELEASE_VERSION.into());
config.insert("blueprint_version".into(), BLUEPRINT_RELEASE_VERSION.into());
config
}
pub(crate) fn change(root: &Path, matches: &ArgMatches) -> Result<String> {
configure(root, matches)?;
super::language_images::reconcile(root)?;
Ok(
match forklaunch_state::framework(root).and_then(|config| {
config
.get("path")
.and_then(Value::as_str)
.map(str::to_owned)
}) {
Some(path) => format!("local framework checkout {path}"),
None => format!(
"published framework {}",
forklaunch_state::framework(root)
.and_then(|c| c.get("version").and_then(Value::as_str).map(str::to_owned))
.unwrap_or_default()
),
},
)
}
pub(crate) fn configure(root: &Path, matches: &ArgMatches) -> Result<()> {
let config = configuration(matches)?;
forklaunch_state::write_framework(root, &config)?;
rewrite_tree(root, root)
}
pub(super) fn prepare(root: &Path) -> Result<()> {
if !configured(root) {
forklaunch_state::write_framework(root, &default_configuration())?;
}
rewrite_tree(root, root)
}
fn valid_version(version: &str) -> bool {
let parts: Vec<_> = version.split('.').collect();
parts.len() == 3
&& parts.iter().all(|part| {
!part.is_empty()
&& part.bytes().all(|byte| byte.is_ascii_digit())
&& (part.len() == 1 || !part.starts_with('0'))
})
}
pub(super) fn rewrite_tree(root: &Path, directory: &Path) -> Result<()> {
for entry in std::fs::read_dir(directory)? {
let path = entry?.path();
if path.is_dir() {
if ["vendor", "target", "node_modules", ".git"]
.iter()
.any(|name| path.file_name().is_some_and(|file| file == *name))
{
continue;
}
rewrite_tree(root, &path)?;
} else if path.file_name().is_some_and(|name| name == "Cargo.toml") {
let source = read_to_string(&path)?;
if let Some(updated) = rewrite_in_place(root, &source)? {
write(&path, updated)?;
}
}
}
Ok(())
}
pub(super) fn configured(root: &Path) -> bool {
forklaunch_state::has_framework(root)
}
pub(crate) fn render_for_path(path: &Path, source: &str) -> Result<String> {
if path.file_name().is_none_or(|name| name != "Cargo.toml") {
return Ok(source.into());
}
let source = cargo_toml::fill(source)?;
let root = path.ancestors().find(|root| configured(root));
match root {
Some(root) => render(root, &source),
None => Ok(source),
}
}
fn blueprint_suffix(path: &str) -> Option<&str> {
[".forklaunch/blueprint-rs/crates/", "blueprint-rs/crates/"]
.into_iter()
.find_map(|prefix| path.split_once(prefix).map(|(_, suffix)| suffix))
}
fn local_blueprint(framework: &str) -> PathBuf {
Path::new(framework)
.parent()
.unwrap_or(Path::new(framework))
.join("blueprint-rs/crates")
}
fn framework_suffix(path: &str) -> Option<&str> {
path.split_once("framework-rs/").map(|(_, suffix)| suffix)
}
fn render(root: &Path, source: &str) -> Result<String> {
Ok(rewrite_in_place(root, source)?.unwrap_or_else(|| source.into()))
}
fn rewrite_in_place(root: &Path, source: &str) -> Result<Option<String>> {
let config =
forklaunch_state::framework(root).context("Framework dependency selection missing")?;
let Ok(mut document) = source.parse::<toml_edit::DocumentMut>() else {
return Ok(None);
};
let mut changed = false;
rewrite_item(document.as_item_mut(), &config, &mut changed);
if let Some(path) = config.get("path").and_then(Value::as_str) {
let index = local_crates(Path::new(path));
localize_item(document.as_item_mut(), &index, &mut changed);
}
Ok(changed.then(|| document.to_string()))
}
fn local_crates(framework: &Path) -> std::collections::BTreeMap<String, PathBuf> {
fn walk(
directory: &Path,
index: &mut std::collections::BTreeMap<String, PathBuf>,
depth: usize,
) {
let Ok(entries) = std::fs::read_dir(directory) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir()
|| path
.file_name()
.is_some_and(|name| name == "target" || name == "node_modules")
{
continue;
}
if let Ok(source) = read_to_string(path.join("Cargo.toml"))
&& let Ok(manifest) = toml::from_str::<Value>(&source)
&& let Some(name) = manifest
.get("package")
.and_then(|package| package.get("name"))
.and_then(Value::as_str)
{
index.insert(name.to_owned(), path.clone());
}
if depth < 4 {
walk(&path, index, depth + 1);
}
}
}
let mut index = std::collections::BTreeMap::new();
walk(&framework.join("crates"), &mut index, 0);
let blueprint = local_blueprint(&framework.to_string_lossy());
if blueprint.is_dir() {
walk(&blueprint, &mut index, 0);
}
index
}
fn localize_item(
item: &mut toml_edit::Item,
index: &std::collections::BTreeMap<String, PathBuf>,
changed: &mut bool,
) {
let Some(table) = item.as_table_like_mut() else {
return;
};
let keys: Vec<String> = table.iter().map(|(key, _)| key.to_owned()).collect();
for key in keys {
let Some(child) = table.get_mut(&key) else {
continue;
};
let Some(spec) = child.as_table_like_mut() else {
continue;
};
let package = spec
.get("package")
.and_then(toml_edit::Item::as_str)
.unwrap_or(&key)
.to_owned();
if spec.contains_key("version")
&& !spec.contains_key("path")
&& package.starts_with("forklaunch-")
&& let Some(directory) = index.get(&package)
{
spec.remove("version");
spec.insert(
"path",
toml_edit::value(directory.to_string_lossy().into_owned()),
);
*changed = true;
if let Some(inline) = child.as_inline_table_mut() {
let path = inline.remove("path").expect("inserted above");
let mut ordered = toml_edit::InlineTable::new();
ordered.insert("path", path);
ordered.extend(
inline
.iter()
.map(|(key, value)| (key.to_owned(), value.clone())),
);
ordered.fmt();
*inline = ordered;
}
continue;
}
localize_item(child, index, changed);
}
}
fn rewrite_item(item: &mut toml_edit::Item, config: &Value, changed: &mut bool) {
let Some(table) = item.as_table_like_mut() else {
return;
};
if let Some(path) = table
.get("path")
.and_then(toml_edit::Item::as_str)
.map(str::to_owned)
{
let mut parsed = toml::Table::new();
parsed.insert("path".into(), path.clone().into());
let mut value = Value::Table(parsed);
rewrite_value(&mut value, config);
match (
value.get("path").and_then(Value::as_str),
value.get("version").and_then(Value::as_str),
) {
(Some(next), _) if next != path => {
table.insert("path", toml_edit::value(next));
*changed = true;
}
(None, Some(version)) => {
table.remove("path");
table.insert("version", toml_edit::value(version));
*changed = true;
if let Some(inline) = item.as_inline_table_mut() {
let version = inline.remove("version").unwrap_or_else(|| version.into());
let mut ordered = toml_edit::InlineTable::new();
ordered.insert("version", version);
ordered.extend(
inline
.iter()
.map(|(key, value)| (key.to_owned(), value.clone())),
);
ordered.fmt();
*inline = ordered;
}
return;
}
_ => {}
}
}
for (_, child) in table.iter_mut() {
rewrite_item(child, config, changed);
}
}
fn rewrite_value(value: &mut Value, config: &Value) {
if let Some(table) = value.as_table_mut() {
if let Some(path) = table.get("path").and_then(Value::as_str).map(str::to_owned) {
if let Some(suffix) = blueprint_suffix(&path) {
let local = config
.get("path")
.and_then(Value::as_str)
.map(local_blueprint)
.filter(|path| path.is_dir());
if let Some(local) = local {
table.insert(
"path".into(),
local.join(suffix).to_string_lossy().into_owned().into(),
);
} else if let Some(version) =
config.get("blueprint_version").and_then(Value::as_str)
{
table.remove("path");
table.insert("version".into(), format!("={version}").into());
}
} else if let Some(suffix) = framework_suffix(&path) {
if let Some(local) = config.get("path").and_then(Value::as_str) {
table.insert(
"path".into(),
Path::new(local)
.join(suffix)
.to_string_lossy()
.into_owned()
.into(),
);
} else if let Some(version) = config.get("version").and_then(Value::as_str) {
table.remove("path");
table.insert("version".into(), format!("={version}").into());
}
}
}
for (_, value) in table.iter_mut() {
rewrite_value(value, config);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_mode_preserves_features_and_aliases() {
let root = tempfile::tempdir().unwrap();
create_dir_all(root.path().join(".forklaunch")).unwrap();
write(
root.path().join(".forklaunch/manifest.toml"),
"app_name = 'demo'\n\n[framework]\nversion = '0.1.0'\n",
)
.unwrap();
let source = "[dependencies]\nframework = { package='forklaunch-axum',path='../../.forklaunch/framework-rs/crates/axum', default-features=false,features=['native'] }\nmodels = { path='../models' }\n";
let output: Value = toml::from_str(&render(root.path(), source).unwrap()).unwrap();
assert_eq!(
output["dependencies"]["framework"]["version"].as_str(),
Some("=0.1.0")
);
assert_eq!(
output["dependencies"]["framework"]["features"][0].as_str(),
Some("native")
);
assert_eq!(
output["dependencies"]["framework"]["default-features"].as_bool(),
Some(false)
);
assert_eq!(
output["dependencies"]["models"]["path"].as_str(),
Some("../models")
);
assert!(output.get("patch").is_none());
assert!(output["dependencies"]["framework"].get("path").is_none());
}
#[test]
fn existing_manifests_keep_comments_and_untouched_files_are_not_rewritten() {
let root = tempfile::tempdir().unwrap();
create_dir_all(root.path().join(".forklaunch")).unwrap();
write(
root.path().join(".forklaunch/manifest.toml"),
"[framework]\nversion = '0.1.2'\nblueprint_version = '0.1.0'\n",
)
.unwrap();
let source = "# owned\n[dependencies]\n# framework\ncore = { package = 'forklaunch-core', path = '../../.forklaunch/framework-rs/crates/core', features = ['build'] }\nworker = { path = '../../.forklaunch/blueprint-rs/crates/interfaces/worker-interface' }\nlocal = { path = '../local' }\n";
let updated = rewrite_in_place(root.path(), source).unwrap().unwrap();
assert!(
updated.starts_with("# owned\n") && updated.contains("# framework\n"),
"{updated}"
);
let value: Value = toml::from_str(&updated).unwrap();
assert_eq!(
value["dependencies"]["core"]["version"].as_str(),
Some("=0.1.2")
);
assert_eq!(
value["dependencies"]["core"]["features"][0].as_str(),
Some("build")
);
assert_eq!(
value["dependencies"]["worker"]["version"].as_str(),
Some("=0.1.0")
);
assert_eq!(
value["dependencies"]["local"]["path"].as_str(),
Some("../local")
);
assert!(
rewrite_in_place(
root.path(),
"# mine\n[dependencies]\nlocal = { path = '../local' }\n"
)
.unwrap()
.is_none()
);
}
#[test]
fn published_crates_switch_to_a_local_checkout_by_package_name() {
let checkout = tempfile::tempdir().unwrap();
for (path, name) in [
("framework-rs/crates/core", "forklaunch-core"),
(
"blueprint-rs/crates/interfaces/worker-interface",
"forklaunch-worker-interface",
),
] {
create_dir_all(checkout.path().join(path)).unwrap();
write(
checkout.path().join(path).join("Cargo.toml"),
format!("[package]\nname = '{name}'\n"),
)
.unwrap();
}
let root = tempfile::tempdir().unwrap();
create_dir_all(root.path().join(".forklaunch")).unwrap();
let framework = checkout.path().join("framework-rs");
write(
root.path().join(".forklaunch/manifest.toml"),
format!("[framework]\npath = '{}'\n", framework.display()),
)
.unwrap();
let source = "[dependencies]\ncore = { package = 'forklaunch-core', version = '=0.2.0', features = ['build'] }\nforklaunch-worker-interface = { version = '=0.2.0' }\nserde = { version = '1' }\n";
let value: Value =
toml::from_str(&rewrite_in_place(root.path(), source).unwrap().unwrap()).unwrap();
assert_eq!(
value["dependencies"]["core"]["path"].as_str(),
Some(framework.join("crates/core").to_str().unwrap())
);
assert_eq!(
value["dependencies"]["core"]["features"][0].as_str(),
Some("build")
);
assert!(value["dependencies"]["core"].get("version").is_none());
assert!(
value["dependencies"]["forklaunch-worker-interface"]["path"]
.as_str()
.unwrap()
.ends_with("blueprint-rs/crates/interfaces/worker-interface")
);
assert_eq!(
value["dependencies"]["serde"]["version"].as_str(),
Some("1")
);
let inline =
"[dependencies]\nforklaunch-core = { version = \"=0.2.0\", features = [\"build\"] }\n";
let local = rewrite_in_place(root.path(), inline).unwrap().unwrap();
assert_eq!(
local,
format!(
"[dependencies]\nforklaunch-core = {{ path = \"{}\", features = [\"build\"] }}\n",
framework.join("crates/core").display()
)
);
}
#[test]
fn local_override_is_explicit_and_exact_versions_are_required() {
assert!(valid_version("0.1.0"));
for invalid in ["*", "^0.1", "0.1", "01.2.3", "0.1.0\n"] {
assert!(!valid_version(invalid));
}
let mut value: Value = toml::from_str("[dependencies]\nworker = {package='forklaunch-worker-interface',path='../../.forklaunch/blueprint-rs/crates/interfaces/worker-interface',features=['apalis']}\n").unwrap();
let config: Value = toml::from_str("path='/tmp/framework-rs'").unwrap();
rewrite_value(&mut value, &config);
assert_eq!(
value["dependencies"]["worker"]["path"].as_str(),
Some("../../.forklaunch/blueprint-rs/crates/interfaces/worker-interface")
);
let mut framework: Value = toml::from_str("[dependencies]\ncore = {package='forklaunch-core',path='../../.forklaunch/framework-rs/crates/core',features=['typed-schema']}\n").unwrap();
rewrite_value(&mut framework, &config);
assert_eq!(
framework["dependencies"]["core"]["path"].as_str(),
Some("/tmp/framework-rs/crates/core")
);
}
}