1use crate::{EngineError, NextWorkCandidate, NextWorkQuery, next_work};
8use dpm_model::{AssetAccess, AssetId, Key, Plan, ProjectId, WorkItem};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeSet;
11use thiserror::Error;
12
13pub const NEXT_RESULT_VERSION: u32 = 1;
15
16#[derive(Debug, Error)]
18pub enum ScopeError {
19 #[error("unknown project key {0}")]
21 UnknownProject(String),
22 #[error("unknown asset key {0}")]
24 UnknownResource(String),
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ScopeMember<I> {
30 pub id: I,
32 pub key: Key,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43pub struct WorkScope {
44 pub projects: Vec<ScopeMember<ProjectId>>,
46 pub assets: Vec<ScopeMember<AssetId>>,
48}
49
50impl WorkScope {
51 pub fn resolve<'a>(
53 plan: &Plan,
54 project_keys: impl IntoIterator<Item = &'a str>,
55 asset_keys: impl IntoIterator<Item = &'a str>,
56 ) -> Result<Self, ScopeError> {
57 let projects = unique(project_keys)
58 .into_iter()
59 .map(|key| {
60 plan.projects
61 .values()
62 .find(|p| p.key.0 == key)
63 .map(|p| ScopeMember {
64 id: p.id,
65 key: p.key.clone(),
66 })
67 .ok_or_else(|| ScopeError::UnknownProject(key.into()))
68 })
69 .collect::<Result<_, _>>()?;
70 let assets = unique(asset_keys)
71 .into_iter()
72 .map(|key| {
73 plan.assets
74 .values()
75 .find(|r| r.key.0 == key)
76 .map(|r| ScopeMember {
77 id: r.id,
78 key: r.key.clone(),
79 })
80 .ok_or_else(|| ScopeError::UnknownResource(key.into()))
81 })
82 .collect::<Result<_, _>>()?;
83 Ok(Self { projects, assets })
84 }
85
86 pub fn is_unscoped(&self) -> bool {
88 self.projects.is_empty() && self.assets.is_empty()
89 }
90
91 pub fn contains(&self, plan: &Plan, work: &WorkItem) -> bool {
93 self.contains_project(plan, work.project) && self.fits_assets(work)
94 }
95
96 fn contains_project(&self, plan: &Plan, project: ProjectId) -> bool {
97 if self.projects.is_empty() {
98 return true;
99 }
100 let roots = self.projects.iter().map(|m| m.id).collect::<BTreeSet<_>>();
101 let mut seen = BTreeSet::new();
102 let mut next = Some(project);
103 while let Some(id) = next {
104 if roots.contains(&id) {
105 return true;
106 }
107 if !seen.insert(id) {
108 return false;
109 }
110 next = plan.projects.get(&id).and_then(|p| p.parent);
111 }
112 false
113 }
114
115 fn fits_assets(&self, work: &WorkItem) -> bool {
116 if self.assets.is_empty() {
117 return true;
118 }
119 let allowed = self.assets.iter().map(|m| m.id).collect::<BTreeSet<_>>();
120 work.contract
121 .assets
122 .iter()
123 .any(|need| allowed.contains(&need.asset))
124 && work
125 .contract
126 .assets
127 .iter()
128 .filter(|need| need.access == AssetAccess::Write)
129 .all(|need| allowed.contains(&need.asset))
130 }
131}
132
133fn unique<'a>(keys: impl IntoIterator<Item = &'a str>) -> BTreeSet<&'a str> {
134 keys.into_iter().collect()
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ScopedCandidate {
140 pub global_rank: usize,
142 #[serde(flatten)]
144 pub candidate: NextWorkCandidate,
145}
146
147#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
149pub struct OutsideScope {
150 pub count: usize,
152 pub higher_ranked_count: usize,
154 pub keys: Vec<Key>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct NextWorkResult {
161 pub result_version: u32,
163 pub scope: WorkScope,
165 pub capabilities: BTreeSet<String>,
167 pub limit: usize,
169 pub eligible_count: usize,
171 pub in_scope_count: usize,
173 pub candidates: Vec<ScopedCandidate>,
175 pub outside_scope: OutsideScope,
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
180 pub advisories: Vec<crate::Advisory>,
181}
182
183pub fn next_in_scope(
186 plan: &Plan,
187 query: &NextWorkQuery,
188 scope: &WorkScope,
189 limit: usize,
190 now: chrono::DateTime<chrono::Utc>,
191) -> Result<NextWorkResult, EngineError> {
192 let ranked = next_work(plan, query, now)?;
193 let eligible_count = ranked.len();
194 let mut inside = Vec::new();
195 let mut outside = OutsideScope::default();
196 let mut best_inside_rank = None;
197 for (index, candidate) in ranked.into_iter().enumerate() {
198 let global_rank = index.wrapping_add(1);
199 if scope.contains(plan, &candidate.work) {
200 best_inside_rank.get_or_insert(global_rank);
201 inside.push(ScopedCandidate {
202 global_rank,
203 candidate,
204 });
205 } else {
206 if best_inside_rank.is_none() {
207 outside.higher_ranked_count = outside.higher_ranked_count.wrapping_add(1);
208 }
209 outside.keys.push(candidate.work.key);
210 }
211 }
212 outside.count = outside.keys.len();
213 let in_scope_count = inside.len();
214 inside.truncate(limit);
215 Ok(NextWorkResult {
216 result_version: NEXT_RESULT_VERSION,
217 scope: scope.clone(),
218 capabilities: query.capabilities.clone(),
219 limit,
220 eligible_count,
221 in_scope_count,
222 candidates: inside,
223 outside_scope: outside,
224 advisories: Vec::new(),
225 })
226}
227
228#[cfg(test)]
229mod tests;