use std::path::Path;
use miette::{Context, IntoDiagnostic, miette};
pub(crate) fn load_manifest(manifest_path: &Path) -> miette::Result<aube_manifest::PackageJson> {
aube_manifest::PackageJson::from_path(manifest_path)
.map_err(miette::Report::new)
.wrap_err("failed to read package.json")
}
pub(crate) fn load_manifest_or_default(root: &Path) -> miette::Result<aube_manifest::PackageJson> {
let path = root.join("package.json");
if path.is_file() {
load_manifest(&path)
} else {
Ok(aube_manifest::PackageJson::default())
}
}
pub(crate) fn write_manifest_json<T: serde::Serialize>(
path: &Path,
value: &T,
) -> miette::Result<()> {
let existing = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => {
return Err(error)
.into_diagnostic()
.wrap_err("failed to read package.json");
}
};
let json = aube_manifest::serialize_json_with_indent(
value,
aube_manifest::detect_json_indent(&existing),
)
.into_diagnostic()
.wrap_err("failed to serialize package.json")?;
write_manifest_atomic(path, format!("{json}\n").as_bytes())
.wrap_err("failed to write package.json")
}
pub(crate) fn update_manifest_json_object<F>(path: &Path, update: F) -> miette::Result<()>
where
F: FnOnce(&mut serde_json::Map<String, serde_json::Value>) -> miette::Result<()>,
{
let content = std::fs::read_to_string(path)
.into_diagnostic()
.wrap_err("failed to read package.json")?;
let indent = aube_manifest::detect_json_indent(&content).to_string();
let mut json: serde_json::Value = serde_json::from_str(&content)
.into_diagnostic()
.wrap_err("failed to parse package.json")?;
let serde_json::Value::Object(obj) = &mut json else {
return Err(miette!("package.json must contain a JSON object"));
};
update(obj)?;
let json = aube_manifest::serialize_json_with_indent(&json, &indent)
.into_diagnostic()
.wrap_err("failed to serialize package.json")?;
write_manifest_atomic(path, format!("{json}\n").as_bytes())
}
pub(crate) fn write_manifest_dep_sections(
path: &Path,
manifest: &aube_manifest::PackageJson,
) -> miette::Result<()> {
update_manifest_json_object(path, |obj| {
sync_manifest_dep_sections(obj, manifest);
Ok(())
})
}
fn sync_manifest_dep_sections(
obj: &mut serde_json::Map<String, serde_json::Value>,
manifest: &aube_manifest::PackageJson,
) {
sync_dep_section(obj, "dependencies", &manifest.dependencies);
sync_dep_section(obj, "devDependencies", &manifest.dev_dependencies);
sync_dep_section(obj, "peerDependencies", &manifest.peer_dependencies);
sync_dep_section(obj, "optionalDependencies", &manifest.optional_dependencies);
}
fn sync_dep_section(
obj: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
deps: &std::collections::BTreeMap<String, String>,
) {
if deps.is_empty() {
obj.remove(key);
return;
}
let section = deps
.iter()
.map(|(name, spec)| (name.clone(), serde_json::Value::String(spec.clone())))
.collect();
obj.insert(key.to_string(), serde_json::Value::Object(section));
}
fn write_manifest_atomic(path: &Path, body: &[u8]) -> miette::Result<()> {
aube_util::fs_atomic::atomic_write(path, body)
.into_diagnostic()
.wrap_err_with(|| format!("failed to write {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn write_manifest_dep_sections_preserves_existing_top_level_order() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("package.json");
std::fs::write(
&path,
r#"{
"name": "example",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"test": "echo test"
},
"devDependencies": {
"typescript": "^6.0.3"
}
}
"#,
)
.unwrap();
let mut manifest = aube_manifest::PackageJson::from_path(&path).unwrap();
manifest
.dev_dependencies
.insert("tstyche".to_string(), "^7.1.0".to_string());
write_manifest_dep_sections(&path, &manifest).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(
root_key_order(&written),
["name", "version", "license", "scripts", "devDependencies"]
);
assert!(written.contains(r#""tstyche": "^7.1.0""#));
}
#[test]
fn write_manifest_dep_sections_removes_empty_sections_without_reordering() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("package.json");
std::fs::write(
&path,
r#"{
"name": "example",
"devDependencies": {
"typescript": "^6.0.3"
},
"license": "MIT"
}
"#,
)
.unwrap();
let mut manifest = aube_manifest::PackageJson::from_path(&path).unwrap();
manifest.dev_dependencies.remove("typescript");
write_manifest_dep_sections(&path, &manifest).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(root_key_order(&written), ["name", "license"]);
assert!(!written.contains("devDependencies"));
}
#[test]
fn write_manifest_json_rejects_invalid_utf8_without_overwriting() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("package.json");
let invalid_utf8 = [0xff];
std::fs::write(&path, invalid_utf8).unwrap();
let result = write_manifest_json(&path, &serde_json::json!({ "name": "example" }));
assert!(result.is_err());
assert_eq!(std::fs::read(&path).unwrap(), invalid_utf8);
}
#[test]
fn write_manifest_dep_sections_preserves_indentation() {
let dir = tempfile::tempdir().unwrap();
for (name, indent) in [
("tabs", "\t"),
("four-spaces", " "),
("three-spaces", " "),
] {
let path = dir.path().join(format!("package-{name}.json"));
std::fs::write(&path, format!("{{\n{indent}\"name\": \"{name}\"\n}}\n")).unwrap();
let mut manifest = aube_manifest::PackageJson::from_path(&path).unwrap();
manifest
.dependencies
.insert("foo".to_string(), "1.0.0".to_string());
write_manifest_dep_sections(&path, &manifest).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
format!(
"{{\n{indent}\"name\": \"{name}\",\n{indent}\"dependencies\": {{\n{indent}{indent}\"foo\": \"1.0.0\"\n{indent}}}\n}}\n"
)
);
}
}
fn root_key_order(raw: &str) -> Vec<String> {
let serde_json::Value::Object(obj) = serde_json::from_str(raw).unwrap() else {
panic!("expected object");
};
obj.keys().cloned().collect()
}
}