use super::Error as ManifestError;
use crate::config::Rule;
use crate::domain::action::identity::ActionId;
use crate::domain::action::spec::Spec as ActionSpec;
use crate::domain::action::specifier::Specifier;
use crate::domain::file::site::{JobId, Scope, StepIndex, WorkflowPath};
use crate::domain::manifest::Manifest;
use crate::domain::manifest::overrides::ActionOverride;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use toml_edit::DocumentMut;
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct GxSection {
#[serde(default)]
pub min_version: String,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct TomlOverride {
pub workflow: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step: Option<usize>,
pub version: String,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct TomlActions {
#[serde(default, flatten)]
pub versions: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub overrides: BTreeMap<String, Vec<TomlOverride>>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct ManifestData {
#[serde(default)]
pub gx: Option<GxSection>,
#[serde(default)]
pub actions: TomlActions,
#[serde(default)]
pub lint: LintData,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct LintData {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub rules: BTreeMap<crate::diagnostic::RuleName, Rule>,
}
pub fn manifest_from_data(
data: ManifestData,
_path: &Path,
is_v2: bool,
) -> Result<Manifest, ManifestError> {
let actions: HashMap<ActionId, ActionSpec> = data
.actions
.versions
.into_iter()
.map(|(k, v)| {
let id = ActionId::from(k);
let specifier = if is_v2 {
Specifier::parse(&v)
} else {
Specifier::from_v1(&v)
};
let spec = ActionSpec::new(id.clone(), specifier);
(id, spec)
})
.collect();
let mut overrides: HashMap<ActionId, Vec<ActionOverride>> = HashMap::new();
for (action_str, toml_overrides) in data.actions.overrides {
let id = ActionId::from(action_str.clone());
if !actions.contains_key(&id) {
return Err(ManifestError::Validation(format!(
"\"{action_str}\" has overrides but no global version — run 'gx tidy' to fix"
)));
}
let mut seen_scopes: Vec<(String, Option<String>, Option<usize>)> = Vec::new();
let mut converted = Vec::new();
for exc in toml_overrides {
let workflow_path = WorkflowPath::new(exc.workflow.clone());
let toml_scope = (exc.workflow.clone(), exc.job.clone(), exc.step);
if seen_scopes.contains(&toml_scope) {
return Err(ManifestError::Validation(format!(
"duplicate override scope for \"{}\" in \"{}\"",
action_str, exc.workflow
)));
}
seen_scopes.push(toml_scope);
let specifier = if is_v2 {
Specifier::parse(&exc.version)
} else {
Specifier::from_v1(&exc.version)
};
let step_index = exc
.step
.map(StepIndex::try_from)
.transpose()
.map_err(ManifestError::Validation)?;
let scope = match (exc.job.map(JobId::from), step_index) {
(Some(job), Some(step)) => Scope::JobStep { job, step },
(Some(job), None) => Scope::Job { job },
(None, Some(step)) => Scope::CompositeStep { step },
(None, None) => Scope::File,
};
converted.push(ActionOverride {
workflow: workflow_path,
scope,
version: specifier,
});
}
overrides.insert(id, converted);
}
Ok(Manifest::with_overrides(actions, overrides))
}
pub fn build_manifest_document(manifest: &Manifest) -> DocumentMut {
let mut doc = DocumentMut::new();
let mut actions = toml_edit::Table::new();
let mut specs: Vec<_> = manifest.specs().collect();
specs.sort_by_key(|s| s.id.as_str().to_owned());
for spec in &specs {
actions.insert(spec.id.as_str(), toml_edit::value(spec.specifier.as_str()));
}
let mut all_overrides: Vec<(&ActionId, &Vec<ActionOverride>)> =
manifest.all_overrides().iter().collect();
all_overrides.sort_by_key(|(id, _)| id.as_str().to_owned());
let has_overrides = all_overrides.iter().any(|(_, ovrs)| !ovrs.is_empty());
if has_overrides {
let mut overrides_table = toml_edit::Table::new();
for (id, ovrs) in &all_overrides {
if ovrs.is_empty() {
continue;
}
let mut arr = toml_edit::Array::new();
for ovr in *ovrs {
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.insert(id.as_str(), toml_edit::value(arr));
}
actions.insert("overrides", toml_edit::Item::Table(overrides_table));
}
doc.insert("actions", toml_edit::Item::Table(actions));
doc
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
clippy::indexing_slicing,
reason = "tests use unwrap, indexing, and other patterns freely"
)]
mod tests {
use super::{Manifest, build_manifest_document};
use crate::domain::action::identity::ActionId;
use crate::domain::action::specifier::Specifier;
use crate::domain::file::site::{Scope, WorkflowPath};
use crate::domain::manifest::overrides::ActionOverride;
use crate::infra::manifest::{Store, parse};
use std::fs;
use std::io::Write as _;
use tempfile::NamedTempFile;
#[test]
fn file_manifest_save_and_load_roundtrip() {
let file = NamedTempFile::new().unwrap();
let store = Store::new(file.path());
let mut manifest = Manifest::default();
manifest.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
manifest.set(ActionId::from("actions/setup-node"), Specifier::parse("^3"));
store.save(&manifest).unwrap();
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 file_manifest_load_existing_toml() {
let content = r#"
[actions]
"actions/checkout" = "v4"
"actions/setup-node" = "v4"
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let loaded = parse(file.path()).unwrap();
assert_eq!(
loaded.value.get(&ActionId::from("actions/checkout")),
Some(&Specifier::from_v1("v4"))
);
}
#[test]
fn file_manifest_save_sorts_actions_alphabetically() {
let file = NamedTempFile::new().unwrap();
let store = Store::new(file.path());
let mut manifest = Manifest::default();
manifest.set(
ActionId::from("docker/build-push-action"),
Specifier::parse("^5"),
);
manifest.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
manifest.set(
ActionId::from("actions-rust-lang/rustfmt"),
Specifier::parse("^1"),
);
store.save(&manifest).unwrap();
let content = fs::read_to_string(file.path()).unwrap();
let action_lines: Vec<&str> = content
.lines()
.filter(|l| l.trim().starts_with('"') && l.contains(" = ") && !l.contains('['))
.collect();
let mut sorted = action_lines.clone();
sorted.sort_unstable();
assert_eq!(action_lines, sorted);
assert!(action_lines[0].contains("actions-rust-lang/rustfmt"));
assert!(action_lines[1].contains("actions/checkout"));
assert!(action_lines[2].contains("docker/build-push-action"));
}
#[test]
fn save_no_gx_section() {
let file = NamedTempFile::new().unwrap();
let store = Store::new(file.path());
let mut manifest = Manifest::default();
manifest.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
store.save(&manifest).unwrap();
let content = fs::read_to_string(file.path()).unwrap();
assert!(
!content.contains("[gx]"),
"Saved file must NOT contain [gx] section, got:\n{content}"
);
assert!(
!content.contains("min_version"),
"Saved file must NOT contain min_version, got:\n{content}"
);
assert!(
content.contains("[actions]"),
"Saved file must contain [actions] section, got:\n{content}"
);
}
#[test]
fn build_manifest_document_with_overrides() {
let mut manifest = Manifest::default();
manifest.set(ActionId::from("actions/checkout"), Specifier::parse("^4"));
manifest.add_override(
ActionId::from("actions/checkout"),
ActionOverride {
workflow: WorkflowPath::new(".github/workflows/ci.yml"),
scope: Scope::File,
version: Specifier::parse("^3"),
},
);
let output = build_manifest_document(&manifest).to_string();
assert!(output.contains("[actions]"));
assert!(output.contains("[actions.overrides]"));
assert!(output.contains("\"actions/checkout\" = \"^4\""));
assert!(!output.contains("[gx]"));
}
}