Skip to main content

quanttide_work/
workflow.rs

1//! 工作流聚合:一串有序的步骤。
2//!
3//! 定义的**语法与不变量**都在这个文件里——字段名、取值、判据种类,不认识、缺了、
4//! 越界,当场报错。规矩的出处是 `docs/specification/process/workflow.md`·语法。
5//!
6//! 模型不可变:[`Workflow::from_yaml`] 读进来顺带校验,[`Workflow::of`] 读已经校验过的,
7//! [`Workflow::to_yaml`] 写成同样的字段形状。YAML 怎么读写是各自包的事。
8
9use crate::criterion::Criterion;
10use crate::executor::{AGENT, CRITERION_TYPES, EXECUTORS, HUMAN, RULE};
11use serde_yaml::{Mapping, Value as Yaml};
12
13/// 定义顶层认得的字段。
14const TOP_FIELDS: [&str; 3] = ["name", "description", "steps"];
15
16/// 步骤认得的字段。
17const STEP_FIELDS: [&str; 4] = ["name", "description", "executor", "criteria"];
18
19/// 一条判据认得的字段。
20const CRITERION_FIELDS: [&str; 7] = [
21    "executor",
22    "description",
23    "path",
24    "absent",
25    "file",
26    "contains",
27    "run",
28];
29
30/// 一份定义读不通:字段缺了、取值越界、有不认识的字段。
31#[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
42/// 取一个字符串字段,去掉两侧空白;不是字符串就当没写。
43pub 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
52/// 这次给的字段里,哪些是不认识的。
53pub 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
62/// 从定义里的字段认出一条判据(不校验)。
63pub 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
97/// 读一条判据:取值不对、缺该有的字段,当场报错。
98///
99/// `file` 与 `place` 只用来说话;返回的是认好的值对象。
100pub 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
165/// 读一份定义:不是映射、缺字段、取值不对,当场报错。`file` 只用来说话。
166pub fn validate(payload: &Yaml, file: &str) -> Result<(), DefinitionError> {
167    Workflow::from_yaml(payload, file).map(|_| ())
168}
169
170/// 一个工作步骤:叫什么、做什么、谁执行、怎么算完。
171#[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    /// 从定义里的字段读出(不校验)。用在已经校验过的定义上。
181    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    /// 从定义里的字段读出,顺带把语法过一遍。
200    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    /// 这一步的执行者是不是人。
250    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    /// 写回定义里的字段形状。
291    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/// 工作流聚合:一串步骤(不含文件位置——那是各自包的事)。
315#[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    /// 从定义里的字段读出(不校验);`name` 由调用方给(比如文件名)。
324    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    /// 从定义里的字段读出,顺带把语法过一遍。
337    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    /// 读已校验的定义;`name` 由调用方给(比如文件名)。
369    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    /// 步骤名,按定义顺序。
382    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    /// 核对这条定义:判据里的路径在不在、描述提到的小节有没有判据覆盖。
391    ///
392    /// `exists` 由调用方给——工具箱不碰文件系统。
393    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    /// 写回定义里的字段形状。
468    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/// 定义核对出来的一件事:在哪里、核的是什么、过没过。
486///
487/// 它是「把这条定义对着工作区核一遍」的回执,不单列成一个文件——
488/// `workflow --check` 的实现产物,规范里还没有这一节。
489#[derive(Debug, Clone)]
490pub struct Finding {
491    pub where_: String,
492    pub what: String,
493    pub ok: bool,
494}
495
496/// 像不像报告小节的名字:中文短词。版本号写法、占位、路径都不算。
497pub 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
509/// 判据里的占位先按数据仓展开(够核对用)。
510pub 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}