use crate::github_facts::GithubFacts;
use crate::trust::{Trust, TrustContext};
use crate::uses_ref::{self, RefKind, UsesRef};
use crate::{config, trust, workflow};
use std::collections::{BTreeMap, HashMap};
use std::io;
use std::path::Path;
pub struct FixChange {
pub file: String,
pub line: usize,
pub from: String,
pub to: String,
}
pub struct FixOutcome {
pub changes: Vec<FixChange>,
pub skipped: Vec<(String, String)>,
pub applied: bool,
}
pub fn fix(root: &Path, facts: &dyn GithubFacts, dry_run: bool) -> io::Result<FixOutcome> {
let ctx = TrustContext::new(
trust::detect_repo_owner(root),
config::load(root)?.trusted_owners,
);
let mut cache: HashMap<(String, String), Option<String>> = HashMap::new();
let mut skipped: BTreeMap<String, String> = BTreeMap::new();
let mut changes = Vec::new();
for wf in workflow::find_workflows(root)? {
let content = std::fs::read_to_string(&wf)?;
let rel = wf.strip_prefix(root).unwrap_or(&wf).display().to_string();
let mut new_content = String::with_capacity(content.len() + 64);
let mut changed = false;
for (idx, segment) in content.split_inclusive('\n').enumerate() {
let (body, ending) = split_ending(segment);
match try_fix_line(body, &ctx, facts, &mut cache, &mut skipped) {
Some((new_body, from, to)) => {
changes.push(FixChange {
file: rel.clone(),
line: idx + 1,
from,
to,
});
new_content.push_str(&new_body);
changed = true;
}
None => new_content.push_str(body),
}
new_content.push_str(ending);
}
if changed && !dry_run {
std::fs::write(&wf, new_content)?;
}
}
Ok(FixOutcome {
changes,
skipped: skipped.into_iter().collect(),
applied: !dry_run,
})
}
fn split_ending(segment: &str) -> (&str, &str) {
if let Some(body) = segment.strip_suffix("\r\n") {
(body, "\r\n")
} else if let Some(body) = segment.strip_suffix('\n') {
(body, "\n")
} else {
(segment, "")
}
}
fn try_fix_line(
line: &str,
ctx: &TrustContext,
facts: &dyn GithubFacts,
cache: &mut HashMap<(String, String), Option<String>>,
skipped: &mut BTreeMap<String, String>,
) -> Option<(String, String, String)> {
let value = workflow::extract_uses_value(line)?;
let UsesRef::Repository {
owner_repo,
git_ref: Some(RefKind::Mutable(git_ref)),
} = uses_ref::parse(&value)
else {
return None;
};
if ctx.classify(&owner_repo) == Trust::FirstParty {
return None;
}
let repo = uses_ref::repo_root(&owner_repo).to_string();
let key = (repo.clone(), git_ref.clone());
let sha = match cache.get(&key) {
Some(cached) => cached.clone(),
None => {
let resolved = match facts.resolve_ref(&repo, &git_ref) {
Ok(Some(sha)) => Some(sha),
Ok(None) => {
skipped.insert(
format!("{repo}@{git_ref}"),
"참조를 찾을 수 없음 — 변경하지 않음".to_string(),
);
None
}
Err(e) => {
skipped.insert(
format!("{repo}@{git_ref}"),
format!("해석 실패 — 변경하지 않음: {e}"),
);
None
}
};
cache.insert(key, resolved.clone());
resolved
}
}?;
let new_value = format!("{owner_repo}@{sha}");
let pos = line.find(&value)?;
let rest = &line[pos + value.len()..];
let mut new_line = String::with_capacity(line.len() + 48);
new_line.push_str(&line[..pos]);
new_line.push_str(&new_value);
new_line.push_str(rest);
if !rest.contains('#') {
new_line.push_str(&format!(" # {git_ref}"));
}
Some((new_line, value, new_value))
}