use crate::{EngineError, NextWorkCandidate, NextWorkQuery, next_work};
use dpm_model::{AssetAccess, AssetId, Key, Plan, ProjectId, WorkItem};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use thiserror::Error;
pub const NEXT_RESULT_VERSION: u32 = 1;
#[derive(Debug, Error)]
pub enum ScopeError {
#[error("unknown project key {0}")]
UnknownProject(String),
#[error("unknown asset key {0}")]
UnknownResource(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScopeMember<I> {
pub id: I,
pub key: Key,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkScope {
pub projects: Vec<ScopeMember<ProjectId>>,
pub assets: Vec<ScopeMember<AssetId>>,
}
impl WorkScope {
pub fn resolve<'a>(
plan: &Plan,
project_keys: impl IntoIterator<Item = &'a str>,
asset_keys: impl IntoIterator<Item = &'a str>,
) -> Result<Self, ScopeError> {
let projects = unique(project_keys)
.into_iter()
.map(|key| {
plan.projects
.values()
.find(|p| p.key.0 == key)
.map(|p| ScopeMember {
id: p.id,
key: p.key.clone(),
})
.ok_or_else(|| ScopeError::UnknownProject(key.into()))
})
.collect::<Result<_, _>>()?;
let assets = unique(asset_keys)
.into_iter()
.map(|key| {
plan.assets
.values()
.find(|r| r.key.0 == key)
.map(|r| ScopeMember {
id: r.id,
key: r.key.clone(),
})
.ok_or_else(|| ScopeError::UnknownResource(key.into()))
})
.collect::<Result<_, _>>()?;
Ok(Self { projects, assets })
}
pub fn is_unscoped(&self) -> bool {
self.projects.is_empty() && self.assets.is_empty()
}
pub fn contains(&self, plan: &Plan, work: &WorkItem) -> bool {
self.contains_project(plan, work.project) && self.fits_assets(work)
}
fn contains_project(&self, plan: &Plan, project: ProjectId) -> bool {
if self.projects.is_empty() {
return true;
}
let roots = self.projects.iter().map(|m| m.id).collect::<BTreeSet<_>>();
let mut seen = BTreeSet::new();
let mut next = Some(project);
while let Some(id) = next {
if roots.contains(&id) {
return true;
}
if !seen.insert(id) {
return false;
}
next = plan.projects.get(&id).and_then(|p| p.parent);
}
false
}
fn fits_assets(&self, work: &WorkItem) -> bool {
if self.assets.is_empty() {
return true;
}
let allowed = self.assets.iter().map(|m| m.id).collect::<BTreeSet<_>>();
work.contract
.assets
.iter()
.any(|need| allowed.contains(&need.asset))
&& work
.contract
.assets
.iter()
.filter(|need| need.access == AssetAccess::Write)
.all(|need| allowed.contains(&need.asset))
}
}
fn unique<'a>(keys: impl IntoIterator<Item = &'a str>) -> BTreeSet<&'a str> {
keys.into_iter().collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScopedCandidate {
pub global_rank: usize,
#[serde(flatten)]
pub candidate: NextWorkCandidate,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OutsideScope {
pub count: usize,
pub higher_ranked_count: usize,
pub keys: Vec<Key>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NextWorkResult {
pub result_version: u32,
pub scope: WorkScope,
pub capabilities: BTreeSet<String>,
pub limit: usize,
pub eligible_count: usize,
pub in_scope_count: usize,
pub candidates: Vec<ScopedCandidate>,
pub outside_scope: OutsideScope,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub advisories: Vec<crate::Advisory>,
}
pub fn next_in_scope(
plan: &Plan,
query: &NextWorkQuery,
scope: &WorkScope,
limit: usize,
now: chrono::DateTime<chrono::Utc>,
) -> Result<NextWorkResult, EngineError> {
let ranked = next_work(plan, query, now)?;
let eligible_count = ranked.len();
let mut inside = Vec::new();
let mut outside = OutsideScope::default();
let mut best_inside_rank = None;
for (index, candidate) in ranked.into_iter().enumerate() {
let global_rank = index.wrapping_add(1);
if scope.contains(plan, &candidate.work) {
best_inside_rank.get_or_insert(global_rank);
inside.push(ScopedCandidate {
global_rank,
candidate,
});
} else {
if best_inside_rank.is_none() {
outside.higher_ranked_count = outside.higher_ranked_count.wrapping_add(1);
}
outside.keys.push(candidate.work.key);
}
}
outside.count = outside.keys.len();
let in_scope_count = inside.len();
inside.truncate(limit);
Ok(NextWorkResult {
result_version: NEXT_RESULT_VERSION,
scope: scope.clone(),
capabilities: query.capabilities.clone(),
limit,
eligible_count,
in_scope_count,
candidates: inside,
outside_scope: outside,
advisories: Vec::new(),
})
}
#[cfg(test)]
mod tests;