1use crate::github_facts::GithubFacts;
9use crate::trust::{Trust, TrustContext};
10use crate::uses_ref::{self, RefKind, UsesRef};
11use crate::{config, trust, workflow};
12use std::collections::{BTreeMap, HashMap};
13use std::io;
14use std::path::Path;
15
16pub struct FixChange {
18 pub file: String,
19 pub line: usize,
20 pub from: String,
21 pub to: String,
22}
23
24pub struct FixOutcome {
26 pub changes: Vec<FixChange>,
27 pub skipped: Vec<(String, String)>,
29 pub applied: bool,
31}
32
33pub fn fix(root: &Path, facts: &dyn GithubFacts, dry_run: bool) -> io::Result<FixOutcome> {
36 let ctx = TrustContext::new(
37 trust::detect_repo_owner(root),
38 config::load(root)?.trusted_owners,
39 );
40 let mut cache: HashMap<(String, String), Option<String>> = HashMap::new();
42 let mut skipped: BTreeMap<String, String> = BTreeMap::new();
43 let mut changes = Vec::new();
44
45 for wf in workflow::find_workflows(root)? {
46 let content = std::fs::read_to_string(&wf)?;
47 let rel = wf.strip_prefix(root).unwrap_or(&wf).display().to_string();
48 let mut new_content = String::with_capacity(content.len() + 64);
49 let mut changed = false;
50
51 for (idx, segment) in content.split_inclusive('\n').enumerate() {
52 let (body, ending) = split_ending(segment);
53 match try_fix_line(body, &ctx, facts, &mut cache, &mut skipped) {
54 Some((new_body, from, to)) => {
55 changes.push(FixChange {
56 file: rel.clone(),
57 line: idx + 1,
58 from,
59 to,
60 });
61 new_content.push_str(&new_body);
62 changed = true;
63 }
64 None => new_content.push_str(body),
65 }
66 new_content.push_str(ending);
67 }
68
69 if changed && !dry_run {
70 std::fs::write(&wf, new_content)?;
71 }
72 }
73
74 Ok(FixOutcome {
75 changes,
76 skipped: skipped.into_iter().collect(),
77 applied: !dry_run,
78 })
79}
80
81fn split_ending(segment: &str) -> (&str, &str) {
83 if let Some(body) = segment.strip_suffix("\r\n") {
84 (body, "\r\n")
85 } else if let Some(body) = segment.strip_suffix('\n') {
86 (body, "\n")
87 } else {
88 (segment, "")
89 }
90}
91
92fn try_fix_line(
94 line: &str,
95 ctx: &TrustContext,
96 facts: &dyn GithubFacts,
97 cache: &mut HashMap<(String, String), Option<String>>,
98 skipped: &mut BTreeMap<String, String>,
99) -> Option<(String, String, String)> {
100 let value = workflow::extract_uses_value(line)?;
101 let UsesRef::Repository {
102 owner_repo,
103 git_ref: Some(RefKind::Mutable(git_ref)),
104 } = uses_ref::parse(&value)
105 else {
106 return None;
107 };
108 if ctx.classify(&owner_repo) == Trust::FirstParty {
109 return None;
110 }
111 let repo = uses_ref::repo_root(&owner_repo).to_string();
112 let key = (repo.clone(), git_ref.clone());
113 let sha = match cache.get(&key) {
114 Some(cached) => cached.clone(),
115 None => {
116 let resolved = match facts.resolve_ref(&repo, &git_ref) {
117 Ok(Some(sha)) => Some(sha),
118 Ok(None) => {
119 skipped.insert(
120 format!("{repo}@{git_ref}"),
121 "참조를 찾을 수 없음 — 변경하지 않음".to_string(),
122 );
123 None
124 }
125 Err(e) => {
126 skipped.insert(
127 format!("{repo}@{git_ref}"),
128 format!("해석 실패 — 변경하지 않음: {e}"),
129 );
130 None
131 }
132 };
133 cache.insert(key, resolved.clone());
134 resolved
135 }
136 }?;
137
138 let new_value = format!("{owner_repo}@{sha}");
139 let pos = line.find(&value)?;
140 let rest = &line[pos + value.len()..];
141 let mut new_line = String::with_capacity(line.len() + 48);
142 new_line.push_str(&line[..pos]);
143 new_line.push_str(&new_value);
144 new_line.push_str(rest);
145 if !rest.contains('#') {
147 new_line.push_str(&format!(" # {git_ref}"));
148 }
149 Some((new_line, value, new_value))
150}