1use serde::{Deserialize, Serialize};
12
13use crate::address::Address;
14
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18pub struct GoalId(String);
19
20impl GoalId {
21 pub fn new(raw: impl Into<String>) -> Option<GoalId> {
22 let raw = raw.into();
23 if raw.is_empty() {
24 None
25 } else {
26 Some(GoalId(raw))
27 }
28 }
29
30 pub fn as_str(&self) -> &str {
31 &self.0
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum GoalResource {
41 Path(Address),
42 External(String),
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct GoalEntry {
47 pub id: GoalId,
48 pub owner: String,
49 pub resources: Vec<GoalResource>,
50 pub statement: String,
51 pub standing: bool,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum GoalVerdict {
57 Clear,
58 Conflict { with: GoalId },
59}
60
61fn resources_clash(a: &GoalResource, b: &GoalResource) -> bool {
62 match (a, b) {
63 (GoalResource::Path(pa), GoalResource::Path(pb)) => pa.is_within(pb) || pb.is_within(pa),
64 (GoalResource::External(ea), GoalResource::External(eb)) => ea == eb,
65 _ => false,
66 }
67}
68
69pub fn detect_conflict(registered: &[GoalEntry], candidate: &GoalEntry) -> GoalVerdict {
73 for entry in registered {
74 if entry.id == candidate.id {
75 continue;
76 }
77 for held in &entry.resources {
78 for wanted in &candidate.resources {
79 if resources_clash(held, wanted) {
80 return GoalVerdict::Conflict {
81 with: entry.id.clone(),
82 };
83 }
84 }
85 }
86 }
87 GoalVerdict::Clear
88}
89
90#[cfg(test)]
91#[allow(
92 clippy::unwrap_used,
93 clippy::expect_used,
94 clippy::panic,
95 clippy::indexing_slicing,
96 reason = "test code"
97)]
98mod tests {
99 use super::*;
100
101 fn entry(id: &str, resources: Vec<GoalResource>) -> GoalEntry {
102 GoalEntry {
103 id: GoalId::new(id).unwrap(),
104 owner: "worker@sim.1".into(),
105 resources,
106 statement: "hold".into(),
107 standing: true,
108 }
109 }
110
111 fn path(raw: &str) -> GoalResource {
112 GoalResource::Path(Address::parse(raw).unwrap())
113 }
114
115 #[test]
116 fn prefix_overlap_conflicts_both_ways() {
117 let held = entry("g1", vec![path("b/docs")]);
118 let narrower = entry("g2", vec![path("b/docs/ch1")]);
119 let wider = entry("g3", vec![path("b")]);
120 assert_eq!(
121 detect_conflict(std::slice::from_ref(&held), &narrower),
122 GoalVerdict::Conflict {
123 with: GoalId::new("g1").unwrap()
124 }
125 );
126 assert_eq!(
127 detect_conflict(&[held], &wider),
128 GoalVerdict::Conflict {
129 with: GoalId::new("g1").unwrap()
130 }
131 );
132 }
133
134 #[test]
135 fn externals_conflict_on_equality_only() {
136 let held = entry("g1", vec![GoalResource::External("crates.io/foo".into())]);
137 let same = entry("g2", vec![GoalResource::External("crates.io/foo".into())]);
138 let other = entry("g3", vec![GoalResource::External("crates.io/bar".into())]);
139 assert!(matches!(
140 detect_conflict(std::slice::from_ref(&held), &same),
141 GoalVerdict::Conflict { .. }
142 ));
143 assert_eq!(detect_conflict(&[held], &other), GoalVerdict::Clear);
144 }
145
146 #[test]
147 fn path_and_external_never_clash_and_resubmission_is_idempotent() {
148 let held = entry("g1", vec![path("b/docs")]);
149 let external = entry("g2", vec![GoalResource::External("b/docs".into())]);
150 assert_eq!(
151 detect_conflict(std::slice::from_ref(&held), &external),
152 GoalVerdict::Clear
153 );
154 let resubmitted = entry("g1", vec![path("b/docs")]);
155 assert_eq!(detect_conflict(&[held], &resubmitted), GoalVerdict::Clear);
156 }
157}