use super::{FieldPolicy, Parent, Placement};
use crate::mspdi::encoding::{
DEFAULT_PRIORITY, TimeBasis, duration_seconds, estimate_from_seconds, format_guid, hours,
parse_duration, priority_from_value, priority_value, time_basis,
};
use crate::mspdi::report::{Finding, ItemOutcome, ItemReport};
use crate::mspdi::source::SourceTask;
use dpm_model::{Key, ProjectId, WorkItem, WorkItemId, WorkKind, WorkStatus};
use std::collections::BTreeSet;
pub(super) fn build(
task: &SourceTask,
existing: Option<&WorkItem>,
(id, key): (WorkItemId, Key),
placement: &Placement,
policy: FieldPolicy<'_>,
) -> (WorkItem, ItemReport) {
let mut report = ItemReport {
uid: task.uid,
guid: task.guid.map(format_guid),
name: task.name.clone(),
outcome: ItemOutcome::Created,
work: None,
preserved: Vec::new(),
approximated: Vec::new(),
rejected: task.rejected.clone(),
kept: Vec::new(),
changes: Vec::new(),
};
let kind = map_kind(task, existing, placement.has_children, &mut report);
let mut work = existing
.cloned()
.unwrap_or_else(|| new_work(id, key, kind, placement.project));
work.kind = kind;
work.parent = match placement.parent {
Parent::Imported(parent) => Some(parent),
Parent::TopLevel | Parent::NotImported(_) => None,
};
report.preserved.push("outline".into());
if let Some(metadata) = &task.metadata {
if metadata.key == work.key {
report.preserved.push("key".into());
} else {
report.kept.push("key".into());
report.approximated.push(Finding::new(
"key",
format!(
"carried key {} not applied; existing key {} kept",
metadata.key, work.key
),
));
}
}
map_title(task, existing, &mut work, &mut report);
map_objective(task, existing, &mut work, &mut report);
match existing {
Some(existing) if policy.keep_existing_priority => {
keep_priority(task, existing, &mut report);
}
_ => map_priority(task, existing, &mut work, &mut report),
}
let on_calendars = policy.calendars.is_some();
map_duration(task, existing, &mut work, &mut report, on_calendars);
if let Some(imported) = policy.calendars {
super::calendar::map(task, existing, &mut work, &mut report, imported);
}
(work, report)
}
pub(super) fn resolve_kind(
task: &SourceTask,
existing: Option<&WorkItem>,
has_children: bool,
) -> WorkKind {
if has_children || task.summary {
WorkKind::WorkPackage
} else {
match task.milestone {
Some(true) => WorkKind::Milestone,
Some(false) => WorkKind::Task,
None if existing.is_some_and(|e| e.kind == WorkKind::Milestone) => WorkKind::Milestone,
None => WorkKind::Task,
}
}
}
fn map_kind(
task: &SourceTask,
existing: Option<&WorkItem>,
has_children: bool,
report: &mut ItemReport,
) -> WorkKind {
let kind = resolve_kind(task, existing, has_children);
if kind == WorkKind::WorkPackage || task.milestone.is_some() {
report.preserved.push("kind".into());
} else if existing.is_some() {
report.kept.push("kind".into());
} else {
report.approximated.push(Finding::new(
"kind",
format!("source omits Milestone; new work defaults to {kind:?}"),
));
}
kind
}
fn new_work(id: WorkItemId, key: Key, kind: WorkKind, project: ProjectId) -> WorkItem {
WorkItem {
id,
key,
project,
parent: None,
kind,
title: String::new(),
order: Default::default(),
contract: dpm_model::WorkContract {
objective: String::new(),
acceptance: Vec::new(),
instructions: None,
capabilities: BTreeSet::new(),
requirement_ids: BTreeSet::new(),
assets: Vec::new(),
condition: None,
join: dpm_model::JoinPolicy::default(),
},
execution: dpm_model::ExecutionRecord {
status: if kind == WorkKind::Task {
WorkStatus::Proposed
} else {
WorkStatus::Planned
},
reported_progress_percent: 0,
artifact_ids: BTreeSet::new(),
owner: None,
handoffs: Vec::new(),
releases: Vec::new(),
last_rejection: None,
attempts: Vec::new(),
basis: Vec::new(),
block_reason: None,
events: Default::default(),
},
schedule: dpm_model::ScheduleInputs {
priority: dpm_model::Priority::default(),
estimate: None,
executor: None,
calendar: None,
},
}
}
fn map_title(
task: &SourceTask,
existing: Option<&WorkItem>,
work: &mut WorkItem,
report: &mut ItemReport,
) {
if !task.name.trim().is_empty() {
work.title = task.name.clone();
report.preserved.push("title".into());
} else if existing.is_some() {
report.kept.push("title".into());
} else {
work.title = format!("MSPDI task {}", task.uid);
report
.approximated
.push(Finding::new("title", "source name is empty; titled by UID"));
}
}
fn map_objective(
task: &SourceTask,
existing: Option<&WorkItem>,
work: &mut WorkItem,
report: &mut ItemReport,
) {
if let Some(notes) = &task.notes {
if !existing.is_some_and(|e| e.contract.objective.trim_end() == notes) {
work.contract.objective = notes.clone();
}
report.preserved.push("notes".into());
} else if existing.is_some() {
report.kept.push("notes".into());
}
}
fn keep_priority(task: &SourceTask, existing: &WorkItem, report: &mut ItemReport) {
match task.priority {
Some(value) if priority_value(existing.schedule.priority) == value => {
report.preserved.push("priority".into());
}
Some(value) => {
report.kept.push("priority".into());
report.approximated.push(Finding::new(
"priority",
format!(
"source priority {value} not applied; existing {:?} kept",
existing.schedule.priority
),
));
}
None => report.kept.push("priority".into()),
}
}
fn map_priority(
task: &SourceTask,
existing: Option<&WorkItem>,
work: &mut WorkItem,
report: &mut ItemReport,
) {
let Some(value) = task.priority else {
if existing.is_some() {
report.kept.push("priority".into());
} else {
work.schedule.priority = priority_from_value(DEFAULT_PRIORITY);
report.approximated.push(Finding::new(
"priority",
format!(
"source omits Priority; the MSPDI default {DEFAULT_PRIORITY} applies ({:?})",
work.schedule.priority
),
));
}
return;
};
if existing.is_some_and(|e| priority_value(e.schedule.priority) == value) {
report.preserved.push("priority".into());
return;
}
work.schedule.priority = priority_from_value(value);
if priority_value(work.schedule.priority) == value {
report.preserved.push("priority".into());
} else {
report.approximated.push(Finding::new(
"priority",
format!(
"source priority {value} mapped to {:?}",
work.schedule.priority
),
));
}
}
fn map_duration(
task: &SourceTask,
existing: Option<&WorkItem>,
work: &mut WorkItem,
report: &mut ItemReport,
on_calendars: bool,
) {
let seconds = match task.duration.as_deref().map(parse_duration) {
None => None,
Some(Ok(seconds)) => Some(seconds),
Some(Err(reason)) => {
report.rejected.push(Finding::new("duration", reason));
None
}
};
match work.kind {
WorkKind::WorkPackage => {
if work.schedule.estimate.take().is_some() {
report.approximated.push(Finding::new(
"duration",
"work packages carry no estimate; the local estimate is removed",
));
}
if task.milestone == Some(true) {
report.approximated.push(Finding::new(
"milestone",
"milestone flag on a summary task ignored; summaries import as work packages",
));
}
}
WorkKind::Milestone => {
work.schedule.estimate = None;
if let Some(seconds) = seconds.filter(|s| *s > 0) {
report.approximated.push(Finding::new(
"duration",
format!(
"milestone duration {} h dropped; milestones have zero duration",
hours(seconds)
),
));
}
}
WorkKind::Task => match seconds {
Some(seconds) => task_estimate(task, (seconds, on_calendars), existing, work, report),
None if existing.is_some_and(|e| e.kind == WorkKind::Task) => {
report.kept.push("duration".into());
}
None => {}
},
}
}
fn task_estimate(
task: &SourceTask,
(seconds, on_calendars): (u64, bool),
existing: Option<&WorkItem>,
work: &mut WorkItem,
report: &mut ItemReport,
) {
if let Some(metadata) = &task.metadata {
if metadata.matches_duration(seconds) {
work.schedule.estimate = metadata.estimate;
report.preserved.push("estimate".into());
report.preserved.push("duration".into());
return;
}
report.approximated.push(Finding::new("estimate", "source duration differs from the carried DPM estimate; external duration wins over O/M/P"));
}
let unchanged = existing
.filter(|e| e.kind == WorkKind::Task)
.is_some_and(|e| duration_seconds(e.schedule.estimate) == seconds);
if unchanged || (seconds == 0 && work.schedule.estimate.is_none()) {
report.preserved.push("duration".into());
return;
}
if seconds == 0 {
work.schedule.estimate = None;
report.approximated.push(Finding::new(
"duration",
"zero source duration removes the local estimate; the task becomes unestimated",
));
return;
}
work.schedule.estimate = estimate_from_seconds(seconds);
let basis = task.duration_format.and_then(time_basis);
let detail = match basis {
Some(TimeBasis::Elapsed) => format!(
"elapsed duration {} h becomes the single-point estimate O=M=P",
hours(seconds)
),
_ if on_calendars => format!(
"working-time duration {} h becomes the single-point estimate O=M=P in working hours of the task's calendar",
hours(seconds)
),
_ => format!(
"working-time duration {} h treated as elapsed hours (calendar not applied); single-point estimate O=M=P",
hours(seconds)
),
};
report.approximated.push(Finding::new("duration", detail));
}