use chrono::Utc;
use nono::{NonoError, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_yaml_ng as yaml;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::os::unix::fs as unix_fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WiringDirective {
#[serde(skip_serializing)]
Skipped,
Symlink { link: String, target: String },
WriteFile { source: String, dest: String },
JsonMerge { file: String, patch: String },
JsonArrayAppend {
file: String,
path: String,
patch_entries: String,
key_field: String,
},
TomlBlock {
file: String,
marker_id: String,
content: String,
},
YamlMerge { file: String, patch: String },
}
impl<'de> Deserialize<'de> for WiringDirective {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let mut value = serde_json::Value::deserialize(deserializer)?;
if let serde_json::Value::Object(object) = &mut value
&& let Some(when_value) = object.remove("when")
{
let when =
crate::platform::When::deserialize(when_value).map_err(serde::de::Error::custom)?;
if !crate::platform::when_matches_current(Some(&when))
.map_err(serde::de::Error::custom)?
{
return Ok(Self::Skipped);
}
}
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum RawWiringDirective {
Symlink {
link: String,
target: String,
},
WriteFile {
source: String,
dest: String,
},
JsonMerge {
file: String,
patch: String,
},
JsonArrayAppend {
file: String,
path: String,
patch_entries: String,
key_field: String,
},
TomlBlock {
file: String,
marker_id: String,
content: String,
},
YamlMerge {
file: String,
patch: String,
},
}
let raw = serde_json::from_value::<RawWiringDirective>(value)
.map_err(serde::de::Error::custom)?;
Ok(match raw {
RawWiringDirective::Symlink { link, target } => Self::Symlink { link, target },
RawWiringDirective::WriteFile { source, dest } => Self::WriteFile { source, dest },
RawWiringDirective::JsonMerge { file, patch } => Self::JsonMerge { file, patch },
RawWiringDirective::JsonArrayAppend {
file,
path,
patch_entries,
key_field,
} => Self::JsonArrayAppend {
file,
path,
patch_entries,
key_field,
},
RawWiringDirective::TomlBlock {
file,
marker_id,
content,
} => Self::TomlBlock {
file,
marker_id,
content,
},
RawWiringDirective::YamlMerge { file, patch } => Self::YamlMerge { file, patch },
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum WiringRecord {
Symlink { link: String },
WriteFile { dest: String, sha256: String },
JsonMerge {
file: String,
leaves: Vec<JsonLeaf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
created_parents: Vec<Vec<String>>,
},
JsonArrayAppend {
file: String,
path: String,
key_field: String,
entries: Vec<AppendedEntry>,
},
TomlBlock { file: String, marker_id: String },
YamlMerge {
file: String,
leaves: Vec<JsonLeaf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
created_parents: Vec<Vec<String>>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct JsonLeaf {
pub path: Vec<String>,
pub installed_value: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prior_value: Option<Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct AppendedEntry {
pub key: String,
pub installed: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prior: Option<Value>,
}
#[derive(Debug, Clone)]
pub struct ReversalFailure {
#[allow(dead_code)]
pub record_index: usize,
pub record_summary: String,
pub error: String,
}
#[derive(Debug)]
pub struct WiringContext {
pub pack_dir: PathBuf,
pub namespace: String,
pub pack_name: String,
}
#[derive(Debug, Default)]
pub struct WiringReport {
pub records: Vec<WiringRecord>,
pub conflicts: Vec<String>,
pub changed: bool,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct ExecuteOptions {
pub allow_unmanaged_identical_write_files: bool,
}
pub fn execute(
directives: &[WiringDirective],
ctx: &WiringContext,
pack_owned_files: &HashMap<PathBuf, String>,
) -> Result<WiringReport> {
execute_with_options(directives, ctx, pack_owned_files, ExecuteOptions::default())
}
pub fn execute_with_options(
directives: &[WiringDirective],
ctx: &WiringContext,
pack_owned_files: &HashMap<PathBuf, String>,
options: ExecuteOptions,
) -> Result<WiringReport> {
let mut report = WiringReport::default();
for directive in directives {
execute_one(directive, ctx, pack_owned_files, options, &mut report)?;
}
Ok(report)
}
fn execute_one(
directive: &WiringDirective,
ctx: &WiringContext,
pack_owned_files: &HashMap<PathBuf, String>,
options: ExecuteOptions,
report: &mut WiringReport,
) -> Result<()> {
match directive {
WiringDirective::Skipped => {}
WiringDirective::Symlink { link, target } => {
let link_path = expand_to_path(link, ctx)?;
let target_path = expand_to_path(target, ctx)?;
match ensure_symlink(&link_path, &target_path)? {
SymlinkOutcome::Created | SymlinkOutcome::Repointed => {
report.changed = true;
report.records.push(WiringRecord::Symlink {
link: link_path.to_string_lossy().into_owned(),
});
}
SymlinkOutcome::AlreadyCorrect => {
report.records.push(WiringRecord::Symlink {
link: link_path.to_string_lossy().into_owned(),
});
}
SymlinkOutcome::Conflict(msg) => {
report.conflicts.push(msg);
}
}
}
WiringDirective::WriteFile { source, dest } => {
let source_path = pack_relative(source, ctx)?;
let dest_path = expand_to_path(dest, ctx)?;
if dest_path.exists() {
let recorded_hash = pack_owned_files.get(&dest_path);
let current_hash = match fs::read(&dest_path) {
Ok(b) => hash_bytes(&b),
Err(e) => return Err(NonoError::Io(e)),
};
match recorded_hash {
None => {
let source_hash = match fs::read(&source_path) {
Ok(bytes) => hash_bytes(&bytes),
Err(e) => return Err(NonoError::Io(e)),
};
if !options.allow_unmanaged_identical_write_files
|| source_hash != current_hash
{
return Err(NonoError::PackageInstall(format!(
"write_file: refusing to overwrite '{}' — \
file already exists and was not written by a \
managed pack. Move/remove it manually then re-pull.",
dest_path.display()
)));
}
}
Some(prior) if prior != ¤t_hash => {
return Err(NonoError::PackageInstall(format!(
"write_file: refusing to overwrite '{}' — \
file has been edited since the last install \
(sha256 mismatch). Either revert your changes \
then re-pull, or `nono remove` this pack first.",
dest_path.display()
)));
}
_ => {}
}
}
let outcome = copy_file_atomic(&source_path, &dest_path)?;
if outcome.mutated {
report.changed = true;
}
report.records.push(WiringRecord::WriteFile {
dest: dest_path.to_string_lossy().into_owned(),
sha256: outcome.sha256,
});
}
WiringDirective::JsonMerge { file, patch } => {
let file_path = expand_to_path(file, ctx)?;
let patch_path = pack_relative(patch, ctx)?;
let patch_value = read_pack_json(&patch_path, ctx)?;
let outcome = merge_json_into_file(&file_path, &patch_value)?;
if outcome.leaves.iter().any(leaf_changed_disk) || !outcome.created_parents.is_empty() {
report.changed = true;
}
report.records.push(WiringRecord::JsonMerge {
file: file_path.to_string_lossy().into_owned(),
leaves: outcome.leaves,
created_parents: outcome.created_parents,
});
}
WiringDirective::JsonArrayAppend {
file,
path,
patch_entries,
key_field,
} => {
let file_path = expand_to_path(file, ctx)?;
let entries_path = pack_relative(patch_entries, ctx)?;
let entries_value = read_pack_json(&entries_path, ctx)?;
let entries = entries_value.as_array().ok_or_else(|| {
NonoError::PackageInstall(format!(
"json_array_append: {} must be a JSON array",
entries_path.display()
))
})?;
let outcome = append_json_entries(&file_path, path, entries, key_field)?;
if outcome.mutated {
report.changed = true;
}
report.records.push(WiringRecord::JsonArrayAppend {
file: file_path.to_string_lossy().into_owned(),
path: path.clone(),
key_field: key_field.clone(),
entries: outcome.entries,
});
}
WiringDirective::TomlBlock {
file,
marker_id,
content,
} => {
let file_path = expand_to_path(file, ctx)?;
let content_path = pack_relative(content, ctx)?;
let raw_body = fs::read_to_string(&content_path).map_err(NonoError::Io)?;
let body = expand_vars(&raw_body, ctx)?;
let changed = upsert_toml_block(&file_path, marker_id, &body)?;
if changed {
report.changed = true;
}
report.records.push(WiringRecord::TomlBlock {
file: file_path.to_string_lossy().into_owned(),
marker_id: marker_id.clone(),
});
}
WiringDirective::YamlMerge { file, patch } => {
let file_path = expand_to_path(file, ctx)?;
let patch_path = pack_relative(patch, ctx)?;
let patch_value = read_pack_yaml(&patch_path, ctx)?;
let outcome = merge_yaml_into_file(&file_path, &patch_value)?;
if outcome.leaves.iter().any(leaf_changed_disk) || !outcome.created_parents.is_empty() {
report.changed = true;
}
report.records.push(WiringRecord::YamlMerge {
file: file_path.to_string_lossy().into_owned(),
leaves: outcome.leaves,
created_parents: outcome.created_parents,
});
}
}
Ok(())
}
pub fn reverse(records: &[WiringRecord]) -> Vec<ReversalFailure> {
let mut failures = Vec::new();
for (i, record) in records.iter().enumerate().rev() {
if let Err(e) = reverse_one(record) {
failures.push(ReversalFailure {
record_index: i,
record_summary: summarise_record(record),
error: e.to_string(),
});
}
}
failures
}
fn summarise_record(record: &WiringRecord) -> String {
match record {
WiringRecord::Symlink { link } => format!("symlink {link}"),
WiringRecord::WriteFile { dest, .. } => format!("write_file {dest}"),
WiringRecord::JsonMerge { file, .. } => format!("json_merge {file}"),
WiringRecord::JsonArrayAppend { file, path, .. } => {
format!("json_array_append {file} at {path}")
}
WiringRecord::TomlBlock { file, marker_id } => {
format!("toml_block {file} #{marker_id}")
}
WiringRecord::YamlMerge { file, .. } => format!("yaml_merge {file}"),
}
}
fn reverse_one(record: &WiringRecord) -> Result<()> {
match record {
WiringRecord::Symlink { link } => {
let path = Path::new(link);
if let Ok(meta) = path.symlink_metadata()
&& meta.file_type().is_symlink()
{
fs::remove_file(path).map_err(NonoError::Io)?;
}
}
WiringRecord::WriteFile { dest, sha256 } => {
let path = Path::new(dest);
if !path.exists() {
return Ok(());
}
let bytes = fs::read(path).map_err(NonoError::Io)?;
if hash_bytes(&bytes) != *sha256 {
tracing::info!(
"write_file: leaving '{}' in place — content has been \
modified since install (sha256 mismatch).",
path.display()
);
return Ok(());
}
fs::remove_file(path).map_err(NonoError::Io)?;
}
WiringRecord::JsonMerge {
file,
leaves,
created_parents,
} => {
restore_json_leaves(Path::new(file), leaves, created_parents)?;
}
WiringRecord::JsonArrayAppend {
file,
path,
key_field,
entries,
} => {
restore_json_array_entries(Path::new(file), path, key_field, entries)?;
}
WiringRecord::TomlBlock { file, marker_id } => {
strip_toml_block(Path::new(file), marker_id)?;
}
WiringRecord::YamlMerge {
file,
leaves,
created_parents,
} => {
restore_yaml_leaves(Path::new(file), leaves, created_parents)?;
}
}
Ok(())
}
fn hash_bytes(bytes: &[u8]) -> String {
let mut h = Sha256::new();
h.update(bytes);
let digest = h.finalize();
let mut out = String::with_capacity(64);
for byte in digest.iter() {
use std::fmt::Write;
let _ = write!(&mut out, "{byte:02x}");
}
out
}
fn leaf_changed_disk(leaf: &JsonLeaf) -> bool {
leaf.prior_value.as_ref() != Some(&leaf.installed_value)
}
fn expand_to_path(template: &str, ctx: &WiringContext) -> Result<PathBuf> {
let expanded = expand_vars(template, ctx)?;
let path = PathBuf::from(&expanded);
if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(NonoError::PackageInstall(format!(
"wiring path contains '..': '{template}'"
)));
}
Ok(path)
}
fn pack_relative(rel: &str, ctx: &WiringContext) -> Result<PathBuf> {
let p = Path::new(rel);
if p.is_absolute() {
return Err(NonoError::PackageInstall(format!(
"wiring source must be pack-relative, got '{rel}'"
)));
}
if p.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(NonoError::PackageInstall(format!(
"wiring source contains '..': '{rel}'"
)));
}
Ok(ctx.pack_dir.join(p))
}
fn expand_vars(template: &str, ctx: &WiringContext) -> Result<String> {
let home = xdg_home::home_dir()
.ok_or_else(|| NonoError::PackageInstall("HOME not set".to_string()))?;
let xdg_config_home = std::env::var("XDG_CONFIG_HOME")
.ok()
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".config"));
let nono_config = crate::package::nono_config_dir()
.map_err(|e| NonoError::PackageInstall(format!("failed to resolve $NONO_CONFIG: {e}")))?;
let nono_packages = crate::package::package_store_dir()
.map_err(|e| NonoError::PackageInstall(format!("failed to resolve $NONO_PACKAGES: {e}")))?;
let pack_dir = ctx.pack_dir.to_string_lossy().into_owned();
let home_str = home.to_string_lossy().into_owned();
let xdg_str = xdg_config_home.to_string_lossy().into_owned();
let nono_config_str = nono_config.to_string_lossy().into_owned();
let nono_packages_str = nono_packages.to_string_lossy().into_owned();
let mut out = String::with_capacity(template.len());
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' {
out.push(c);
continue;
}
match chars.peek() {
Some(&p) if p.is_ascii_uppercase() || p == '_' => {}
_ => {
out.push('$');
continue;
}
}
let mut name = String::new();
while let Some(&peek) = chars.peek() {
if peek.is_ascii_alphanumeric() || peek == '_' {
name.push(peek);
chars.next();
} else {
break;
}
}
let value = match name.as_str() {
"PACK_DIR" => pack_dir.clone(),
"NS" => ctx.namespace.clone(),
"PLUGIN" => ctx.pack_name.clone(),
"HOME" => home_str.clone(),
"XDG_CONFIG_HOME" => xdg_str.clone(),
"NONO_CONFIG" => nono_config_str.clone(),
"NONO_PACKAGES" => nono_packages_str.clone(),
"NOW" => Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
other => {
return Err(NonoError::PackageInstall(format!(
"wiring template references unknown variable '${other}' in '{template}'"
)));
}
};
out.push_str(&value);
}
Ok(out)
}
enum SymlinkOutcome {
Created,
Repointed,
AlreadyCorrect,
Conflict(String),
}
fn ensure_symlink(link: &Path, target: &Path) -> Result<SymlinkOutcome> {
if let Some(parent) = link.parent() {
fs::create_dir_all(parent).map_err(NonoError::Io)?;
}
match link.symlink_metadata() {
Ok(meta) => {
if !meta.file_type().is_symlink() {
return Ok(SymlinkOutcome::Conflict(format!(
"{} exists and is not a nono-managed symlink — leaving it alone",
link.display()
)));
}
let current = fs::read_link(link).map_err(NonoError::Io)?;
if current == target {
return Ok(SymlinkOutcome::AlreadyCorrect);
}
fs::remove_file(link).map_err(NonoError::Io)?;
unix_fs::symlink(target, link).map_err(NonoError::Io)?;
Ok(SymlinkOutcome::Repointed)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
unix_fs::symlink(target, link).map_err(NonoError::Io)?;
Ok(SymlinkOutcome::Created)
}
Err(e) => Err(NonoError::Io(e)),
}
}
struct CopyOutcome {
mutated: bool,
sha256: String,
}
fn copy_file_atomic(source: &Path, dest: &Path) -> Result<CopyOutcome> {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(NonoError::Io)?;
}
let source_bytes = fs::read(source).map_err(NonoError::Io)?;
let sha256 = hash_bytes(&source_bytes);
if let Ok(existing) = fs::read(dest)
&& existing == source_bytes
{
return Ok(CopyOutcome {
mutated: false,
sha256,
});
}
let tmp = dest.with_extension("nono-tmp");
fs::write(&tmp, &source_bytes).map_err(NonoError::Io)?;
fs::rename(&tmp, dest).map_err(NonoError::Io)?;
Ok(CopyOutcome {
mutated: true,
sha256,
})
}
fn read_json(path: &Path) -> Result<Value> {
let content = fs::read_to_string(path).map_err(NonoError::Io)?;
crate::jsonc::parse(&content)
.map_err(|e| NonoError::PackageInstall(format!("invalid JSON in {}: {e}", path.display())))
}
fn read_pack_json(path: &Path, ctx: &WiringContext) -> Result<Value> {
let mut value = read_json(path)?;
expand_json_strings(&mut value, ctx)?;
Ok(value)
}
fn expand_json_strings(value: &mut Value, ctx: &WiringContext) -> Result<()> {
match value {
Value::String(s) if s.contains('$') => {
*s = expand_vars(s, ctx)?;
}
Value::String(_) => {}
Value::Array(arr) => {
for v in arr.iter_mut() {
expand_json_strings(v, ctx)?;
}
}
Value::Object(obj) => {
for (_k, v) in obj.iter_mut() {
expand_json_strings(v, ctx)?;
}
}
_ => {}
}
Ok(())
}
struct MergeOutcome {
leaves: Vec<JsonLeaf>,
created_parents: Vec<Vec<String>>,
}
fn merge_json_into_file(file: &Path, patch: &Value) -> Result<MergeOutcome> {
let mut existing = if file.exists() {
read_json(file)?
} else {
Value::Object(serde_json::Map::new())
};
if !patch.is_object() || !existing.is_object() {
return Err(NonoError::PackageInstall(format!(
"json_merge: {} must be a JSON object at the root",
file.display()
)));
}
let before = existing.clone();
let mut leaves = Vec::new();
let mut created_parents = Vec::new();
walk_and_merge(
&mut existing,
patch,
&mut Vec::new(),
&mut leaves,
&mut created_parents,
);
if existing != before {
write_json(file, &existing)?;
}
Ok(MergeOutcome {
leaves,
created_parents,
})
}
fn walk_and_merge(
target: &mut Value,
patch: &Value,
path: &mut Vec<String>,
leaves: &mut Vec<JsonLeaf>,
created_parents: &mut Vec<Vec<String>>,
) {
if let (Value::Object(dst), Value::Object(src)) = (target, patch) {
for (k, v) in src {
path.push(k.clone());
if v.is_object() {
let parent_existed = dst.contains_key(k);
let entry = dst
.entry(k.clone())
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if !entry.is_object() {
let prior = Some(entry.clone());
*entry = v.clone();
leaves.push(JsonLeaf {
path: path.clone(),
installed_value: v.clone(),
prior_value: prior,
});
} else {
if !parent_existed {
created_parents.push(path.clone());
}
walk_and_merge(entry, v, path, leaves, created_parents);
}
} else {
let prior = dst.get(k).cloned();
dst.insert(k.clone(), v.clone());
leaves.push(JsonLeaf {
path: path.clone(),
installed_value: v.clone(),
prior_value: prior,
});
}
path.pop();
}
}
}
struct AppendOutcome {
entries: Vec<AppendedEntry>,
mutated: bool,
}
fn append_json_entries(
file: &Path,
path: &str,
entries: &[Value],
key_field: &str,
) -> Result<AppendOutcome> {
let mut doc = if file.exists() {
read_json(file)?
} else {
Value::Object(serde_json::Map::new())
};
let before = doc.clone();
let array = ensure_array_at(&mut doc, path)?;
let mut applied = Vec::new();
for entry in entries {
let Some(key) = extract_string_at(entry, key_field) else {
return Err(NonoError::PackageInstall(format!(
"json_array_append: entry has no string at key_field '{key_field}'"
)));
};
let key_owned = key.to_string();
let mut prior_value: Option<Value> = None;
let mut replaced = false;
for existing in array.iter_mut() {
if extract_string_at(existing, key_field) == Some(key) {
prior_value = Some(existing.clone());
*existing = entry.clone();
replaced = true;
break;
}
}
if !replaced {
array.push(entry.clone());
}
applied.push(AppendedEntry {
key: key_owned,
installed: entry.clone(),
prior: prior_value,
});
}
let mutated = doc != before;
if mutated {
write_json(file, &doc)?;
}
Ok(AppendOutcome {
entries: applied,
mutated,
})
}
fn extract_string_at<'a>(value: &'a Value, path: &str) -> Option<&'a str> {
let mut cursor = value;
for seg in path.split('.') {
cursor = if let Ok(idx) = seg.parse::<usize>() {
cursor.as_array().and_then(|arr| arr.get(idx))?
} else {
cursor.as_object().and_then(|obj| obj.get(seg))?
};
}
cursor.as_str()
}
fn ensure_array_at<'a>(doc: &'a mut Value, path: &str) -> Result<&'a mut Vec<Value>> {
let segments: Vec<&str> = path.split('.').collect();
let mut cursor = doc;
for (i, seg) in segments.iter().enumerate() {
let is_last = i == segments.len() - 1;
let obj = cursor.as_object_mut().ok_or_else(|| {
NonoError::PackageInstall(format!("json_array_append: '{path}' traverses non-object"))
})?;
if is_last {
let entry = obj
.entry(seg.to_string())
.or_insert_with(|| Value::Array(Vec::new()));
return entry.as_array_mut().ok_or_else(|| {
NonoError::PackageInstall(format!("json_array_append: '{path}' is not an array"))
});
}
cursor = obj
.entry(seg.to_string())
.or_insert_with(|| Value::Object(serde_json::Map::new()));
}
unreachable!("segments has at least one element")
}
fn write_json(path: &Path, value: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(NonoError::Io)?;
}
let pretty = serde_json::to_string_pretty(value)
.map_err(|e| NonoError::PackageInstall(format!("serialize {}: {e}", path.display())))?;
let tmp = path.with_extension("json.nono-tmp");
fs::write(&tmp, format!("{pretty}\n")).map_err(NonoError::Io)?;
fs::rename(&tmp, path).map_err(NonoError::Io)?;
Ok(())
}
fn restore_json_leaves(
file: &Path,
leaves: &[JsonLeaf],
created_parents: &[Vec<String>],
) -> Result<()> {
if !file.exists() {
return Ok(());
}
let mut doc = read_json(file)?;
let mut changed = false;
for leaf in leaves.iter().rev() {
if restore_one_leaf(
&mut doc,
&leaf.path,
&leaf.installed_value,
&leaf.prior_value,
) {
changed = true;
}
}
let mut sorted_parents: Vec<&Vec<String>> = created_parents.iter().collect();
sorted_parents.sort_by_key(|p| std::cmp::Reverse(p.len()));
for parent_path in sorted_parents {
if prune_empty_object_at(&mut doc, parent_path) {
changed = true;
}
}
if changed {
write_json(file, &doc)?;
}
Ok(())
}
fn prune_empty_object_at(doc: &mut Value, path: &[String]) -> bool {
if path.is_empty() {
return false;
}
let mut cursor: &mut Value = doc;
for seg in &path[..path.len() - 1] {
let Some(next) = cursor.as_object_mut().and_then(|o| o.get_mut(seg)) else {
return false;
};
cursor = next;
}
let Some(parent) = cursor.as_object_mut() else {
return false;
};
let leaf_key = &path[path.len() - 1];
let is_empty_object = parent
.get(leaf_key)
.and_then(Value::as_object)
.map(|o| o.is_empty())
.unwrap_or(false);
if is_empty_object {
parent.remove(leaf_key);
true
} else {
false
}
}
fn restore_one_leaf(
doc: &mut Value,
path: &[String],
installed: &Value,
prior: &Option<Value>,
) -> bool {
if path.is_empty() {
return false;
}
let mut cursor: &mut Value = doc;
for seg in &path[..path.len() - 1] {
let Some(next) = cursor.as_object_mut().and_then(|o| o.get_mut(seg)) else {
return false;
};
cursor = next;
}
let Some(parent) = cursor.as_object_mut() else {
return false;
};
let leaf_key = &path[path.len() - 1];
match parent.get(leaf_key) {
None => false,
Some(current) if current == installed => match prior {
Some(p) => {
parent.insert(leaf_key.clone(), p.clone());
true
}
None => {
parent.remove(leaf_key);
true
}
},
Some(_) => false, }
}
fn restore_json_array_entries(
file: &Path,
path: &str,
key_field: &str,
entries: &[AppendedEntry],
) -> Result<()> {
if !file.exists() {
return Ok(());
}
let mut doc = read_json(file)?;
let Ok(array) = ensure_array_at(&mut doc, path) else {
return Ok(());
};
let mut changed = false;
let by_key: BTreeMap<&str, (&Value, Option<&Value>)> = entries
.iter()
.map(|e| (e.key.as_str(), (&e.installed, e.prior.as_ref())))
.collect();
let mut new_array: Vec<Value> = Vec::with_capacity(array.len());
for entry in array.drain(..) {
let key = extract_string_at(&entry, key_field);
let action = key.and_then(|k| by_key.get(k).copied());
match action {
Some((installed, prior)) if &entry == installed => {
if let Some(p) = prior {
new_array.push((*p).clone());
}
changed = true;
}
_ => new_array.push(entry), }
}
*array = new_array;
if changed {
write_json(file, &doc)?;
}
Ok(())
}
fn yaml_to_json(v: yaml::Value) -> Result<Value> {
match v {
yaml::Value::Null => Ok(Value::Null),
yaml::Value::Bool(b) => Ok(Value::Bool(b)),
yaml::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(Value::Number(i.into()))
} else if let Some(u) = n.as_u64() {
Ok(Value::Number(u.into()))
} else if let Some(f) = n.as_f64() {
serde_json::Number::from_f64(f)
.map(Value::Number)
.ok_or_else(|| {
NonoError::PackageInstall(
"yaml_merge: non-finite float (.inf/.nan) is not representable \
in the JSON value model; use a string instead"
.to_string(),
)
})
} else {
Err(NonoError::PackageInstall(
"yaml_merge: unrepresentable number in YAML".to_string(),
))
}
}
yaml::Value::String(s) => Ok(Value::String(s)),
yaml::Value::Sequence(seq) => {
let arr: Result<Vec<Value>> = seq.into_iter().map(yaml_to_json).collect();
Ok(Value::Array(arr?))
}
yaml::Value::Mapping(map) => {
let mut obj = serde_json::Map::new();
for (k, v) in map {
let key = match k {
yaml::Value::String(s) => s,
other => {
return Err(NonoError::PackageInstall(format!(
"yaml_merge: mapping key must be a string, got {:?}",
other
)));
}
};
obj.insert(key, yaml_to_json(v)?);
}
Ok(Value::Object(obj))
}
yaml::Value::Tagged(_) => Err(NonoError::PackageInstall(
"yaml_merge: custom YAML tags are not supported".to_string(),
)),
}
}
fn json_to_yaml(v: Value) -> yaml::Value {
match v {
Value::Null => yaml::Value::Null,
Value::Bool(b) => yaml::Value::Bool(b),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
yaml::Value::Number(i.into())
} else if let Some(u) = n.as_u64() {
yaml::Value::Number(u.into())
} else if let Some(f) = n.as_f64() {
yaml::Value::Number(f.into())
} else {
yaml::Value::String(n.to_string())
}
}
Value::String(s) => yaml::Value::String(s),
Value::Array(arr) => yaml::Value::Sequence(arr.into_iter().map(json_to_yaml).collect()),
Value::Object(obj) => {
let mut map = yaml::Mapping::new();
for (k, v) in obj {
map.insert(yaml::Value::String(k), json_to_yaml(v));
}
yaml::Value::Mapping(map)
}
}
}
fn read_yaml(path: &Path) -> Result<Value> {
let content = fs::read_to_string(path).map_err(NonoError::Io)?;
let yaml_val: yaml::Value = yaml::from_str(&content).map_err(|e| {
NonoError::PackageInstall(format!("invalid YAML in {}: {e}", path.display()))
})?;
yaml_to_json(yaml_val)
}
fn read_pack_yaml(path: &Path, ctx: &WiringContext) -> Result<Value> {
let mut value = read_yaml(path)?;
expand_json_strings(&mut value, ctx)?;
Ok(value)
}
fn write_yaml(path: &Path, value: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(NonoError::Io)?;
}
let yaml_val = json_to_yaml(value.clone());
let serialized = yaml::to_string(&yaml_val)
.map_err(|e| NonoError::PackageInstall(format!("serialize {}: {e}", path.display())))?;
let tmp = path.with_extension("yaml.nono-tmp");
fs::write(&tmp, &serialized).map_err(NonoError::Io)?;
fs::rename(&tmp, path).map_err(NonoError::Io)?;
Ok(())
}
fn merge_yaml_into_file(file: &Path, patch: &Value) -> Result<MergeOutcome> {
let mut existing = if file.exists() {
read_yaml(file)?
} else {
Value::Object(serde_json::Map::new())
};
if !patch.is_object() || !existing.is_object() {
return Err(NonoError::PackageInstall(format!(
"yaml_merge: {} must be a YAML mapping at the root",
file.display()
)));
}
let before = existing.clone();
let mut leaves = Vec::new();
let mut created_parents = Vec::new();
walk_and_merge(
&mut existing,
patch,
&mut Vec::new(),
&mut leaves,
&mut created_parents,
);
if existing != before {
write_yaml(file, &existing)?;
}
Ok(MergeOutcome {
leaves,
created_parents,
})
}
fn restore_yaml_leaves(
file: &Path,
leaves: &[JsonLeaf],
created_parents: &[Vec<String>],
) -> Result<()> {
if !file.exists() {
return Ok(());
}
let mut doc = read_yaml(file)?;
let mut changed = false;
for leaf in leaves.iter().rev() {
if restore_one_leaf(
&mut doc,
&leaf.path,
&leaf.installed_value,
&leaf.prior_value,
) {
changed = true;
}
}
let mut sorted_parents: Vec<&Vec<String>> = created_parents.iter().collect();
sorted_parents.sort_by_key(|p| std::cmp::Reverse(p.len()));
for parent_path in sorted_parents {
if prune_empty_object_at(&mut doc, parent_path) {
changed = true;
}
}
if changed {
write_yaml(file, &doc)?;
}
Ok(())
}
fn block_markers(marker_id: &str) -> (String, String) {
(
format!("# >>> nono:{marker_id} >>>"),
format!("# <<< nono:{marker_id} <<<"),
)
}
fn upsert_toml_block(file: &Path, marker_id: &str, body: &str) -> Result<bool> {
let (begin, end) = block_markers(marker_id);
let existing = fs::read_to_string(file).unwrap_or_default();
let new_block = format!("{begin}\n{}{end}\n", ensure_trailing_newline(body));
let updated = match find_block_bounds(&existing, &begin, &end) {
Some((s, e)) => {
let mut out = String::with_capacity(existing.len() + new_block.len());
out.push_str(&existing[..s]);
out.push_str(&new_block);
out.push_str(&existing[e..]);
out
}
None => {
let mut out = existing.clone();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str(&new_block);
out
}
};
if updated == existing {
return Ok(false);
}
if let Some(parent) = file.parent() {
fs::create_dir_all(parent).map_err(NonoError::Io)?;
}
let tmp = file.with_extension("toml.nono-tmp");
fs::write(&tmp, &updated).map_err(NonoError::Io)?;
fs::rename(&tmp, file).map_err(NonoError::Io)?;
Ok(true)
}
fn strip_toml_block(file: &Path, marker_id: &str) -> Result<()> {
if !file.exists() {
return Ok(());
}
let (begin, end) = block_markers(marker_id);
let existing = fs::read_to_string(file).map_err(NonoError::Io)?;
let Some((s, e)) = find_block_bounds(&existing, &begin, &end) else {
return Ok(());
};
let mut out = String::with_capacity(existing.len());
out.push_str(&existing[..s]);
out.push_str(&existing[e..]);
if out.ends_with("\n\n") {
out.pop();
}
let tmp = file.with_extension("toml.nono-tmp");
fs::write(&tmp, &out).map_err(NonoError::Io)?;
fs::rename(&tmp, file).map_err(NonoError::Io)?;
Ok(())
}
fn find_block_bounds(content: &str, begin: &str, end: &str) -> Option<(usize, usize)> {
let s = content.find(begin)?;
let end_marker_end = content[s..].find(end).map(|rel| s + rel + end.len())?;
let after = if content[end_marker_end..].starts_with('\n') {
end_marker_end + 1
} else {
end_marker_end
};
Some((s, after))
}
fn ensure_trailing_newline(s: &str) -> String {
if s.ends_with('\n') {
s.to_string()
} else {
format!("{s}\n")
}
}
#[allow(dead_code)]
fn _suppress_unused() {
let _ = (Utc::now, BTreeMap::<String, String>::new);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_env::{ENV_LOCK, EnvVarGuard};
use tempfile::TempDir;
fn ctx_in(home: &Path, pack_dir: PathBuf) -> WiringContext {
let _ = home; WiringContext {
pack_dir,
namespace: "always-further".to_string(),
pack_name: "claude".to_string(),
}
}
fn with_fake_home<F: FnOnce(&Path)>(f: F) {
let _g = match ENV_LOCK.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
let home = TempDir::new().expect("tempdir");
let _env = EnvVarGuard::set_all(&[("HOME", home.path().to_str().expect("utf8"))]);
f(home.path());
}
fn exec(directives: &[WiringDirective], ctx: &WiringContext) -> Result<WiringReport> {
execute(directives, ctx, &HashMap::new())
}
fn rev(records: &[WiringRecord]) {
let failures = reverse(records);
assert!(
failures.is_empty(),
"reverse produced unexpected failures: {failures:?}"
);
}
#[test]
fn expand_vars_substitutes_known_set() {
let pack_dir = PathBuf::from("/p");
let ctx = WiringContext {
pack_dir,
namespace: "ns".to_string(),
pack_name: "name".to_string(),
};
let _g = match ENV_LOCK.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
let _env = EnvVarGuard::set_all(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "__placeholder__")]);
_env.remove("XDG_CONFIG_HOME");
assert_eq!(expand_vars("$PACK_DIR/x", &ctx).expect("expand"), "/p/x");
assert_eq!(expand_vars("$NS/$PLUGIN", &ctx).expect("expand"), "ns/name");
assert_eq!(
expand_vars("$HOME/.config", &ctx).expect("expand"),
"/h/.config"
);
assert_eq!(
expand_vars("$NONO_CONFIG/profile-drafts", &ctx).expect("expand"),
"/h/.config/nono/profile-drafts"
);
assert_eq!(
expand_vars("$NONO_PACKAGES/always-further/claude", &ctx).expect("expand"),
"/h/.config/nono/packages/always-further/claude"
);
}
#[test]
fn expand_vars_nono_config_respects_xdg_config_home() {
let _g = match ENV_LOCK.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
let home = TempDir::new().expect("home tempdir");
let config = TempDir::new().expect("config tempdir");
let home_str = home.path().to_str().expect("home path");
let config_str = config.path().to_str().expect("config path");
let ctx = WiringContext {
pack_dir: PathBuf::from("/p"),
namespace: "always-further".to_string(),
pack_name: "claude".to_string(),
};
let _env = EnvVarGuard::set_all(&[("HOME", home_str), ("XDG_CONFIG_HOME", config_str)]);
let expected_config = format!("{config_str}/nono/profile-drafts");
let expected_packages = format!("{config_str}/nono/packages/always-further/claude");
assert_eq!(
nono::try_canonicalize(Path::new(
&expand_vars("$NONO_CONFIG/profile-drafts", &ctx).expect("expand")
)),
nono::try_canonicalize(Path::new(&expected_config))
);
assert_eq!(
nono::try_canonicalize(Path::new(
&expand_vars("$NONO_PACKAGES/always-further/claude", &ctx).expect("expand")
)),
nono::try_canonicalize(Path::new(&expected_packages))
);
}
#[test]
fn expand_vars_rejects_unknown() {
let ctx = WiringContext {
pack_dir: PathBuf::from("/p"),
namespace: "n".to_string(),
pack_name: "p".to_string(),
};
assert!(expand_vars("$BOGUS/x", &ctx).is_err());
assert_eq!(
expand_vars("trailing $", &ctx).expect("trailing"),
"trailing $"
);
assert_eq!(
expand_vars("^(Bash|apply_patch)$", &ctx).expect("regex"),
"^(Bash|apply_patch)$"
);
assert_eq!(
expand_vars("$lowercase", &ctx).expect("lower"),
"$lowercase"
);
}
#[test]
fn symlink_directive_creates_records_and_reverses() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
let ctx = ctx_in(home, pack.clone());
let directives = vec![WiringDirective::Symlink {
link: "$HOME/link".to_string(),
target: "$PACK_DIR".to_string(),
}];
let report = exec(&directives, &ctx).expect("execute");
assert!(report.changed);
assert_eq!(report.records.len(), 1);
let link = home.join("link");
assert!(
link.symlink_metadata()
.expect("meta")
.file_type()
.is_symlink()
);
assert_eq!(fs::read_link(&link).expect("readlink"), pack);
rev(&report.records);
assert!(link.symlink_metadata().is_err());
});
}
#[test]
fn symlink_directive_is_idempotent() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::Symlink {
link: "$HOME/link".to_string(),
target: "$PACK_DIR".to_string(),
}];
let _ = exec(&directives, &ctx).expect("first");
let r2 = exec(&directives, &ctx).expect("second");
assert!(!r2.changed, "second run should be no-op");
});
}
#[test]
fn json_merge_and_strip_round_trip() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(
pack.join("patch.json"),
r#"{ "enabledPlugins": { "nono": true } }"#,
)
.expect("write patch");
let target = home.join("settings.json");
fs::write(&target, r#"{ "effortLevel": "xhigh" }"#).expect("seed target");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::JsonMerge {
file: "$HOME/settings.json".to_string(),
patch: "patch.json".to_string(),
}];
let report = exec(&directives, &ctx).expect("execute");
let v: Value =
serde_json::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
assert_eq!(v["effortLevel"], "xhigh", "preserve unrelated keys");
assert_eq!(v["enabledPlugins"]["nono"], true);
rev(&report.records);
let v2: Value =
serde_json::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
assert_eq!(v2["effortLevel"], "xhigh", "unrelated keys still present");
assert!(v2.get("enabledPlugins").is_none(), "merged keys gone");
});
}
#[test]
fn json_merge_accepts_jsonc_in_target_and_patch() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(
pack.join("patch.json"),
r#"{
// Pack patches may carry comments too.
"enabledPlugins": {
"nono": true,
},
}"#,
)
.expect("write patch");
let target = home.join("settings.json");
fs::write(
&target,
r#"{
// User config comments must not break package wiring.
"effortLevel": "xhigh",
}"#,
)
.expect("seed target");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::JsonMerge {
file: "$HOME/settings.json".to_string(),
patch: "patch.json".to_string(),
}];
let report = exec(&directives, &ctx).expect("execute");
let v: Value =
serde_json::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
assert_eq!(v["effortLevel"], "xhigh", "preserve unrelated keys");
assert_eq!(v["enabledPlugins"]["nono"], true);
rev(&report.records);
let v2: Value =
serde_json::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
assert_eq!(v2["effortLevel"], "xhigh", "unrelated keys still present");
assert!(v2.get("enabledPlugins").is_none(), "merged keys gone");
});
}
#[test]
fn json_array_append_dedups_by_key_field() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(
pack.join("entries.json"),
r#"[{ "name": "nono", "command": "x" }]"#,
)
.expect("write entries");
let target = home.join("hooks.json");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::JsonArrayAppend {
file: "$HOME/hooks.json".to_string(),
path: "hooks.PostToolUse".to_string(),
patch_entries: "entries.json".to_string(),
key_field: "name".to_string(),
}];
let r1 = exec(&directives, &ctx).expect("first");
assert!(r1.changed);
let r2 = exec(&directives, &ctx).expect("second");
assert!(!r2.changed, "dedup by key_field");
let v: Value =
serde_json::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
assert_eq!(
v["hooks"]["PostToolUse"].as_array().expect("array").len(),
1
);
rev(&r1.records);
let v2: Value =
serde_json::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
assert_eq!(
v2["hooks"]["PostToolUse"].as_array().expect("array").len(),
0
);
});
}
#[test]
fn json_array_append_accepts_jsonc_in_target_and_patch() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(
pack.join("entries.json"),
r#"[
// Pack-provided append entries can be JSONC.
{ "name": "nono", "command": "x" },
]"#,
)
.expect("write entries");
let target = home.join("hooks.json");
fs::write(
&target,
r#"{
// Mirrors user-owned config files such as Copilot config.
"hooks": {
"PostToolUse": [],
},
}"#,
)
.expect("seed target");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::JsonArrayAppend {
file: "$HOME/hooks.json".to_string(),
path: "hooks.PostToolUse".to_string(),
patch_entries: "entries.json".to_string(),
key_field: "name".to_string(),
}];
let report = exec(&directives, &ctx).expect("execute");
let v: Value =
serde_json::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
let entries = v["hooks"]["PostToolUse"].as_array().expect("array");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["name"], "nono");
rev(&report.records);
let v2: Value =
serde_json::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
assert_eq!(
v2["hooks"]["PostToolUse"].as_array().expect("array").len(),
0
);
});
}
#[test]
fn json_array_append_replaces_entry_when_shape_changes() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(
pack.join("entries.json"),
r#"[{ "name": "nono", "command": "x" }]"#,
)
.expect("write v1");
let target = home.join("hooks.json");
let ctx = ctx_in(home, pack.clone());
let directives = vec![WiringDirective::JsonArrayAppend {
file: "$HOME/hooks.json".to_string(),
path: "hooks.PostToolUse".to_string(),
patch_entries: "entries.json".to_string(),
key_field: "name".to_string(),
}];
let r1 = exec(&directives, &ctx).expect("first install");
assert!(r1.changed);
fs::write(
pack.join("entries.json"),
r#"[{ "name": "nono", "command": "x", "silent": true }]"#,
)
.expect("write v2");
let r2 = exec(&directives, &ctx).expect("re-install");
assert!(r2.changed, "shape change must be applied, not skipped");
let v: Value =
serde_json::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
let entries = v["hooks"]["PostToolUse"].as_array().expect("array present");
assert_eq!(entries.len(), 1, "key dedup keeps a single entry");
assert_eq!(
entries[0].get("silent").and_then(|v| v.as_bool()),
Some(true),
"new shape (silent:true) replaces old shape"
);
let r3 = exec(&directives, &ctx).expect("third");
assert!(!r3.changed, "identical re-run must report no change");
});
}
#[test]
fn toml_block_upsert_strip_round_trip() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(pack.join("block.toml"), "[plugins.test]\nenabled = true\n")
.expect("write block");
let target = home.join("config.toml");
fs::write(&target, "[features]\ncodex_hooks = true\n").expect("seed");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::TomlBlock {
file: "$HOME/config.toml".to_string(),
marker_id: "test".to_string(),
content: "block.toml".to_string(),
}];
let r = exec(&directives, &ctx).expect("execute");
let after = fs::read_to_string(&target).expect("read");
assert!(after.contains("# >>> nono:test >>>"));
assert!(after.contains("[plugins.test]"));
assert!(after.contains("[features]"), "unrelated section preserved");
rev(&r.records);
let after_strip = fs::read_to_string(&target).expect("read");
assert!(!after_strip.contains("nono:test"));
assert!(after_strip.contains("[features]"));
});
}
#[test]
fn write_file_atomic_skips_when_identical() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(pack.join("file"), "hello").expect("seed source");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::WriteFile {
source: "file".to_string(),
dest: "$HOME/dest".to_string(),
}];
let r1 = exec(&directives, &ctx).expect("first");
assert!(r1.changed);
let prior_sha = match &r1.records[0] {
WiringRecord::WriteFile { sha256, .. } => sha256.clone(),
other => panic!("expected WriteFile, got {other:?}"),
};
let mut owned: HashMap<PathBuf, String> = HashMap::new();
owned.insert(home.join("dest"), prior_sha);
let r2 = execute(&directives, &ctx, &owned).expect("second");
assert!(!r2.changed, "identical content is no-op");
rev(&r1.records);
assert!(!home.join("dest").exists());
});
}
#[test]
fn write_file_refuses_to_overwrite_unmanaged_file() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(pack.join("file"), "from-pack").expect("seed source");
fs::write(home.join("dest"), "user content").expect("seed user file");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::WriteFile {
source: "file".to_string(),
dest: "$HOME/dest".to_string(),
}];
let err = exec(&directives, &ctx).expect_err("must refuse");
assert!(err.to_string().contains("refusing to overwrite"));
assert_eq!(
fs::read_to_string(home.join("dest")).expect("read"),
"user content",
"user file must be untouched"
);
});
}
#[test]
fn json_merge_reverse_preserves_unrelated_sibling_keys() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(
pack.join("patch.json"),
r#"{ "enabledPlugins": { "nono": true } }"#,
)
.expect("write patch");
let target = home.join("settings.json");
fs::write(
&target,
r#"{ "enabledPlugins": { "user-plugin": true, "another": false } }"#,
)
.expect("seed");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::JsonMerge {
file: "$HOME/settings.json".to_string(),
patch: "patch.json".to_string(),
}];
let report = exec(&directives, &ctx).expect("install");
rev(&report.records);
let v: Value =
serde_json::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
assert_eq!(v["enabledPlugins"]["user-plugin"], true);
assert_eq!(v["enabledPlugins"]["another"], false);
assert!(
v["enabledPlugins"].get("nono").is_none(),
"only our leaf should be gone"
);
});
}
#[test]
fn json_array_append_reverse_restores_user_owned_entry() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(
pack.join("entries.json"),
r#"[{ "name": "shared", "value": "from-pack" }]"#,
)
.expect("write entries");
let target = home.join("hooks.json");
fs::write(
&target,
r#"{ "hooks": { "PostToolUse": [
{ "name": "shared", "value": "user-original" }
] } }"#,
)
.expect("seed");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::JsonArrayAppend {
file: "$HOME/hooks.json".to_string(),
path: "hooks.PostToolUse".to_string(),
patch_entries: "entries.json".to_string(),
key_field: "name".to_string(),
}];
let report = exec(&directives, &ctx).expect("install");
let v: Value =
serde_json::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
assert_eq!(v["hooks"]["PostToolUse"][0]["value"], "from-pack");
rev(&report.records);
let v2: Value =
serde_json::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
assert_eq!(
v2["hooks"]["PostToolUse"][0]["value"], "user-original",
"user's original entry must be restored"
);
});
}
#[test]
fn reverse_collects_failures_instead_of_swallowing() {
with_fake_home(|home| {
let target = home.join("broken.json");
fs::write(&target, "{not valid json").expect("seed broken");
let records = vec![WiringRecord::JsonMerge {
file: target.to_string_lossy().into_owned(),
leaves: vec![JsonLeaf {
path: vec!["k".to_string()],
installed_value: Value::Bool(true),
prior_value: None,
}],
created_parents: Vec::new(),
}];
let failures = reverse(&records);
assert_eq!(failures.len(), 1, "broken JSON must surface as failure");
assert!(
failures[0].record_summary.contains("json_merge"),
"summary should identify the directive"
);
});
}
#[test]
fn write_file_force_recovery_accepts_identical_unmanaged_file() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(pack.join("file"), "from-pack").expect("seed source");
fs::write(home.join("dest"), "from-pack").expect("seed matching dest");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::WriteFile {
source: "file".to_string(),
dest: "$HOME/dest".to_string(),
}];
let report = execute_with_options(
&directives,
&ctx,
&HashMap::new(),
ExecuteOptions {
allow_unmanaged_identical_write_files: true,
},
)
.expect("identical unmanaged file should be adopted during force recovery");
assert!(
!report.changed,
"identical destination should not be rewritten"
);
assert_eq!(report.records.len(), 1);
assert_eq!(
fs::read_to_string(home.join("dest")).expect("read"),
"from-pack"
);
});
}
#[test]
fn write_file_force_recovery_refuses_mismatched_unmanaged_file() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(pack.join("file"), "from-pack").expect("seed source");
fs::write(home.join("dest"), "user content").expect("seed user file");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::WriteFile {
source: "file".to_string(),
dest: "$HOME/dest".to_string(),
}];
let err = execute_with_options(
&directives,
&ctx,
&HashMap::new(),
ExecuteOptions {
allow_unmanaged_identical_write_files: true,
},
)
.expect_err("mismatched unmanaged file must still be refused");
assert!(err.to_string().contains("refusing to overwrite"));
assert_eq!(
fs::read_to_string(home.join("dest")).expect("read"),
"user content"
);
});
}
#[test]
fn write_file_refuses_to_overwrite_edited_file_on_repull() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(pack.join("file"), "from-pack-v2").expect("seed v2");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::WriteFile {
source: "file".to_string(),
dest: "$HOME/dest".to_string(),
}];
fs::write(home.join("dest"), "from-pack-v1").expect("seed prior");
let prior_sha = hash_bytes(b"from-pack-v1");
fs::write(home.join("dest"), "user edited").expect("user edit");
let mut owned: HashMap<PathBuf, String> = HashMap::new();
owned.insert(home.join("dest"), prior_sha);
let err = execute(&directives, &ctx, &owned).expect_err("must refuse");
assert!(
err.to_string().contains("sha256 mismatch") || err.to_string().contains("edited"),
"error must mention edit/mismatch: {err}"
);
assert_eq!(
fs::read_to_string(home.join("dest")).expect("read"),
"user edited",
"user edit must be preserved",
);
});
}
#[test]
fn json_array_append_reverse_leaves_user_edit_alone() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(
pack.join("entries.json"),
r#"[{ "name": "nono", "value": "from-pack" }]"#,
)
.expect("write entries");
let target = home.join("hooks.json");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::JsonArrayAppend {
file: "$HOME/hooks.json".to_string(),
path: "hooks.PostToolUse".to_string(),
patch_entries: "entries.json".to_string(),
key_field: "name".to_string(),
}];
let report = exec(&directives, &ctx).expect("install");
let mut doc: Value =
serde_json::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
doc["hooks"]["PostToolUse"][0]["value"] = Value::String("user-customised".to_string());
fs::write(&target, serde_json::to_string_pretty(&doc).expect("ser"))
.expect("write user edit");
rev(&report.records);
let v: Value =
serde_json::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
assert_eq!(
v["hooks"]["PostToolUse"][0]["value"], "user-customised",
"user-edited entry must be preserved",
);
});
}
#[test]
fn write_file_reverse_leaves_modified_file_alone() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(pack.join("file"), "from-pack").expect("seed source");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::WriteFile {
source: "file".to_string(),
dest: "$HOME/dest".to_string(),
}];
let r1 = exec(&directives, &ctx).expect("install");
fs::write(home.join("dest"), "user edited").expect("user edit");
rev(&r1.records);
assert!(home.join("dest").exists(), "modified file must be kept");
assert_eq!(
fs::read_to_string(home.join("dest")).expect("read"),
"user edited",
);
});
}
#[test]
fn yaml_merge_and_strip_round_trip() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(
pack.join("patch.yaml"),
"plugins:\n enabled:\n - nono-sandbox\n",
)
.expect("write patch");
let target = home.join("config.yaml");
fs::write(&target, "level: info\n").expect("seed target");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::YamlMerge {
file: "$HOME/config.yaml".to_string(),
patch: "patch.yaml".to_string(),
}];
let report = exec(&directives, &ctx).expect("execute");
assert!(report.changed);
let after: yaml::Value =
yaml::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
assert_eq!(after["level"], yaml::Value::String("info".to_string()));
assert!(after["plugins"]["enabled"].is_sequence());
rev(&report.records);
let restored: yaml::Value =
yaml::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
assert_eq!(restored["level"], yaml::Value::String("info".to_string()));
assert!(
restored.get("plugins").is_none(),
"merged keys gone after reverse"
);
});
}
#[test]
fn yaml_merge_is_idempotent() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(pack.join("patch.yaml"), "foo:\n bar: true\n").expect("write patch");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::YamlMerge {
file: "$HOME/config.yaml".to_string(),
patch: "patch.yaml".to_string(),
}];
let _ = exec(&directives, &ctx).expect("first");
let r2 = exec(&directives, &ctx).expect("second");
assert!(!r2.changed, "second run must be no-op");
});
}
#[test]
fn yaml_merge_preserves_sibling_keys_on_reverse() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(pack.join("patch.yaml"), "plugins:\n nono: true\n").expect("write patch");
let target = home.join("config.yaml");
fs::write(&target, "plugins:\n user-plugin: true\n").expect("seed");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::YamlMerge {
file: "$HOME/config.yaml".to_string(),
patch: "patch.yaml".to_string(),
}];
let report = exec(&directives, &ctx).expect("install");
rev(&report.records);
let restored: yaml::Value =
yaml::from_str(&fs::read_to_string(&target).expect("read")).expect("parse");
assert_eq!(
restored["plugins"]["user-plugin"],
yaml::Value::Bool(true),
"sibling key must survive removal"
);
assert!(
restored["plugins"].get("nono").is_none(),
"only our leaf should be gone"
);
});
}
#[test]
fn yaml_merge_rejects_non_string_keys() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(pack.join("patch.yaml"), "123: bad\n").expect("write patch");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::YamlMerge {
file: "$HOME/config.yaml".to_string(),
patch: "patch.yaml".to_string(),
}];
let err = exec(&directives, &ctx).expect_err("must reject non-string keys");
assert!(
err.to_string().contains("mapping key must be a string"),
"error must mention key type: {err}"
);
});
}
#[test]
fn yaml_merge_rejects_custom_tags() {
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(pack.join("patch.yaml"), "key: !MyTag value\n").expect("write patch");
let ctx = ctx_in(home, pack);
let directives = vec![WiringDirective::YamlMerge {
file: "$HOME/config.yaml".to_string(),
patch: "patch.yaml".to_string(),
}];
let err = exec(&directives, &ctx).expect_err("must reject custom tags");
assert!(
err.to_string().contains("custom YAML tags"),
"error must mention tags: {err}"
);
});
}
#[test]
fn yaml_reverse_collects_failures_instead_of_swallowing() {
with_fake_home(|home| {
let target = home.join("broken.yaml");
fs::write(&target, "key: [unclosed").expect("seed broken");
let records = vec![WiringRecord::YamlMerge {
file: target.to_string_lossy().into_owned(),
leaves: vec![JsonLeaf {
path: vec!["k".to_string()],
installed_value: Value::Bool(true),
prior_value: None,
}],
created_parents: Vec::new(),
}];
let failures = reverse(&records);
assert_eq!(failures.len(), 1, "broken YAML must surface as failure");
assert!(
failures[0].record_summary.contains("yaml_merge"),
"summary should identify the directive: {}",
failures[0].record_summary
);
});
}
#[test]
fn wiring_when_filters_directives_before_execution() {
let current = crate::platform::current_os_name();
let other = if current == "linux" { "macos" } else { "linux" };
let json = format!(
r#"[
{{
"type": "write_file",
"source": "match.txt",
"dest": "$HOME/match.txt",
"when": "{current}"
}},
{{
"type": "write_file",
"source": "skip.txt",
"dest": "$HOME/skip.txt",
"when": "{other}"
}}
]"#
);
let directives: Vec<WiringDirective> = serde_json::from_str(&json).expect("parse wiring");
assert!(matches!(directives[1], WiringDirective::Skipped));
with_fake_home(|home| {
let pack = home.join("pack");
fs::create_dir_all(&pack).expect("mkdir pack");
fs::write(pack.join("match.txt"), "match").expect("write match");
fs::write(pack.join("skip.txt"), "skip").expect("write skip");
let ctx = ctx_in(home, pack);
let report = exec(&directives, &ctx).expect("execute");
assert!(home.join("match.txt").exists());
assert!(!home.join("skip.txt").exists());
assert_eq!(report.records.len(), 1);
});
}
}