1use super::keys::{
44 BuiltinInput, Ext, TASK_POLICIES, TASK_V3_KEYS, WORKFLOW_POLICIES, builtin_input,
45};
46use super::parse::{descends, is_group, step_kind};
47use super::{V3_VALIDATE, VALIDATION};
48use crate::base::typo::unknown_key;
49use crate::base::{AuthoredPath, Issue, IssueCode, StepKind};
50use serde_json::{Map, Value};
51use std::collections::HashMap;
52
53#[derive(Clone, Debug, PartialEq, Eq)]
64#[non_exhaustive]
65pub struct Rewrite {
66 pub path: AuthoredPath,
69 pub kind: RewriteKind,
71}
72
73#[derive(Clone, Debug, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum RewriteKind {
79 Policy {
81 v3: String,
84 v4: Vec<(String, String)>,
86 },
87 Function {
89 v3: String,
91 v4: String,
93 },
94}
95
96impl std::fmt::Display for RewriteKind {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 match self {
99 Self::Policy { v3, v4 } => {
100 let v4 = v4
101 .iter()
102 .map(|(key, value)| format!("{key}: {value}"))
103 .collect::<Vec<_>>()
104 .join(", ");
105 write!(f, "`{v3}` is a v3 spelling, read as `{v4}`")
106 }
107 Self::Function { v3, v4 } => {
108 write!(f, "`{v3}` is a v3 spelling, read as `{v4}`")
109 }
110 }
111 }
112}
113
114impl std::fmt::Display for Rewrite {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 self.kind.fmt(f)
117 }
118}
119
120pub(super) struct Read {
124 pub(super) issues: Vec<Issue>,
125 pub(super) rewrites: Vec<Rewrite>,
126 pub(super) exts: HashMap<AuthoredPath, Ext>,
127}
128
129pub(super) fn read(workflow: &mut Map<String, Value>) -> Read {
131 let mut reader = Read::new();
132 reader.workflow(workflow);
133 reader
134}
135
136pub(super) fn read_steps(
140 items: &mut [Value],
141 at: &AuthoredPath,
142 setup: bool,
143 depth: usize,
144) -> Read {
145 let mut reader = Read::new();
146 reader.steps(items, at, setup, depth);
147 reader
148}
149
150struct Step {
152 kind: StepKind,
153 id: Option<String>,
154}
155
156enum Flag {
158 Absent,
159 Valid {
162 text: String,
163 set: bool,
164 },
165 Refused {
167 code: IssueCode,
168 message: String,
169 },
170}
171
172impl Flag {
173 fn is_set(&self) -> bool {
174 matches!(self, Self::Valid { set: true, .. })
175 }
176}
177
178fn take(obj: &mut Map<String, Value>, key: &str) -> Option<Value> {
182 obj.shift_remove(key)
183}
184
185fn keys_among(obj: &Map<String, Value>, wanted: &[&'static str]) -> Vec<&'static str> {
187 obj.keys()
188 .filter_map(|key| wanted.iter().copied().find(|w| *w == key))
189 .collect()
190}
191
192fn policies_in(obj: &Map<String, Value>, policies: &[&'static str]) -> Vec<&'static str> {
194 policies
195 .iter()
196 .copied()
197 .filter(|key| obj.contains_key(*key))
198 .collect()
199}
200
201fn mixed(v3: &str, v4: &[&str], node: &str, policies: &str) -> String {
204 let v4: Vec<String> = v4.iter().map(|key| format!("`{key}`")).collect();
205 format!(
206 "v3's `{v3}` and v4's {} on one {node}; write only the v4 {policies}",
207 v4.join(" and ")
208 )
209}
210
211impl Read {
212 fn new() -> Self {
213 Self {
214 issues: Vec::new(),
215 rewrites: Vec::new(),
216 exts: HashMap::new(),
217 }
218 }
219
220 fn report(
221 &mut self,
222 code: IssueCode,
223 path: AuthoredPath,
224 message: impl Into<String>,
225 step: Option<&Step>,
226 field: Option<&str>,
227 ) {
228 let mut issue = Issue::new(code, path, message);
229 if let Some(step) = step {
230 issue = issue.with_step(step.kind, step.id.as_deref());
231 }
232 if let Some(field) = field {
233 issue = issue.with_field(field);
234 }
235 self.issues.push(issue);
236 }
237
238 fn rewrite(
240 &mut self,
241 path: AuthoredPath,
242 kind: RewriteKind,
243 step: Option<&Step>,
244 field: Option<&str>,
245 ) {
246 let message = kind.to_string();
247 self.report(IssueCode::V3Spelling, path.clone(), message, step, field);
248 self.rewrites.push(Rewrite { path, kind });
249 }
250
251 fn workflow(&mut self, obj: &mut Map<String, Value>) {
252 const WANTED: &[&str] = &["continue_on_error", "loop", "tasks"];
253 let root = AuthoredPath::root();
254 for key in keys_among(obj, WANTED) {
255 match key {
256 "continue_on_error" => self.workflow_flag(obj, &root),
257 "loop" => {
258 if let Some(Value::Object(config)) = obj.get_mut("loop")
259 && let Some(Value::Array(setup)) = config.get_mut("setup")
260 {
261 self.steps(setup, &root.join("loop").join("setup"), true, 0);
262 }
263 }
264 "tasks" => {
265 if let Some(Value::Array(tasks)) = obj.get_mut("tasks") {
266 self.steps(tasks, &root.join("tasks"), false, 0);
267 }
268 }
269 _ => {}
270 }
271 }
272 }
273
274 fn workflow_flag(&mut self, obj: &mut Map<String, Value>, at: &AuthoredPath) {
276 let Some(raw) = take(obj, "continue_on_error") else {
277 return;
278 };
279 let path = at.join("continue_on_error");
280 let Value::Bool(flag) = raw else {
281 self.report(
282 IssueCode::ParseFailed,
283 path,
284 "expected a boolean",
285 None,
286 None,
287 );
288 return;
289 };
290 let v4 = policies_in(obj, WORKFLOW_POLICIES);
291 if !v4.is_empty() {
292 let message = mixed("continue_on_error", &v4, "workflow", "policy");
293 self.report(IssueCode::MixedSpelling, path, message, None, None);
294 return;
295 }
296 let policy = if flag { "continue" } else { "fail" };
297 obj.insert("on_failure".into(), Value::String(policy.into()));
298 let (v3, v4) = (
299 format!("continue_on_error: {flag}"),
300 vec![("on_failure".to_string(), policy.to_string())],
301 );
302 self.rewrite(path, RewriteKind::Policy { v3, v4 }, None, None);
303 }
304
305 fn steps(&mut self, items: &mut [Value], at: &AuthoredPath, setup: bool, depth: usize) {
307 for (i, node) in items.iter_mut().enumerate() {
308 if let Value::Object(obj) = node {
309 self.step(obj, at.join(i), setup, depth);
310 }
311 }
312 }
313
314 fn step(&mut self, obj: &mut Map<String, Value>, at: AuthoredPath, setup: bool, depth: usize) {
315 let group = is_group(obj);
316 let step = Step {
317 kind: step_kind(obj, setup),
318 id: obj
319 .get("id")
320 .and_then(Value::as_str)
321 .filter(|id| !id.is_empty())
322 .map(str::to_string),
323 };
324 if group {
325 self.group(obj, &at, &step, setup, depth);
326 } else {
327 self.task(obj, &at, &step);
328 }
329 }
330
331 fn group(
332 &mut self,
333 obj: &mut Map<String, Value>,
334 at: &AuthoredPath,
335 step: &Step,
336 setup: bool,
337 depth: usize,
338 ) {
339 const WANTED: &[&str] = &["continue_on_error", "tasks"];
341 for key in keys_among(obj, WANTED) {
342 if key == "continue_on_error" {
343 self.group_flag(obj, at, step);
344 } else if descends(depth)
345 && let Some(Value::Array(members)) = obj.get_mut("tasks")
346 {
347 self.steps(members, &at.join("tasks"), setup, depth + 1);
348 }
349 }
350 }
351
352 fn group_flag(&mut self, obj: &mut Map<String, Value>, at: &AuthoredPath, step: &Step) {
354 const KEY: &str = "continue_on_error";
355 let Some(value) = take(obj, KEY) else {
356 return;
357 };
358 let (code, message) = if value == Value::Bool(true) {
359 let message = format!(
361 "group '{}' carries continue_on_error, which the engine does not honour — error \
362 handling is per task and per workflow, and a group only gates a span. Put it \
363 on the tasks inside the group, or on the workflow",
364 step.id.as_deref().unwrap_or_default()
365 );
366 (IssueCode::GroupContinueOnError, message)
367 } else {
368 let message = format!(
369 "unknown key `{KEY}`: a group has no failure policy (kept in `ext` as `{KEY}`)"
370 );
371 (IssueCode::UnknownKey, message)
372 };
373 let moved = Moved {
374 key: KEY,
375 name: KEY.to_string(),
376 value,
377 path: at.join(KEY),
378 field: None,
379 };
380 self.keep_in_ext(obj, at, moved, code, message, step);
381 }
382
383 fn task(&mut self, obj: &mut Map<String, Value>, at: &AuthoredPath, step: &Step) {
384 if !TASK_V3_KEYS.iter().any(|key| obj.contains_key(*key)) {
385 self.function(obj, at, step);
386 return;
387 }
388 const WANTED: &[&str] = &["continue_on_error", "halt_on", "function"];
389 let order = keys_among(obj, WANTED);
390 let v4_policies = policies_in(obj, TASK_POLICIES);
392 let refuse_mixed = |v3: &str| Flag::Refused {
393 code: IssueCode::MixedSpelling,
394 message: mixed(v3, &v4_policies, "task", "policies"),
395 };
396 let continue_on_error = match take(obj, "continue_on_error") {
397 None => Flag::Absent,
398 Some(Value::Bool(_)) if !v4_policies.is_empty() => refuse_mixed("continue_on_error"),
399 Some(Value::Bool(set)) => Flag::Valid {
400 text: format!("continue_on_error: {set}"),
401 set,
402 },
403 Some(_) => Flag::Refused {
404 code: IssueCode::ParseFailed,
405 message: "expected a boolean".into(),
406 },
407 };
408 let halt_on = match take(obj, "halt_on") {
409 None => Flag::Absent,
410 Some(raw) => match raw.as_str() {
411 Some("never" | "failure") if !v4_policies.is_empty() => refuse_mixed("halt_on"),
412 Some(spelling @ ("never" | "failure")) => Flag::Valid {
413 text: format!("halt_on: {spelling}"),
414 set: spelling == "failure",
415 },
416 _ => Flag::Refused {
418 code: IssueCode::InvalidHaltOn,
419 message: "halt_on must be one of \"never\", \"failure\"".into(),
420 },
421 },
422 };
423
424 let v4: Vec<(String, String)> = if v4_policies.is_empty() {
427 let (on_failure, on_rejection) = match (continue_on_error.is_set(), halt_on.is_set()) {
428 (false, false) => ("fail", "continue"),
429 (true, false) => ("continue", "continue"),
430 (false, true) => ("fail", "halt"),
431 (true, true) => ("halt", "halt"),
432 };
433 obj.insert("on_failure".into(), Value::String(on_failure.into()));
434 obj.insert("on_rejection".into(), Value::String(on_rejection.into()));
435 vec![
436 ("on_failure".to_string(), on_failure.to_string()),
437 ("on_rejection".to_string(), on_rejection.to_string()),
438 ]
439 } else {
440 Vec::new()
441 };
442
443 for key in order {
444 match key {
445 "function" => self.function(obj, at, step),
446 _ => {
447 let flag = if key == "halt_on" {
448 &halt_on
449 } else {
450 &continue_on_error
451 };
452 let path = at.join(key);
453 match flag {
454 Flag::Absent => {}
455 Flag::Valid { text, .. } => {
456 self.rewrite(
457 path,
458 RewriteKind::Policy {
459 v3: text.clone(),
460 v4: v4.clone(),
461 },
462 Some(step),
463 None,
464 );
465 }
466 Flag::Refused { code, message } => {
467 self.report(*code, path, message.clone(), Some(step), None);
468 }
469 }
470 }
471 }
472 }
473 }
474
475 fn function(&mut self, obj: &mut Map<String, Value>, at: &AuthoredPath, step: &Step) {
477 let Some(Value::Object(slot)) = obj.get_mut("function") else {
478 return;
479 };
480 let mut fields = std::mem::take(slot);
483 let task = at;
484 let at = at.join("function");
485 let (is_validate, builtin) = match fields.get("name").and_then(Value::as_str) {
486 Some(V3_VALIDATE) => (true, builtin_input(VALIDATION)),
487 Some(name) => (false, builtin_input(name)),
488 None => (false, None),
489 };
490 for key in keys_among(&fields, &["input", "name"]) {
491 if key == "name" {
492 if is_validate {
493 fields.insert("name".into(), Value::String(VALIDATION.into()));
494 self.rewrite(
495 at.join("name"),
496 RewriteKind::Function {
497 v3: V3_VALIDATE.into(),
498 v4: VALIDATION.into(),
499 },
500 Some(step),
501 None,
502 );
503 }
504 } else if let Some(Value::Object(input)) = fields.get_mut("input")
505 && let Some(known) = builtin
506 {
507 self.builtin_input(obj, task, input, known, step);
508 }
509 }
510 if let Some(Value::Object(slot)) = obj.get_mut("function") {
511 *slot = fields;
512 }
513 }
514
515 fn builtin_input(
519 &mut self,
520 obj: &Map<String, Value>,
521 task: &AuthoredPath,
522 input: &mut Map<String, Value>,
523 known: BuiltinInput,
524 step: &Step,
525 ) {
526 input.retain(|key, value| {
527 if !known.keys.contains(&key.as_str()) {
528 let at = StrayKey {
529 key,
530 field: key,
531 below: AuthoredPath::root().join(key.as_str()),
532 };
533 self.stray(obj, task, at, known.keys, std::mem::take(value), step);
534 return false;
535 }
536 if let Some((array, element_keys)) = known.elements
537 && key == array
538 && let Value::Array(items) = value
539 {
540 for (i, item) in items.iter_mut().enumerate() {
541 let Value::Object(element) = item else {
542 continue;
543 };
544 element.retain(|stray, val| {
545 if element_keys.contains(&stray.as_str()) {
546 return true;
547 }
548 let at = StrayKey {
549 key: stray,
550 field: array,
551 below: AuthoredPath::root()
552 .join(array)
553 .join(i)
554 .join(stray.as_str()),
555 };
556 self.stray(obj, task, at, element_keys, std::mem::take(val), step);
557 false
558 });
559 }
560 }
561 true
562 });
563 }
564
565 fn stray(
569 &mut self,
570 obj: &Map<String, Value>,
571 task: &AuthoredPath,
572 at: StrayKey<'_>,
573 known: &[&str],
574 value: Value,
575 step: &Step,
576 ) {
577 let StrayKey { key, field, below } = at;
578 let rel = AuthoredPath::root()
579 .join("function")
580 .join("input")
581 .extend(&below);
582 let name = rel.to_string();
583 let (code, message) = unknown_key(key, known);
584 let message = format!("{message} (kept in `ext` as `{name}`)");
585 let moved = Moved {
586 key,
587 path: task.extend(&rel),
588 field: Some(field),
589 name,
590 value,
591 };
592 self.keep_in_ext(obj, task, moved, code, message, step);
593 }
594
595 fn keep_in_ext(
599 &mut self,
600 obj: &Map<String, Value>,
601 node: &AuthoredPath,
602 moved: Moved<'_>,
603 code: IssueCode,
604 message: String,
605 step: &Step,
606 ) {
607 let Moved {
608 key,
609 name,
610 value,
611 path,
612 field,
613 } = moved;
614 let ext = self
615 .exts
616 .entry(node.clone())
617 .or_insert_with(|| Ext::authored(obj.get("ext")));
618 match ext.keep(key, name, value, path.clone()) {
619 Ok(()) => self.report(code, path, message, Some(step), field),
620 Err(collision) => {
621 self.report(IssueCode::ExtCollision, path, collision, Some(step), field);
622 }
623 }
624 }
625}
626
627struct StrayKey<'k> {
629 key: &'k str,
631 field: &'k str,
634 below: AuthoredPath,
636}
637
638struct Moved<'k> {
640 key: &'k str,
642 name: String,
644 value: Value,
645 path: AuthoredPath,
646 field: Option<&'k str>,
648}