1use crate::criterion::Criterion;
10use crate::executor::{AGENT, CRITERION_TYPES, EXECUTORS, HUMAN, RULE};
11use serde_yaml::{Mapping, Value as Yaml};
12
13const TOP_FIELDS: [&str; 3] = ["name", "description", "steps"];
15
16const STEP_FIELDS: [&str; 4] = ["name", "description", "executor", "criteria"];
18
19const CRITERION_FIELDS: [&str; 7] = [
21 "executor",
22 "description",
23 "path",
24 "absent",
25 "file",
26 "contains",
27 "run",
28];
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct DefinitionError(pub String);
33
34impl std::fmt::Display for DefinitionError {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 write!(f, "{}", self.0)
37 }
38}
39
40impl std::error::Error for DefinitionError {}
41
42pub fn text_of(value: &Yaml, key: &str) -> String {
44 value
45 .get(key)
46 .and_then(|v| v.as_str())
47 .unwrap_or("")
48 .trim()
49 .to_string()
50}
51
52pub fn unknown_fields(mapping: &Mapping, allowed: &[&str]) -> Vec<String> {
54 mapping
55 .keys()
56 .filter_map(|key| key.as_str())
57 .filter(|key| !allowed.contains(key))
58 .map(|key| key.to_string())
59 .collect()
60}
61
62pub fn criterion_of(value: &Yaml) -> Criterion {
64 let description = text_of(value, "description");
65 let kind = text_of(value, "executor");
66 if kind == AGENT {
67 return Criterion::AgentJudgement { description };
68 }
69 if kind == HUMAN {
70 return Criterion::HumanGate { description };
71 }
72 let path = text_of(value, "path");
73 if !path.is_empty() {
74 return Criterion::PathExists { path, description };
75 }
76 let absent = text_of(value, "absent");
77 if !absent.is_empty() {
78 return Criterion::PathAbsent {
79 absent,
80 description,
81 };
82 }
83 let file = text_of(value, "file");
84 if !file.is_empty() {
85 return Criterion::FileContains {
86 file,
87 contains: text_of(value, "contains"),
88 description,
89 };
90 }
91 Criterion::CommandRun {
92 run: text_of(value, "run"),
93 description,
94 }
95}
96
97pub fn read_criterion(value: &Yaml, file: &str, place: &str) -> Result<Criterion, DefinitionError> {
101 let kind = text_of(value, "executor");
102 if !CRITERION_TYPES.contains(&kind.as_str()) {
103 return Err(DefinitionError(format!(
104 "{file} {place}的 executor 只能是 {}(谁判:规则引擎 / 智能体 / 人)",
105 CRITERION_TYPES.join(" / ")
106 )));
107 }
108 let criterion_map = value
109 .as_mapping()
110 .ok_or_else(|| DefinitionError(format!("{file} {place}不是映射")))?;
111 let odd = unknown_fields(criterion_map, &CRITERION_FIELDS);
112 if !odd.is_empty() {
113 return Err(DefinitionError(format!(
114 "{file} {place}有不认识的字段:{}(只认 {})",
115 odd.join("、"),
116 CRITERION_FIELDS.join("、")
117 )));
118 }
119 let given: Vec<&str> = ["path", "absent", "file", "contains", "run"]
120 .into_iter()
121 .filter(|name| value.get(*name).is_some())
122 .collect();
123 if kind == RULE {
124 if given.is_empty() {
125 return Err(DefinitionError(format!(
126 "{file} {place}是 rule,得写一条判法(path / absent / file+contains / run)"
127 )));
128 }
129 if given.contains(&"contains") && !given.contains(&"file") {
130 return Err(DefinitionError(format!(
131 "{file} {place}写了 contains,还得写 file"
132 )));
133 }
134 if given.contains(&"file") && !given.contains(&"contains") {
135 return Err(DefinitionError(format!(
136 "{file} {place}写了 file,还得写 contains"
137 )));
138 }
139 let others: Vec<&str> = given
140 .iter()
141 .copied()
142 .filter(|name| *name != "file" && *name != "contains")
143 .collect();
144 if others.len() > 1 || (!others.is_empty() && given.contains(&"file")) {
145 return Err(DefinitionError(format!(
146 "{file} {place}的判法只能一种:path / absent / file+contains / run"
147 )));
148 }
149 } else {
150 if text_of(value, "description").is_empty() {
151 return Err(DefinitionError(format!(
152 "{file} {place}是 {kind},必须写 description(判准 / 要人拍板的事)"
153 )));
154 }
155 if !given.is_empty() {
156 return Err(DefinitionError(format!(
157 "{file} {place}是 {kind},不该带 {}(那是 rule 的字段)",
158 given.join("、")
159 )));
160 }
161 }
162 Ok(criterion_of(value))
163}
164
165pub fn validate(payload: &Yaml, file: &str) -> Result<(), DefinitionError> {
167 Workflow::from_yaml(payload, file).map(|_| ())
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct Step {
173 pub name: String,
174 pub description: String,
175 pub executor: String,
176 pub criteria: Vec<Criterion>,
177}
178
179impl Step {
180 pub fn of(value: &Yaml) -> Step {
182 let criteria = value
183 .get("criteria")
184 .and_then(|v| v.as_sequence())
185 .map(|items| items.iter().map(criterion_of).collect())
186 .unwrap_or_default();
187 let mut executor = text_of(value, "executor");
188 if executor.is_empty() {
189 executor = AGENT.to_string();
190 }
191 Step {
192 name: text_of(value, "name"),
193 description: text_of(value, "description"),
194 executor,
195 criteria,
196 }
197 }
198
199 pub fn from_yaml(value: &Yaml, file: &str, position: usize) -> Result<Step, DefinitionError> {
201 let step_map = value
202 .as_mapping()
203 .ok_or_else(|| DefinitionError(format!("{file} 第 {position} 个步骤少了 name")))?;
204 if text_of(value, "name").is_empty() {
205 return Err(DefinitionError(format!(
206 "{file} 第 {position} 个步骤少了 name"
207 )));
208 }
209 let extra = unknown_fields(step_map, &STEP_FIELDS);
210 if !extra.is_empty() {
211 return Err(DefinitionError(format!(
212 "{file} 第 {position} 个步骤有不认识的字段:{}(只认 {})",
213 extra.join("、"),
214 STEP_FIELDS.join("、")
215 )));
216 }
217 let mut executor = text_of(value, "executor");
218 if executor.is_empty() {
219 executor = AGENT.to_string();
220 }
221 if !EXECUTORS.contains(&executor.as_str()) {
222 return Err(DefinitionError(format!(
223 "{file} 第 {position} 个步骤的 executor 只能是 {},实得 {executor}",
224 EXECUTORS.join(" 或 ")
225 )));
226 }
227 let criteria: &[Yaml] = match value.get("criteria") {
228 None | Some(Yaml::Null) => &[],
229 Some(Yaml::Sequence(items)) => items.as_slice(),
230 Some(_) => {
231 return Err(DefinitionError(format!(
232 "{file} 第 {position} 个步骤的 criteria 应当是列表"
233 )));
234 }
235 };
236 let mut parsed = Vec::with_capacity(criteria.len());
237 for (order, criterion) in criteria.iter().enumerate() {
238 let place = format!("第 {position} 个步骤第 {} 条判据", order + 1);
239 parsed.push(read_criterion(criterion, file, &place)?);
240 }
241 Ok(Step {
242 name: text_of(value, "name"),
243 description: text_of(value, "description"),
244 executor,
245 criteria: parsed,
246 })
247 }
248
249 pub fn human(&self) -> bool {
251 self.executor == HUMAN
252 }
253
254 pub fn name(&self) -> String {
255 self.name.clone()
256 }
257
258 pub fn description(&self) -> String {
259 self.description.clone()
260 }
261
262 pub fn executor(&self) -> String {
263 self.executor.clone()
264 }
265
266 pub fn criteria(&self) -> Vec<Criterion> {
267 self.criteria.clone()
268 }
269
270 pub fn rules(&self) -> Vec<Criterion> {
271 self.of_kind(RULE)
272 }
273
274 pub fn agents(&self) -> Vec<Criterion> {
275 self.of_kind(AGENT)
276 }
277
278 pub fn gates(&self) -> Vec<Criterion> {
279 self.of_kind(HUMAN)
280 }
281
282 fn of_kind(&self, kind: &str) -> Vec<Criterion> {
283 self.criteria
284 .iter()
285 .filter(|item| item.executor() == kind)
286 .cloned()
287 .collect()
288 }
289
290 pub fn to_yaml(&self) -> Yaml {
292 let mut map = Mapping::new();
293 map.insert(Yaml::String("name".into()), Yaml::String(self.name.clone()));
294 if !self.description.is_empty() {
295 map.insert(
296 Yaml::String("description".into()),
297 Yaml::String(self.description.clone()),
298 );
299 }
300 map.insert(
301 Yaml::String("executor".into()),
302 Yaml::String(self.executor.clone()),
303 );
304 if !self.criteria.is_empty() {
305 map.insert(
306 Yaml::String("criteria".into()),
307 Yaml::Sequence(self.criteria.iter().map(Criterion::to_yaml).collect()),
308 );
309 }
310 Yaml::Mapping(map)
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct Workflow {
317 pub name: String,
318 pub description: String,
319 pub steps: Vec<Step>,
320}
321
322impl Workflow {
323 pub fn of(name: &str, payload: &Yaml) -> Workflow {
325 Workflow {
326 name: name.to_string(),
327 description: text_of(payload, "description"),
328 steps: payload
329 .get("steps")
330 .and_then(|v| v.as_sequence())
331 .map(|items| items.iter().map(Step::of).collect())
332 .unwrap_or_default(),
333 }
334 }
335
336 pub fn from_yaml(payload: &Yaml, file: &str) -> Result<Workflow, DefinitionError> {
338 let top = payload
339 .as_mapping()
340 .ok_or_else(|| DefinitionError(format!("{file} 的顶层不是映射(name / steps)")))?;
341 if text_of(payload, "name").is_empty() {
342 return Err(DefinitionError(format!("{file} 少了 name")));
343 }
344 let steps = payload
345 .get("steps")
346 .and_then(|v| v.as_sequence())
347 .filter(|items| !items.is_empty())
348 .ok_or_else(|| DefinitionError(format!("{file} 少了 steps(至少一个步骤)")))?;
349 let unknown = unknown_fields(top, &TOP_FIELDS);
350 if !unknown.is_empty() {
351 return Err(DefinitionError(format!(
352 "{file} 顶层有不认识的字段:{}(只认 {})",
353 unknown.join("、"),
354 TOP_FIELDS.join("、")
355 )));
356 }
357 let mut parsed = Vec::with_capacity(steps.len());
358 for (index, step) in steps.iter().enumerate() {
359 parsed.push(Step::from_yaml(step, file, index + 1)?);
360 }
361 Ok(Workflow {
362 name: text_of(payload, "name"),
363 description: text_of(payload, "description"),
364 steps: parsed,
365 })
366 }
367
368 pub fn new(name: &str, payload: &Yaml) -> Workflow {
370 Workflow::of(name, payload)
371 }
372
373 pub fn description(&self) -> String {
374 self.description.clone()
375 }
376
377 pub fn steps(&self) -> Vec<Step> {
378 self.steps.clone()
379 }
380
381 pub fn step_names(&self) -> Vec<String> {
383 self.steps.iter().map(|step| step.name.clone()).collect()
384 }
385
386 pub fn step(&self, name: &str) -> Option<Step> {
387 self.steps.iter().find(|step| step.name == name).cloned()
388 }
389
390 pub fn check<F>(&self, data: &str, exists: F) -> Vec<Finding>
394 where
395 F: Fn(&str) -> bool,
396 {
397 let mut found: Vec<Finding> = Vec::new();
398 for step in &self.steps {
399 for criterion in step.rules() {
400 let literal = match &criterion {
401 Criterion::PathExists { path, .. } => path.clone(),
402 Criterion::FileContains { file, .. } => file.clone(),
403 _ => continue,
404 };
405 if literal.contains("{{report}}")
406 || literal.contains("{{journal}}")
407 || literal.contains("{{log}}")
408 {
409 continue;
410 }
411 let written = expand_placeholders(&literal, data);
412 found.push(Finding {
413 where_: format!("{}·{}", step.name, literal),
414 what: format!("判据里的路径在不在:{written}"),
415 ok: exists(&written),
416 });
417 }
418 }
419
420 let covered: Vec<String> = self
421 .steps
422 .iter()
423 .flat_map(|step| step.rules())
424 .filter_map(|criterion| match criterion {
425 Criterion::FileContains { contains, .. } => Some(contains),
426 _ => None,
427 })
428 .collect();
429 let mut mentioned: Vec<String> = Vec::new();
430 for step in &self.steps {
431 let text = step.description.clone();
432 for piece in text.split("## ").skip(1) {
433 let name = piece
434 .split(|ch: char| ch.is_whitespace() || ch == '`' || ch == '」')
435 .next()
436 .unwrap_or("")
437 .trim()
438 .to_string();
439 if looks_like_section(&name) && !mentioned.contains(&name) {
440 mentioned.push(name);
441 }
442 }
443 let mut rest: &str = text.as_str();
444 while let Some(at) = rest.find('「') {
445 let after = &rest[at + '「'.len_utf8()..];
446 let Some(end) = after.find('」') else { break };
447 let name = after[..end].trim().to_string();
448 let tail = after[end + '」'.len_utf8()..].trim_start();
449 let is_section =
450 tail.starts_with("一节") || tail.starts_with("节") || tail.starts_with("两节");
451 if is_section && looks_like_section(&name) && !mentioned.contains(&name) {
452 mentioned.push(name);
453 }
454 rest = &after[end + '」'.len_utf8()..];
455 }
456 }
457 for name in mentioned {
458 found.push(Finding {
459 where_: "description".to_string(),
460 what: format!("description 提到的报告小节有没有判据覆盖:{name}"),
461 ok: covered.iter().any(|value| value.contains(&name)),
462 });
463 }
464 found
465 }
466
467 pub fn to_yaml(&self) -> Yaml {
469 let mut map = Mapping::new();
470 map.insert(Yaml::String("name".into()), Yaml::String(self.name.clone()));
471 if !self.description.is_empty() {
472 map.insert(
473 Yaml::String("description".into()),
474 Yaml::String(self.description.clone()),
475 );
476 }
477 map.insert(
478 Yaml::String("steps".into()),
479 Yaml::Sequence(self.steps.iter().map(Step::to_yaml).collect()),
480 );
481 Yaml::Mapping(map)
482 }
483}
484
485#[derive(Debug, Clone)]
490pub struct Finding {
491 pub where_: String,
492 pub what: String,
493 pub ok: bool,
494}
495
496pub fn looks_like_section(name: &str) -> bool {
498 !name.is_empty()
499 && name.chars().count() <= 12
500 && !name.contains(|ch: char| {
501 ch.is_ascii_digit()
502 || matches!(
503 ch,
504 '[' | ']' | '{' | '}' | '.' | '/' | '`' | '<' | '>' | '-' | '_'
505 )
506 })
507}
508
509pub fn expand_placeholders(value: &str, data: &str) -> String {
511 value
512 .replace("{{artifacts}}", &format!("{data}/artifacts"))
513 .replace("{{report}}", &format!("{data}/artifacts/report"))
514 .replace("{{journal}}", &format!("{data}/artifacts/journal"))
515 .replace("{{log}}", &format!("{data}/tasks"))
516}