use crate::mspdi::source::SourceProject;
use dpm_model::{Plan, ProjectId, WorkItemId};
use std::collections::BTreeMap;
pub(super) struct Matcher {
paths: BTreeMap<i64, Path>,
matches: BTreeMap<i64, WorkItemId>,
}
impl Matcher {
pub(super) fn new(
current: &Plan,
source: &SourceProject,
project: ProjectId,
) -> (Self, Vec<String>) {
let paths = source_paths(source);
let local = local_paths(current, project);
let mut by_path: BTreeMap<&Path, Vec<i64>> = BTreeMap::new();
for (uid, path) in &paths {
by_path.entry(path).or_default().push(*uid);
}
let mut matches = BTreeMap::new();
let mut ambiguities = Vec::new();
for (path, uids) in &by_path {
let Some(candidates) = local.get(*path) else {
continue;
};
let path = display(path);
let keys = || {
candidates
.iter()
.filter_map(|id| current.work_items.get(id))
.map(|w| w.key.0.as_str())
.collect::<Vec<_>>()
.join(", ")
};
match (uids.as_slice(), candidates.as_slice()) {
([uid], [id]) => {
matches.insert(*uid, *id);
}
([uid], _) => ambiguities.push(format!(
"task UID {uid} has title path {path:?}, shared by existing work {}",
keys()
)),
_ => ambiguities.push(format!(
"tasks UID {} share title path {path:?} with existing work {}",
uids.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join(", "),
keys()
)),
}
}
(Self { paths, matches }, ambiguities)
}
pub(super) fn find(&self, uid: i64) -> Option<WorkItemId> {
self.matches.get(&uid).copied()
}
pub(super) fn path(&self, uid: i64) -> Option<String> {
self.paths.get(&uid).map(display)
}
}
type Path = Vec<String>;
fn display(path: &Path) -> String {
path.join(" / ")
}
fn source_paths(source: &SourceProject) -> BTreeMap<i64, Path> {
let mut stack: Vec<(u32, &str)> = Vec::new();
let mut paths = BTreeMap::new();
for task in &source.tasks {
if task.is_project_summary() {
continue;
}
while stack
.last()
.is_some_and(|(level, _)| *level >= task.outline_level)
{
stack.pop();
}
stack.push((task.outline_level, task.name.as_str()));
if task.guid.is_none() && task.metadata.is_none() && task.exclusion.is_none() {
let titles = stack.iter().map(|(_, name)| (*name).to_owned()).collect();
paths.insert(task.uid, titles);
}
}
paths
}
fn local_paths(current: &Plan, project: ProjectId) -> BTreeMap<Path, Vec<WorkItemId>> {
let mut found: BTreeMap<Path, Vec<WorkItemId>> = BTreeMap::new();
for work in current.work_items.values().filter(|w| w.project == project) {
let mut titles = vec![work.title.clone()];
let mut parent = work.parent;
while let Some(id) = parent.filter(|_| titles.len() <= current.work_items.len()) {
let Some(ancestor) = current.work_items.get(&id) else {
break;
};
titles.push(ancestor.title.clone());
parent = ancestor.parent;
}
titles.reverse();
found.entry(titles).or_default().push(work.id);
}
found
}
#[cfg(test)]
mod tests;