rac_engine/
watchkeeper.rs1use std::path::Path;
9
10use crate::compare::{compare_states, load_state, RepositoryComparison};
11use crate::intent::{
12 analyze_intent, IntentFinding, ACCEPTANCE_CRITERIA_REMOVED, CONSTRAINT_REMOVED,
13 CONSTRAINT_WEAKENED, SEVERITY_WARNING, SPECIFICITY_REGRESSION, SUCCESS_MEASURES_REMOVED,
14};
15use crate::pycompat::{py_abspath, py_relpath};
16use crate::revisions::{materialize_revision, repository_root, MaterializedRevision, RevisionError};
17
18pub const REASON_VALIDATION_REGRESSION: &str = "validation_regression";
20pub const REASON_BROKEN_RELATIONSHIP: &str = "broken_relationship";
21
22pub const RECOMMENDING_FINDINGS: [&str; 5] = [
25 SPECIFICITY_REGRESSION,
26 CONSTRAINT_WEAKENED,
27 CONSTRAINT_REMOVED,
28 ACCEPTANCE_CRITERIA_REMOVED,
29 SUCCESS_MEASURES_REMOVED,
30];
31
32pub fn is_recommending(code: &str) -> bool {
33 RECOMMENDING_FINDINGS.contains(&code)
34}
35
36fn reason_text(code: &str) -> &'static str {
39 match code {
40 REASON_VALIDATION_REGRESSION => "One or more artifacts became invalid.",
41 REASON_BROKEN_RELATIONSHIP => "One or more relationship references broke.",
42 SPECIFICITY_REGRESSION => "A measurable requirement became vague.",
43 CONSTRAINT_WEAKENED => "A mandatory requirement was weakened.",
44 CONSTRAINT_REMOVED => "A requirement with mandatory wording was removed.",
45 ACCEPTANCE_CRITERIA_REMOVED => "An acceptance criteria section was removed.",
46 SUCCESS_MEASURES_REMOVED => "A success measures section was removed.",
47 _ => "",
48 }
49}
50
51#[derive(Debug, Clone)]
53pub struct ReviewRecommendation {
54 pub code: &'static str,
55 pub reason: &'static str,
56}
57
58pub struct WatchkeeperReport {
60 pub directory: String,
61 pub base: String, pub head: String, pub comparison: RepositoryComparison,
64 pub findings: Vec<IntentFinding>,
65 pub recommendations: Vec<ReviewRecommendation>,
66}
67
68impl WatchkeeperReport {
69 pub fn review_recommended(&self) -> bool {
70 !self.recommendations.is_empty()
71 }
72
73 pub fn has_warnings(&self) -> bool {
74 self.findings
75 .iter()
76 .any(|f| f.severity == SEVERITY_WARNING)
77 }
78}
79
80pub fn derive_recommendations(
84 comparison: &RepositoryComparison,
85 findings: &[IntentFinding],
86) -> Vec<ReviewRecommendation> {
87 let mut codes: Vec<&'static str> = Vec::new();
88 if !comparison.validation.newly_invalid.is_empty() {
89 codes.push(REASON_VALIDATION_REGRESSION);
90 }
91 if !comparison.relationships.new_issues.is_empty() {
92 codes.push(REASON_BROKEN_RELATIONSHIP);
93 }
94 for finding in findings {
95 if is_recommending(finding.code) && !codes.contains(&finding.code) {
96 codes.push(finding.code);
97 }
98 }
99 codes
100 .into_iter()
101 .map(|code| ReviewRecommendation {
102 code,
103 reason: reason_text(code),
104 })
105 .collect()
106}
107
108fn resolve_side(
111 guards: &mut Vec<MaterializedRevision>,
112 directory: &str,
113 reference: &str,
114) -> Result<String, RevisionError> {
115 if Path::new(reference).is_dir() {
116 return Ok(reference.to_string());
117 }
118 let root = repository_root(directory)?;
119 let subpath = py_relpath(&py_abspath(directory), &root);
120 let materialized = materialize_revision(&root, reference, &subpath)?;
121 let corpus = materialized.corpus.to_string_lossy().into_owned();
122 guards.push(materialized);
123 Ok(corpus)
124}
125
126pub fn build_watchkeeper_report(
130 directory: &str,
131 base: &str,
132 head: Option<&str>,
133) -> Result<WatchkeeperReport, RevisionError> {
134 let head_label = head.unwrap_or(directory);
135 let mut guards: Vec<MaterializedRevision> = Vec::new();
136 let base_dir = resolve_side(&mut guards, directory, base)?;
137 let head_dir = match head {
138 Some(reference) => resolve_side(&mut guards, directory, reference)?,
139 None => directory.to_string(),
140 };
141 let base_state = load_state(&base_dir, base);
142 let head_state = load_state(&head_dir, head_label);
143 let comparison = compare_states(base_state, head_state);
144 let findings = analyze_intent(&comparison);
145 drop(guards); let recommendations = derive_recommendations(&comparison, &findings);
147 Ok(WatchkeeperReport {
148 directory: directory.to_string(),
149 base: base.to_string(),
150 head: head_label.to_string(),
151 comparison,
152 findings,
153 recommendations,
154 })
155}