use std::{fs::read_to_string, path::Path};
use anyhow::{Context, Result, bail};
use application_package_json::ApplicationPackageJson;
use ramhorns::{Content, Template};
use serde::Serialize;
use serde_json::{from_str, to_string_pretty};
use super::{
manifest::{InitializableManifestConfig, ManifestData},
pnpm_workspace::PnpmWorkspace,
rendered_template::{RenderedTemplate, RenderedTemplatesCache},
};
use crate::{
constants::{
ERROR_FAILED_TO_ADD_PROJECT_METADATA_TO_PACKAGE_JSON, ERROR_FAILED_TO_PARSE_PACKAGE_JSON,
ERROR_FAILED_TO_READ_PACKAGE_JSON, ERROR_FAILED_TO_UPDATE_APPLICATION_PACKAGE_JSON,
ERROR_UNSUPPORTED_DATABASE, Runtime,
},
core::manifest::{ManifestConfig, ProjectManifestConfig},
};
use package_json_constants::{compose_up_command, split_compose_up_command};
pub(crate) mod application_package_json;
pub(crate) mod package_json_constants;
pub(crate) mod project_package_json;
pub(crate) fn preserve_json_layout(original: &str, rendered: &str) -> String {
preserve_json_layout_renamed(original, rendered, &|key| key.to_string())
}
pub(crate) fn preserve_json_layout_renamed(
original: &str,
rendered: &str,
rename: &dyn Fn(&str) -> String,
) -> String {
use serde_json::{Map, Value};
fn ordered(original: &Value, rendered: &Value, rename: &dyn Fn(&str) -> String) -> Value {
match (original, rendered) {
(Value::Object(before), Value::Object(after)) => {
let mut keys: std::vec::Vec<(String, Value)> = std::vec::Vec::new();
for (key, value) in before {
let key = if after.contains_key(key) {
key.clone()
} else {
rename(key)
};
if let Some(new) = after.get(&key) {
keys.push((key, ordered(value, new, rename)));
}
}
let mut previous: Option<&String> = None;
for (key, value) in after {
if !keys.iter().any(|(existing, _)| existing == key) {
let at = previous
.and_then(|p| keys.iter().position(|(existing, _)| existing == p))
.map_or(0, |i| i + 1);
keys.insert(at, (key.clone(), value.clone()));
}
previous = Some(key);
}
Value::Object(keys.into_iter().collect::<Map<String, Value>>())
}
(Value::Array(before), Value::Array(after)) => Value::Array(
after
.iter()
.enumerate()
.map(|(i, value)| {
before
.get(i)
.map_or_else(|| value.clone(), |old| ordered(old, value, rename))
})
.collect(),
),
(_, rendered) => rendered.clone(),
}
}
let (Ok(before), Ok(after)) = (
serde_json::from_str::<Value>(original),
serde_json::from_str::<Value>(rendered),
) else {
return rendered.to_string();
};
if before == after {
return original.to_string();
}
let Ok(mut out) = to_string_pretty(&ordered(&before, &after, rename)) else {
return rendered.to_string();
};
if original.ends_with('\n') && !out.ends_with('\n') {
out.push('\n');
}
out
}
pub(crate) fn add_project_definition_to_package_json<
T: Content + ManifestConfig + ProjectManifestConfig + InitializableManifestConfig + Serialize,
>(
base_path: &Path,
manifest_data: &T,
) -> Result<String> {
let mut full_package_json: ApplicationPackageJson = from_str(
&read_to_string(base_path.join("package.json"))
.with_context(|| ERROR_FAILED_TO_READ_PACKAGE_JSON)?,
)
.with_context(|| ERROR_FAILED_TO_PARSE_PACKAGE_JSON)?;
if let Some(workspaces) = full_package_json.workspaces.as_mut() {
if !workspaces.contains(&manifest_data.name()) {
workspaces.push(manifest_data.name().clone());
}
}
Ok(Template::new(
to_string_pretty(&full_package_json)
.with_context(|| ERROR_FAILED_TO_ADD_PROJECT_METADATA_TO_PACKAGE_JSON)?
.as_str(),
)?
.render(&manifest_data))
}
pub(crate) fn update_application_package_json(
manifest_data: &ManifestData,
base_path: &Path,
existing_package_json: Option<String>,
) -> Result<Option<RenderedTemplate>> {
let mut full_package_json: ApplicationPackageJson = from_str(
&existing_package_json.unwrap_or(
read_to_string(base_path.join("package.json"))
.with_context(|| ERROR_FAILED_TO_READ_PACKAGE_JSON)?,
),
)
.with_context(|| ERROR_FAILED_TO_PARSE_PACKAGE_JSON)?;
if let ManifestData::Worker(worker_data) = manifest_data {
if !worker_data.is_database_enabled {
return Ok(Some(RenderedTemplate {
path: base_path.join("package.json"),
content: to_string_pretty(&full_package_json)?,
context: Some(ERROR_FAILED_TO_UPDATE_APPLICATION_PACKAGE_JSON.to_string()),
}));
}
}
let scripts = full_package_json.scripts.as_mut().unwrap();
let database = match manifest_data {
ManifestData::Service(service_data) => service_data.database.to_string(),
ManifestData::Worker(worker_data) => worker_data.database.clone().unwrap(),
_ => bail!(ERROR_UNSUPPORTED_DATABASE),
};
let is_mongo = match manifest_data {
ManifestData::Service(service_data) => service_data.is_mongo,
ManifestData::Worker(worker_data) => worker_data.is_mongo,
_ => false,
};
let update_docker_cmd = |cmd: &str| {
let (mut services, rest_cmd) = split_compose_up_command(cmd);
if !services.contains(&database) {
services.push(database.clone());
if is_mongo {
services.push("mongo-init".to_string());
}
}
let new_docker_cmd = compose_up_command(&services);
if rest_cmd.is_empty() {
new_docker_cmd
} else {
format!("{} && {}", new_docker_cmd, rest_cmd)
}
};
if let Some(init_script) = &scripts.migrate_init {
scripts.migrate_init = Some(update_docker_cmd(&init_script));
}
if let Some(create_script) = &scripts.migrate_create {
scripts.migrate_create = Some(update_docker_cmd(&create_script));
}
if let Some(up_script) = &scripts.migrate_up {
scripts.migrate_up = Some(update_docker_cmd(&up_script));
}
if let Some(down_script) = &scripts.migrate_down {
scripts.migrate_down = Some(update_docker_cmd(&down_script));
}
if let Some(seed_script) = &scripts.seed {
scripts.seed = Some(update_docker_cmd(&seed_script));
}
Ok(Some(
RenderedTemplate {
path: base_path.join("package.json"),
content: to_string_pretty(&full_package_json)?,
context: Some(ERROR_FAILED_TO_UPDATE_APPLICATION_PACKAGE_JSON.to_string()),
}
.preserving_json_layout(),
))
}
pub(crate) fn replace_project_in_workspace_definition(
application_base_path: &Path,
application_package_json: &mut ApplicationPackageJson,
runtime: &Runtime,
existing_project_name: &str,
new_project_name: &str,
rendered_templates_cache: &mut RenderedTemplatesCache,
) -> Result<()> {
match runtime {
Runtime::Bun => {
if let Some(workspaces) = application_package_json.workspaces.as_mut() {
workspaces.remove(
workspaces
.iter()
.position(|name| name == existing_project_name)
.unwrap(),
);
workspaces.push(new_project_name.to_string());
}
}
Runtime::Node => {
let workspace_definition = rendered_templates_cache
.get(Path::new(application_base_path).join("pnpm-workspace.yaml"))?
.unwrap()
.content;
let mut workspace_definition: PnpmWorkspace =
serde_yml::from_str(&workspace_definition)
.with_context(|| ERROR_FAILED_TO_PARSE_PACKAGE_JSON)?;
workspace_definition.packages.remove(
workspace_definition
.packages
.iter()
.position(|name| name == existing_project_name)
.unwrap(),
);
workspace_definition
.packages
.push(new_project_name.to_string());
rendered_templates_cache.insert(
application_base_path
.join("pnpm-workspace.yaml")
.to_string_lossy(),
RenderedTemplate {
path: application_base_path.join("pnpm-workspace.yaml"),
content: serde_yml::to_string(&workspace_definition)?,
context: None,
},
);
}
}
Ok(())
}
pub(crate) fn remove_project_definition_to_package_json(
base_path: &Path,
project_name: &str,
) -> Result<String> {
let mut full_package_json: ApplicationPackageJson = from_str(
&read_to_string(base_path.join("package.json"))
.with_context(|| ERROR_FAILED_TO_READ_PACKAGE_JSON)?,
)
.with_context(|| ERROR_FAILED_TO_PARSE_PACKAGE_JSON)?;
if let Some(workspaces) = full_package_json.workspaces.as_mut() {
if let Some(position) = workspaces.iter().position(|name| name == project_name) {
workspaces.remove(position);
}
}
Ok(to_string_pretty(&full_package_json)
.with_context(|| ERROR_FAILED_TO_ADD_PROJECT_METADATA_TO_PACKAGE_JSON)?)
}
#[cfg(test)]
mod layout_tests {
use super::preserve_json_layout;
#[test]
fn unchanged_content_keeps_its_bytes_and_changes_keep_key_order() {
let original = "{\n \"name\": \"a\",\n \"dependencies\": {\"@app/core\": \"workspace:*\", \"zod\": \"1\"}\n}\n";
let same =
"{\"dependencies\":{\"zod\":\"1\",\"@app/core\":\"workspace:*\"},\"name\":\"a\"}";
assert_eq!(preserve_json_layout(original, same), original);
let changed = "{\"dependencies\":{\"zod\":\"2\",\"@app/core\":\"workspace:*\",\"uuid\":\"1\"},\"name\":\"a\"}";
assert_eq!(
preserve_json_layout(original, changed),
"{\n \"name\": \"a\",\n \"dependencies\": {\n \"@app/core\": \"workspace:*\",\n \"uuid\": \"1\",\n \"zod\": \"2\"\n }\n}\n"
);
}
}