1use serde::Deserialize;
4use serde_json::Value;
5use std::collections::HashSet;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10const EVALUATOR_COMMAND_ID: &str = "fno delivery evaluate --json";
11
12#[derive(Debug)]
13pub enum DeliveryCompletion {
14 Inactive,
15 Passed {
16 fact_revision: String,
17 evidence_revision: String,
18 work_order_node_id: String,
19 verdict: Value,
20 },
21 Nonpassing {
22 reason: String,
23 evidence_revision: Option<String>,
24 },
25}
26
27impl DeliveryCompletion {
28 pub fn is_active(&self) -> bool {
29 !matches!(self, Self::Inactive)
30 }
31
32 pub fn delivery_fingerprint(&self, legacy: String) -> String {
33 let revision = match self {
34 Self::Inactive => return legacy,
35 Self::Passed {
36 evidence_revision, ..
37 }
38 | Self::Nonpassing {
39 evidence_revision: Some(evidence_revision),
40 ..
41 } => evidence_revision.as_str(),
42 Self::Nonpassing { .. } => "unavailable",
43 };
44 let (head, tail) = legacy
45 .split_once('|')
46 .unwrap_or((&legacy, "none|none|none"));
47 format!("{head}@delivery:{revision}|{tail}")
48 }
49}
50
51#[derive(Debug)]
52pub struct DeliveryReceipt {
53 pub node: String,
54 pub attempt: String,
55 pub fact_revision: String,
56 pub uri: String,
57}
58
59pub fn pr_passes(open: bool, ci: bool, reviewed: bool, head: bool, probes: bool) -> bool {
61 open && ci && reviewed && head && probes
62}
63
64#[derive(Debug, Deserialize)]
65#[serde(deny_unknown_fields)]
66struct Response {
67 version: String,
68 status: String,
69 fact_revision: Value,
70 #[serde(default)]
71 evidence_revision: Option<String>,
72 verdict: Value,
73 diagnostics: Vec<String>,
74}
75
76#[derive(Debug, Deserialize)]
77#[serde(deny_unknown_fields)]
78struct Verdict {
79 evaluator_version: String,
80 #[serde(default, rename = "session_id")]
81 _session_id: Option<String>,
82 work_order_node_id: String,
83 attempt_id: String,
84 aggregate: String,
85 fact_revision: Value,
86 required_requirements: Vec<RequirementBinding>,
87 requirements: Vec<Requirement>,
88 diagnostics: Vec<String>,
89}
90
91#[derive(Debug, Deserialize)]
92#[serde(deny_unknown_fields)]
93struct RequirementBinding {
94 deliverable_id: String,
95 evidence_id: String,
96}
97
98#[derive(Debug, Deserialize)]
99#[serde(deny_unknown_fields)]
100struct Requirement {
101 deliverable_id: String,
102 evidence_id: String,
103 subject_kind: String,
104 subject_id: String,
105 result: String,
106 producers: Vec<String>,
107 source_revisions: Vec<String>,
108 diagnostics: Vec<String>,
109}
110
111pub fn evaluate(fno_bin: &str, cwd: &Path, plan_path: &Path, events: &Path) -> DeliveryCompletion {
112 match activation(plan_path) {
113 Activation::Inactive => return DeliveryCompletion::Inactive,
114 Activation::Invalid(reason) => return nonpassing(reason, None),
115 Activation::Active => {}
116 }
117 let output = match Command::new(fno_bin)
118 .args(["delivery", "evaluate", "--json", "--plan-path"])
119 .arg(plan_path)
120 .arg("--events")
121 .arg(events)
122 .current_dir(cwd)
123 .output()
124 {
125 Ok(output) if output.status.success() => output,
126 Ok(output) => {
127 return nonpassing(
128 format!(
129 "delivery evaluator exited {:?}: {}",
130 output.status.code(),
131 String::from_utf8_lossy(&output.stderr).trim()
132 ),
133 None,
134 )
135 }
136 Err(error) => {
137 return nonpassing(format!("delivery evaluator could not start: {error}"), None)
138 }
139 };
140 parse_response(&output.stdout)
141}
142
143pub fn evaluate_manifest(cwd: &Path, plan_path: Option<&str>, events: &Path) -> DeliveryCompletion {
144 let Some(plan_path) = plan_path else {
145 return DeliveryCompletion::Inactive;
146 };
147 let plan_path = PathBuf::from(plan_path);
148 let plan_path = if plan_path.is_absolute() {
149 plan_path
150 } else {
151 cwd.join(plan_path)
152 };
153 let fno_bin = std::env::var("FNO_LOOPCHECK_FNO_BIN").unwrap_or_else(|_| "fno".into());
154 evaluate(&fno_bin, cwd, &plan_path, events)
155}
156
157#[allow(clippy::too_many_arguments)]
158pub fn gate_output(
159 completion: &DeliveryCompletion,
160 promise: bool,
161 project_events: &Path,
162 global_events: &Path,
163 session_id: &str,
164 manifest_session_id: Option<&str>,
165 expected_node: Option<&str>,
166 intent_source: &str,
167 fingerprint: &str,
168 fires: u64,
169 backstop_tripped: bool,
170 consecutive_unchanged: u64,
171 streak_window_secs: i64,
172 pr_state: &str,
173 ci: &str,
174) -> Option<String> {
175 let DeliveryCompletion::Passed {
176 fact_revision,
177 evidence_revision: _,
178 work_order_node_id,
179 verdict,
180 } = completion
181 else {
182 return match completion {
183 DeliveryCompletion::Inactive => None,
184 DeliveryCompletion::Nonpassing { reason, .. } => Some(nonpassing_output(
185 project_events,
186 global_events,
187 session_id,
188 intent_source,
189 fingerprint,
190 fires,
191 backstop_tripped,
192 promise,
193 consecutive_unchanged,
194 streak_window_secs,
195 pr_state,
196 ci,
197 &format!("generic delivery undeterminable: {reason}"),
198 )),
199 DeliveryCompletion::Passed { .. } => unreachable!(),
200 };
201 };
202 if manifest_session_id.is_none_or(|value| value.trim().is_empty()) {
203 return Some(nonpassing_output(
204 project_events,
205 global_events,
206 session_id,
207 intent_source,
208 fingerprint,
209 fires,
210 backstop_tripped,
211 promise,
212 consecutive_unchanged,
213 streak_window_secs,
214 pr_state,
215 ci,
216 "generic delivery session has no session id binding",
217 ));
218 }
219 let binding_error = match expected_node {
220 Some(expected) if expected == work_order_node_id => None,
221 Some(expected) => Some(format!(
222 "generic delivery verdict node {work_order_node_id} does not match session node {expected}"
223 )),
224 None => Some("generic delivery session has no graph node binding".into()),
225 };
226 if let Some(reason) = binding_error {
227 return Some(nonpassing_output(
228 project_events,
229 global_events,
230 session_id,
231 intent_source,
232 fingerprint,
233 fires,
234 backstop_tripped,
235 promise,
236 consecutive_unchanged,
237 streak_window_secs,
238 pr_state,
239 ci,
240 &reason,
241 ));
242 }
243 if !promise {
244 return Some(nonpassing_output(
245 project_events,
246 global_events,
247 session_id,
248 intent_source,
249 fingerprint,
250 fires,
251 backstop_tripped,
252 promise,
253 consecutive_unchanged,
254 streak_window_secs,
255 pr_state,
256 ci,
257 "generic delivery requires a promise",
258 ));
259 }
260 if !emit_verdict(project_events, global_events, session_id, verdict) {
261 return Some(crate::completion_output::allow_output(
262 "block",
263 None,
264 "generic delivery verdict could not be durably recorded",
265 fires,
266 Some(fingerprint.into()),
267 ));
268 }
269 let terminal_message =
270 format!("generic delivery passed via {EVALUATOR_COMMAND_ID} at {fact_revision}");
271 crate::loopcheck::emit_to_both(
272 project_events,
273 global_events,
274 "loop_check",
275 serde_json::json!({
276 "session_id": session_id, "decision": "allow", "intent": "promise",
277 "intent_source": intent_source, "fingerprint": fingerprint,
278 "fires": fires, "fact_revision": fact_revision,
279 "consecutive_unchanged": consecutive_unchanged,
280 "streak_window_secs": streak_window_secs,
281 "pr_state": pr_state, "ci": ci, "reviewed": false,
282 "review_skipped": true, "fp_read_failed": false
283 }),
284 );
285 Some(crate::completion_output::allow_output(
286 "allow",
287 Some(crate::loopcheck::TerminationReason::DoneDelivery),
288 &terminal_message,
289 fires,
290 Some(fingerprint.into()),
291 ))
292}
293
294#[allow(clippy::too_many_arguments)]
295fn nonpassing_output(
296 project_events: &Path,
297 global_events: &Path,
298 session_id: &str,
299 intent_source: &str,
300 fingerprint: &str,
301 fires: u64,
302 backstop_tripped: bool,
303 promise: bool,
304 consecutive_unchanged: u64,
305 streak_window_secs: i64,
306 pr_state: &str,
307 ci: &str,
308 reason: &str,
309) -> String {
310 if !backstop_tripped {
311 crate::loopcheck::emit_to_both(
312 project_events,
313 global_events,
314 "loop_check",
315 serde_json::json!({
316 "session_id": session_id,
317 "decision": "block",
318 "intent": if promise { "promise" } else { "none" },
319 "intent_source": intent_source,
320 "fingerprint": fingerprint,
321 "fires": fires,
322 "consecutive_unchanged": consecutive_unchanged,
323 "streak_window_secs": streak_window_secs,
324 "pr_state": pr_state, "ci": ci, "reviewed": false,
325 "review_skipped": true, "fp_read_failed": false
326 }),
327 );
328 return crate::completion_output::allow_output(
329 "block",
330 None,
331 reason,
332 fires,
333 Some(fingerprint.into()),
334 );
335 }
336 let message = format!("generic delivery made no progress: {reason}");
337 crate::loopcheck::emit_to_both(
338 project_events,
339 global_events,
340 "termination",
341 serde_json::json!({
342 "session_id": session_id,
343 "reason": "NoProgress",
344 "message": message
345 }),
346 );
347 crate::loopcheck::emit_to_both(
348 project_events,
349 global_events,
350 "loop_check",
351 serde_json::json!({
352 "session_id": session_id,
353 "decision": "allow",
354 "intent": "backstop",
355 "intent_source": intent_source,
356 "fingerprint": fingerprint,
357 "fires": fires,
358 "consecutive_unchanged": consecutive_unchanged,
359 "streak_window_secs": streak_window_secs,
360 "pr_state": pr_state, "ci": ci, "reviewed": false,
361 "review_skipped": true, "fp_read_failed": false
362 }),
363 );
364 crate::completion_output::allow_output(
365 "allow",
366 Some(crate::loopcheck::TerminationReason::NoProgress),
367 &message,
368 fires,
369 Some(fingerprint.into()),
370 )
371}
372
373pub fn emit_verdict(
374 project_events: &Path,
375 global_events: &Path,
376 session_id: &str,
377 verdict: &Value,
378) -> bool {
379 let mut data = verdict.clone();
380 let Some(mapping) = data.as_object_mut() else {
381 return false;
382 };
383 mapping.insert("session_id".into(), Value::String(session_id.into()));
384 let envelope = serde_json::json!({
385 "ts": chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
386 "type": "delivery_verdict_evaluated",
387 "source": "target",
388 "data": data,
389 });
390 let Ok(mut line) = serde_json::to_vec(&envelope) else {
391 return false;
392 };
393 line.push(b'\n');
394 let durable = append(project_events, &line);
395 if project_events != global_events {
396 let _ = append(global_events, &line);
397 }
398 durable
399}
400
401pub fn emit_terminal(
402 project_events: &Path,
403 global_events: &Path,
404 session_id: &str,
405 message: &str,
406) -> bool {
407 let envelope = serde_json::json!({
408 "ts": chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
409 "type": "termination",
410 "source": "hook",
411 "data": {
412 "session_id": session_id,
413 "reason": "DoneDelivery",
414 "message": message
415 },
416 });
417 let Ok(mut line) = serde_json::to_vec(&envelope) else {
418 return false;
419 };
420 line.push(b'\n');
421 let durable = append(project_events, &line);
422 if project_events != global_events {
423 let _ = append(global_events, &line);
424 }
425 durable
426}
427
428fn append(path: &Path, line: &[u8]) -> bool {
429 if let Some(parent) = path.parent() {
430 let _ = std::fs::create_dir_all(parent);
431 }
432 match std::fs::OpenOptions::new()
433 .create(true)
434 .append(true)
435 .open(path)
436 {
437 Ok(mut file) => match file.write_all(line) {
438 Ok(()) => true,
439 Err(error) => {
440 eprintln!(
441 "delivery completion: append {} failed: {error}",
442 path.display()
443 );
444 false
445 }
446 },
447 Err(error) => {
448 eprintln!(
449 "delivery completion: open {} failed: {error}",
450 path.display()
451 );
452 false
453 }
454 }
455}
456
457pub fn selected_receipt(
458 events: &Path,
459 expected_node: Option<&str>,
460 session_id: &str,
461) -> Option<DeliveryReceipt> {
462 let content = std::fs::read_to_string(events).ok()?;
463 for line in content.lines().rev() {
464 let event: Value = match serde_json::from_str(line) {
465 Ok(event) => event,
466 Err(_) => continue,
467 };
468 if event.get("type").and_then(Value::as_str) != Some("delivery_verdict_evaluated")
469 || event.get("source").and_then(Value::as_str) != Some("target")
470 {
471 continue;
472 }
473 let data = event.get("data")?;
474 match data.get("session_id").and_then(Value::as_str) {
475 Some(other) if other != session_id => continue,
476 Some(_) => {}
477 None => return None,
478 }
479 let revision = data.get("fact_revision")?.as_str()?;
480 let verdict = strict_passed_verdict(data, revision)?;
481 let node = verdict.work_order_node_id.as_str();
482 if expected_node.is_some_and(|expected| expected != node) {
483 return None;
484 }
485 let attempt = verdict.attempt_id.as_str();
486 return Some(DeliveryReceipt {
487 node: node.into(),
488 attempt: attempt.into(),
489 fact_revision: revision.into(),
490 uri: format!("fno-delivery://{node}/{attempt}/{revision}"),
491 });
492 }
493 None
494}
495
496pub fn write_receipt_handoff(
497 dir: &Path,
498 session_id: &str,
499 receipt: &DeliveryReceipt,
500) -> Result<String, String> {
501 std::fs::create_dir_all(dir).map_err(|error| error.to_string())?;
502 let prefix: String = session_id.chars().take(16).collect();
503 let path = dir.join(format!(
504 "{}-{prefix}-delivery.md",
505 chrono::Utc::now().format("%Y-%m-%d")
506 ));
507 let body = format!(
508 "# generic delivery receipt\n\n- session: `{session_id}`\n- node: `{}`\n- attempt: `{}`\n- fact_revision: `{}`\n- receipt: {}\n",
509 receipt.node, receipt.attempt, receipt.fact_revision, receipt.uri
510 );
511 std::fs::write(&path, body).map_err(|error| error.to_string())?;
512 Ok(path.to_string_lossy().into_owned())
513}
514
515enum Activation {
516 Inactive,
517 Active,
518 Invalid(String),
519}
520
521fn activation(plan_path: &Path) -> Activation {
522 let text = match std::fs::read_to_string(plan_path) {
523 Ok(text) => text,
524 Err(error) => {
525 return Activation::Invalid(format!("delivery plan could not be read: {error}"))
526 }
527 };
528 let Some(rest) = text.strip_prefix("---") else {
529 return Activation::Inactive;
530 };
531 let Some(end) = rest.find("\n---") else {
532 return Activation::Invalid("delivery plan frontmatter is unterminated".into());
533 };
534 let frontmatter = match serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&rest[..end]) {
535 Ok(frontmatter) => frontmatter,
536 Err(error) => {
537 return Activation::Invalid(format!("delivery plan frontmatter is malformed: {error}"))
538 }
539 };
540 let Some(mapping) = frontmatter.as_mapping() else {
541 return Activation::Invalid("delivery plan frontmatter is not a mapping".into());
542 };
543 if mapping
544 .get(serde_yaml_ng::Value::from("completion"))
545 .and_then(serde_yaml_ng::Value::as_str)
546 != Some("delivery")
547 {
548 return Activation::Inactive;
549 }
550 let company_key = serde_yaml_ng::Value::from("company_work");
551 let Some(company_value) = mapping.get(&company_key) else {
552 return Activation::Inactive;
553 };
554 let Some(company) = company_value.as_mapping() else {
555 return Activation::Invalid("delivery plan company_work is not a mapping".into());
556 };
557 let work_order_key = serde_yaml_ng::Value::from("work_order");
558 let work_order_valid = match company.get(&work_order_key) {
559 None => false,
560 Some(value) => {
561 let Some(work_order) = value.as_mapping() else {
562 return Activation::Invalid(
563 "delivery plan company_work.work_order is not a mapping".into(),
564 );
565 };
566 let node = work_order.get(serde_yaml_ng::Value::from("node_id"));
567 let attempt = work_order.get(serde_yaml_ng::Value::from("attempt_id"));
568 match (node, attempt) {
569 (None, _) | (_, None) => {
570 return Activation::Invalid(
571 "delivery plan work_order is missing node_id or attempt_id".into(),
572 );
573 }
574 (Some(node), Some(attempt)) => {
575 let Some(node) = node.as_str() else {
576 return Activation::Invalid(
577 "delivery plan work_order.node_id is not a string".into(),
578 );
579 };
580 let Some(attempt) = attempt.as_str() else {
581 return Activation::Invalid(
582 "delivery plan work_order.attempt_id is not a string".into(),
583 );
584 };
585 if node.trim().is_empty() || attempt.trim().is_empty() {
586 return Activation::Invalid(
587 "delivery plan work_order node_id or attempt_id is empty".into(),
588 );
589 }
590 true
591 }
592 }
593 }
594 };
595 let deliverables_key = serde_yaml_ng::Value::from("deliverables");
596 let Some(deliverables_value) = company.get(&deliverables_key) else {
597 return Activation::Inactive;
598 };
599 let Some(deliverables) = deliverables_value.as_sequence() else {
600 return Activation::Invalid("delivery plan company_work.deliverables is not a list".into());
601 };
602 let mut has_required = false;
603 for deliverable in deliverables {
604 let Some(deliverable) = deliverable.as_mapping() else {
605 return Activation::Invalid("delivery plan deliverable is not a mapping".into());
606 };
607 let required_key = serde_yaml_ng::Value::from("required_evidence_ids");
608 let Some(required_value) = deliverable.get(&required_key) else {
609 continue;
610 };
611 let Some(required_ids) = required_value.as_sequence() else {
612 return Activation::Invalid("delivery plan required_evidence_ids is not a list".into());
613 };
614 for evidence_id in required_ids {
615 let Some(evidence_id) = evidence_id.as_str() else {
616 return Activation::Invalid(
617 "delivery plan required evidence id is not a string".into(),
618 );
619 };
620 if evidence_id.trim().is_empty() {
621 return Activation::Invalid("delivery plan required evidence id is empty".into());
622 }
623 has_required = true;
624 }
625 }
626 if work_order_valid && !deliverables.is_empty() && has_required {
627 Activation::Active
628 } else {
629 Activation::Inactive
630 }
631}
632
633fn parse_response(raw: &[u8]) -> DeliveryCompletion {
634 let response: Response = match serde_json::from_slice(raw) {
635 Ok(response) => response,
636 Err(error) => {
637 return nonpassing(
638 format!("malformed delivery evaluator response: {error}"),
639 None,
640 )
641 }
642 };
643 if response.version != "delivery-evaluate-response.v1" {
644 return nonpassing("unknown delivery evaluator response version".into(), None);
645 }
646 match response.status.as_str() {
647 "inactive" if response.fact_revision.is_null() && response.verdict.is_null() => nonpassing(
648 "active delivery plan evaluator returned inactive".into(),
649 response.evidence_revision,
650 ),
651 "undeterminable" if response.fact_revision.is_null() && response.verdict.is_null() => {
652 nonpassing(response.diagnostics.join("; "), response.evidence_revision)
653 }
654 "evaluated" => parse_evaluated(response),
655 _ => nonpassing(
656 "invalid delivery evaluator response state".into(),
657 response.evidence_revision,
658 ),
659 }
660}
661
662fn parse_evaluated(response: Response) -> DeliveryCompletion {
663 if !response.diagnostics.is_empty() {
664 let details = response.diagnostics.join("; ");
665 return nonpassing(
666 format!("evaluated response diagnostics: {details}"),
667 response.evidence_revision,
668 );
669 }
670 let Some(fact_revision) = response.fact_revision.as_str() else {
671 return nonpassing(
672 "evaluated response has no fact revision".into(),
673 response.evidence_revision,
674 );
675 };
676 if fact_revision.trim().is_empty() {
677 return nonpassing(
678 "evaluated response has blank fact revision".into(),
679 response.evidence_revision,
680 );
681 }
682 let Some(evidence_revision) = response.evidence_revision.clone() else {
683 return nonpassing("evaluated response has no evidence revision".into(), None);
684 };
685 if evidence_revision.trim().is_empty() {
686 return nonpassing(
687 "evaluated response has blank evidence revision".into(),
688 None,
689 );
690 }
691 let Some(verdict) = strict_passed_verdict(&response.verdict, fact_revision) else {
692 let details = verdict_rejection_diagnostics(&response.verdict);
693 let reason = if details.is_empty() {
694 "delivery verdict is nonpassing or incomplete".into()
695 } else {
696 format!(
697 "delivery verdict is nonpassing or incomplete: {}",
698 details.join("; ")
699 )
700 };
701 return nonpassing(reason, Some(evidence_revision));
702 };
703 DeliveryCompletion::Passed {
704 fact_revision: fact_revision.to_string(),
705 evidence_revision,
706 work_order_node_id: verdict.work_order_node_id,
707 verdict: response.verdict,
708 }
709}
710
711fn nonpassing(reason: String, evidence_revision: Option<String>) -> DeliveryCompletion {
712 DeliveryCompletion::Nonpassing {
713 reason,
714 evidence_revision,
715 }
716}
717
718fn strict_passed_verdict(value: &Value, fact_revision: &str) -> Option<Verdict> {
719 let verdict: Verdict = serde_json::from_value(value.clone()).ok()?;
720 let required: Vec<_> = verdict
721 .required_requirements
722 .iter()
723 .map(|item| (item.deliverable_id.as_str(), item.evidence_id.as_str()))
724 .collect();
725 let rows: Vec<_> = verdict
726 .requirements
727 .iter()
728 .map(|item| (item.deliverable_id.as_str(), item.evidence_id.as_str()))
729 .collect();
730 let unique: HashSet<_> = required.iter().copied().collect();
731 let complete = verdict.evaluator_version == "delivery-evaluator.v1"
732 && nonblank(&verdict.work_order_node_id)
733 && nonblank(&verdict.attempt_id)
734 && verdict.aggregate == "passed"
735 && verdict.fact_revision.as_str() == Some(fact_revision)
736 && !required.is_empty()
737 && required
738 .iter()
739 .all(|(deliverable_id, evidence_id)| nonblank(deliverable_id) && nonblank(evidence_id))
740 && required == rows
741 && unique.len() == required.len()
742 && verdict.requirements.iter().all(valid_passed_requirement)
743 && verdict.diagnostics.is_empty();
744 complete.then_some(verdict)
745}
746
747fn verdict_rejection_diagnostics(value: &Value) -> Vec<String> {
748 let Ok(verdict) = serde_json::from_value::<Verdict>(value.clone()) else {
749 return Vec::new();
750 };
751 let mut details: Vec<String> = verdict
752 .diagnostics
753 .iter()
754 .filter(|diagnostic| nonblank(diagnostic))
755 .map(|diagnostic| format!("verdict: {diagnostic}"))
756 .collect();
757 for requirement in &verdict.requirements {
758 if requirement.result == "passed" && requirement.diagnostics.is_empty() {
759 continue;
760 }
761 let producers = if requirement.producers.is_empty() {
762 "none".into()
763 } else {
764 requirement.producers.join(",")
765 };
766 let diagnostics = if requirement.diagnostics.is_empty() {
767 "no diagnostic supplied".into()
768 } else {
769 requirement.diagnostics.join(", ")
770 };
771 details.push(format!(
772 "{}/{} result={} producers=[{}]: {}",
773 requirement.deliverable_id,
774 requirement.evidence_id,
775 requirement.result,
776 producers,
777 diagnostics,
778 ));
779 }
780 details
781}
782
783fn valid_passed_requirement(requirement: &Requirement) -> bool {
784 const SUBJECTS: &[&str] = &[
785 "artifact",
786 "review",
787 "approval",
788 "probe",
789 "acknowledgment",
790 "deliverable",
791 "effect",
792 ];
793 nonblank(&requirement.deliverable_id)
794 && nonblank(&requirement.evidence_id)
795 && SUBJECTS.contains(&requirement.subject_kind.as_str())
796 && nonblank(&requirement.subject_id)
797 && requirement.result == "passed"
798 && !requirement.producers.is_empty()
799 && requirement.producers.iter().all(|value| nonblank(value))
800 && !requirement.source_revisions.is_empty()
801 && requirement
802 .source_revisions
803 .iter()
804 .all(|value| nonblank(value))
805 && requirement.diagnostics.is_empty()
806}
807
808fn nonblank(value: &str) -> bool {
809 !value.trim().is_empty()
810}