use super::Error as ManifestError;
use crate::domain::action::identity::ActionId;
use crate::domain::diff::ManifestDiff;
use crate::domain::file::site::StepIndex;
use crate::domain::manifest::overrides::ActionOverride;
use std::fs;
use std::path::Path;
use toml_edit::DocumentMut;
pub fn apply_manifest_diff(path: &Path, diff: &ManifestDiff) -> Result<(), ManifestError> {
if diff.is_empty() {
return Ok(());
}
let content = fs::read_to_string(path).map_err(|source| ManifestError::Read {
path: path.to_path_buf(),
source,
})?;
let mut doc: DocumentMut = content
.parse()
.map_err(|e| ManifestError::Validation(format!("toml_edit parse error: {e}")))?;
doc.remove("gx");
if doc.get("actions").is_none() {
doc.insert("actions", toml_edit::Item::Table(toml_edit::Table::new()));
}
let actions = doc
.get_mut("actions")
.and_then(toml_edit::Item::as_table_mut)
.ok_or_else(|| ManifestError::Validation("[actions] is not a table".to_owned()))?;
for id in &diff.removed {
actions.remove(id.as_str());
}
for (id, version) in &diff.added {
actions.insert(id.as_str(), toml_edit::value(version.as_str()));
}
for (id, version) in &diff.updated {
actions.insert(id.as_str(), toml_edit::value(version.as_str()));
}
actions.sort_values();
if !diff.overrides_removed.is_empty() {
apply_override_removals(actions, &diff.overrides_removed);
}
if !diff.overrides_added.is_empty() {
apply_override_additions(actions, &diff.overrides_added)?;
}
fs::write(path, doc.to_string()).map_err(|source| ManifestError::Write {
path: path.to_path_buf(),
source,
})?;
Ok(())
}
fn override_entry_matches(
workflow: Option<&str>,
job: Option<&str>,
step: Option<i64>,
ovr: &ActionOverride,
) -> bool {
workflow == Some(ovr.workflow.as_str())
&& job
== ovr
.scope
.job()
.map(crate::domain::file::site::JobId::as_str)
&& step.and_then(|s| StepIndex::try_from(s).ok()) == ovr.scope.step()
}
fn apply_override_removals(
actions: &mut toml_edit::Table,
removals: &[(ActionId, Vec<ActionOverride>)],
) {
let Some(overrides_table) = actions
.get_mut("overrides")
.and_then(toml_edit::Item::as_table_mut)
else {
return;
};
for (id, removed_list) in removals {
let indices = collect_override_removal_indices(overrides_table, id, removed_list);
if let Some(arr_item) = overrides_table.get_mut(id.as_str()) {
if let Some(arr) = arr_item.as_array_of_tables_mut() {
for i in indices.into_iter().rev() {
arr.remove(i);
}
if arr.is_empty() {
overrides_table.remove(id.as_str());
}
} else if let Some(arr) = arr_item.as_array_mut() {
for i in indices.into_iter().rev() {
arr.remove(i);
}
if arr.is_empty() {
overrides_table.remove(id.as_str());
}
}
}
}
if overrides_table.is_empty() {
actions.remove("overrides");
}
}
fn collect_override_removal_indices(
overrides_table: &toml_edit::Table,
id: &ActionId,
removed_list: &[ActionOverride],
) -> Vec<usize> {
let mut indices = Vec::new();
let Some(arr_item) = overrides_table.get(id.as_str()) else {
return indices;
};
if let Some(arr) = arr_item.as_array_of_tables() {
for (i, entry) in arr.iter().enumerate() {
let wf = entry.get("workflow").and_then(toml_edit::Item::as_str);
let job = entry.get("job").and_then(toml_edit::Item::as_str);
let step = entry.get("step").and_then(toml_edit::Item::as_integer);
for ovr in removed_list {
if override_entry_matches(wf, job, step, ovr) {
indices.push(i);
break;
}
}
}
} else if let Some(arr) = arr_item.as_array() {
for (i, entry) in arr.iter().enumerate() {
if let Some(tbl) = entry.as_inline_table() {
let wf = tbl.get("workflow").and_then(toml_edit::Value::as_str);
let job = tbl.get("job").and_then(toml_edit::Value::as_str);
let step = tbl.get("step").and_then(toml_edit::Value::as_integer);
for ovr in removed_list {
if override_entry_matches(wf, job, step, ovr) {
indices.push(i);
break;
}
}
}
}
}
indices
}
fn apply_override_additions(
actions: &mut toml_edit::Table,
additions: &[(ActionId, ActionOverride)],
) -> Result<(), ManifestError> {
if actions.get("overrides").is_none() {
actions.insert("overrides", toml_edit::Item::Table(toml_edit::Table::new()));
}
let Some(overrides_table) = actions
.get_mut("overrides")
.and_then(toml_edit::Item::as_table_mut)
else {
return Ok(());
};
for (id, ovr) in additions {
if overrides_table.get(ActionId::as_str(id)).is_none() {
overrides_table.insert(id.as_str(), toml_edit::value(toml_edit::Array::new()));
}
let arr = overrides_table
.get_mut(id.as_str())
.and_then(toml_edit::Item::as_array_mut)
.ok_or_else(|| {
ManifestError::Validation(format!(
"override entry for \"{}\" is not an array",
id.as_str()
))
})?;
let mut inline = toml_edit::InlineTable::new();
inline.insert("workflow", ovr.workflow.as_str().into());
if let Some(job) = ovr.scope.job() {
inline.insert("job", toml_edit::Value::from(job.as_str()));
}
if let Some(step) = ovr.scope.step() {
inline.insert("step", i64::from(step).into());
}
inline.insert("version", ovr.version.as_str().into());
arr.push(inline);
}
overrides_table.sort_values();
Ok(())
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
clippy::indexing_slicing,
reason = "tests use unwrap, indexing, and other patterns freely"
)]
mod tests {
use super::apply_manifest_diff;
use crate::domain::action::identity::ActionId;
use crate::domain::action::specifier::Specifier;
use crate::domain::diff::ManifestDiff;
use crate::domain::file::site::{JobId, Scope, WorkflowPath};
use crate::domain::manifest::overrides::ActionOverride;
use std::fs;
use std::io::Write as _;
use tempfile::NamedTempFile;
use crate::infra::manifest::parse;
#[test]
fn apply_empty_diff_does_not_modify_file() {
let content = "[actions]\n\"actions/checkout\" = \"v4\"\n";
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let diff = ManifestDiff::default();
apply_manifest_diff(file.path(), &diff).unwrap();
let after = fs::read_to_string(file.path()).unwrap();
assert_eq!(content, after, "Empty diff must not modify file");
}
#[test]
fn apply_add_one_action_preserves_existing() {
let content = "[actions]\n\"actions/checkout\" = \"^4\"\n";
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let diff = ManifestDiff {
added: vec![(ActionId::from("actions/setup-node"), Specifier::parse("^3"))],
..Default::default()
};
apply_manifest_diff(file.path(), &diff).unwrap();
let after = fs::read_to_string(file.path()).unwrap();
assert!(
after.contains("\"actions/checkout\" = \"^4\""),
"Existing entry must be preserved, got:\n{after}"
);
assert!(
after.contains("\"actions/setup-node\" = \"^3\""),
"New entry must be added, got:\n{after}"
);
let loaded = parse(file.path()).unwrap();
assert_eq!(
loaded.value.get(&ActionId::from("actions/checkout")),
Some(&Specifier::parse("^4"))
);
assert_eq!(
loaded.value.get(&ActionId::from("actions/setup-node")),
Some(&Specifier::parse("^3"))
);
}
#[test]
fn apply_remove_one_action() {
let content = "[actions]\n\"actions/checkout\" = \"v4\"\n\"actions/setup-node\" = \"v3\"\n";
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let diff = ManifestDiff {
removed: vec![ActionId::from("actions/checkout")],
..Default::default()
};
apply_manifest_diff(file.path(), &diff).unwrap();
let after = fs::read_to_string(file.path()).unwrap();
assert!(
!after.contains("actions/checkout"),
"Removed entry must be gone, got:\n{after}"
);
assert!(
after.contains("\"actions/setup-node\" = \"v3\""),
"Other entry must be preserved, got:\n{after}"
);
}
#[test]
fn apply_add_override_creates_section_if_missing() {
let content = "[actions]\n\"actions/checkout\" = \"^4\"\n";
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let diff = ManifestDiff {
overrides_added: vec![(
ActionId::from("actions/checkout"),
ActionOverride {
workflow: WorkflowPath::new(".github/workflows/deploy.yml"),
scope: Scope::File,
version: Specifier::parse("^3"),
},
)],
..Default::default()
};
apply_manifest_diff(file.path(), &diff).unwrap();
let loaded = parse(file.path()).unwrap();
let overrides = loaded
.value
.overrides_for(&ActionId::from("actions/checkout"));
assert_eq!(overrides.len(), 1);
assert_eq!(
overrides[0].workflow,
WorkflowPath::new(".github/workflows/deploy.yml")
);
assert_eq!(overrides[0].version.as_str(), "^3");
}
#[test]
fn apply_add_override_to_existing_section() {
let content = r#"[actions]
"actions/checkout" = "v4"
[actions.overrides]
"actions/checkout" = [
{ workflow = ".github/workflows/deploy.yml", version = "v3" },
]
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let diff = ManifestDiff {
overrides_added: vec![(
ActionId::from("actions/checkout"),
ActionOverride {
workflow: WorkflowPath::new(".github/workflows/ci.yml"),
scope: Scope::Job {
job: JobId::from("legacy"),
},
version: Specifier::parse("^2"),
},
)],
..Default::default()
};
apply_manifest_diff(file.path(), &diff).unwrap();
let loaded = parse(file.path()).unwrap();
let overrides = loaded
.value
.overrides_for(&ActionId::from("actions/checkout"));
assert_eq!(overrides.len(), 2, "Should have 2 overrides now");
}
#[test]
fn apply_remove_all_overrides_removes_action_entry() {
let content = r#"[actions]
"actions/checkout" = "v4"
[actions.overrides]
"actions/checkout" = [
{ workflow = ".github/workflows/deploy.yml", version = "v3" },
]
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let diff = ManifestDiff {
overrides_removed: vec![(
ActionId::from("actions/checkout"),
vec![ActionOverride {
workflow: WorkflowPath::new(".github/workflows/deploy.yml"),
scope: Scope::File,
version: Specifier::parse("^3"),
}],
)],
..Default::default()
};
apply_manifest_diff(file.path(), &diff).unwrap();
let loaded = parse(file.path()).unwrap();
assert!(
loaded
.value
.overrides_for(&ActionId::from("actions/checkout"))
.is_empty()
);
}
#[test]
fn apply_remove_last_override_removes_section() {
let content = r#"[actions]
"actions/checkout" = "v4"
[actions.overrides]
"actions/checkout" = [
{ workflow = ".github/workflows/deploy.yml", version = "v3" },
]
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let diff = ManifestDiff {
overrides_removed: vec![(
ActionId::from("actions/checkout"),
vec![ActionOverride {
workflow: WorkflowPath::new(".github/workflows/deploy.yml"),
scope: Scope::File,
version: Specifier::parse("^3"),
}],
)],
..Default::default()
};
apply_manifest_diff(file.path(), &diff).unwrap();
let after = fs::read_to_string(file.path()).unwrap();
assert!(
!after.contains("overrides"),
"Overrides section must be removed when empty, got:\n{after}"
);
}
#[test]
fn apply_roundtrip_domain_state_matches() {
let content = r#"[actions]
"actions/checkout" = "^4"
"actions/setup-node" = "^3"
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let diff = ManifestDiff {
added: vec![(ActionId::from("actions/cache"), Specifier::parse("^3"))],
removed: vec![ActionId::from("actions/setup-node")],
overrides_added: vec![(
ActionId::from("actions/checkout"),
ActionOverride {
workflow: WorkflowPath::new(".github/workflows/windows.yml"),
scope: Scope::File,
version: Specifier::parse("^3"),
},
)],
..Default::default()
};
apply_manifest_diff(file.path(), &diff).unwrap();
let loaded = parse(file.path()).unwrap();
assert!(
loaded
.value
.get(&ActionId::from("actions/checkout"))
.is_some()
);
assert!(loaded.value.get(&ActionId::from("actions/cache")).is_some());
assert!(
loaded
.value
.get(&ActionId::from("actions/setup-node"))
.is_none()
);
let overrides = loaded
.value
.overrides_for(&ActionId::from("actions/checkout"));
assert_eq!(overrides.len(), 1);
assert_eq!(
overrides[0].workflow,
WorkflowPath::new(".github/workflows/windows.yml")
);
}
}