mod calendars;
mod conditional;
mod encoding;
mod export;
mod items;
mod links;
mod metadata;
mod refusal;
mod report;
mod source;
mod unsupported;
use crate::InterchangeError;
use dpm_model::{Plan, ProjectId};
use serde::{Deserialize, Serialize};
pub use export::{ExportResult, export_mspdi};
pub use refusal::{LinkEndpoint, SourceChange, SourceLinkChange};
pub use report::{
CalendarOutcome, CalendarReport, DependencyChange, DependencyExportReport, ExportReport,
FieldChange, Finding, ImportReport, ItemExportReport, ItemOutcome, ItemReport, LinkOutcome,
LinkReport, RemovedDependency, SourceSummary, WorkReference,
};
const IMPORT_SCOPE: &str = concat!(
"Imports plan structure (outline, kinds, names, notes, priority, durations, relations and ",
"lags), not calendar dates. Working-time durations and lags become continuous elapsed hours; ",
"calendars (including resource calendars), resources, assignments and date constraints are ",
"reported as not imported; source start and finish dates are ignored because DPM derives ",
"dates from the graph."
);
const IMPORT_SCOPE_CALENDARS: &str = concat!(
"Imports plan structure (outline, kinds, names, notes, priority, durations, relations and ",
"lags) and working calendars in the given time zone, not calendar dates. Working-time ",
"durations and lags count working hours of the task's calendar; elapsed ones stay elapsed. ",
"Resource calendars, resources, assignments and date constraints are reported as not ",
"imported; source start and finish dates are ignored because DPM derives dates from the graph."
);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ImportOptions {
pub project_key: String,
#[serde(default)]
pub key_prefix: Option<String>,
#[serde(default)]
pub match_existing_by: Option<ExistingMatch>,
#[serde(default)]
pub keep_existing_priority: bool,
#[serde(default)]
pub time_zone: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ExistingMatch {
TitlePath,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ImportResult {
pub candidate: Plan,
pub report: ImportReport,
}
pub fn import_mspdi(
current: &Plan,
xml: &str,
options: &ImportOptions,
) -> Result<ImportResult, InterchangeError> {
let source = source::parse(xml)?;
let project = project_id(current, &options.project_key)?;
require_source_scope(&source, options)?;
let prefix = options
.key_prefix
.clone()
.unwrap_or_else(|| options.project_key.clone());
let resolver = items::Resolver::new(
current,
&source,
(project, &options.project_key),
&prefix,
options.match_existing_by,
)?;
let zone = options
.time_zone
.clone()
.or_else(|| current.calendars.as_ref().map(|c| c.time_zone.clone()));
let calendars = zone
.as_deref()
.map(|zone| calendars::import(current, &source, zone))
.transpose()?;
let fields = items::FieldPolicy {
keep_existing_priority: options.keep_existing_priority,
calendars: calendars.as_ref().map(|c| &c.imported),
};
let outline = items::map(current, &source, &resolver, (project, &prefix), fields)?;
let mut candidate = current.clone();
for mapped in outline.mapped() {
candidate.work_items.insert(mapped.id, mapped.clone());
}
let links = links::map(current, &source, &outline, calendars.is_some());
candidate.dependencies = links.dependencies;
let mut rejected = source.rejected;
let (scope, calendar_reports) = match calendars {
None => (IMPORT_SCOPE, Vec::new()),
Some(imported) => {
candidate.calendars = Some(imported.imported.calendars);
rejected.retain(|finding| finding.field != "calendars");
rejected.splice(0..0, imported.findings);
(IMPORT_SCOPE_CALENDARS, imported.reports)
}
};
let mut retained: Vec<_> = current
.work_items
.values()
.filter(|w| w.project == project && !outline.contains(w.id))
.map(|w| w.key.clone())
.collect();
retained.sort();
Ok(ImportResult {
candidate,
report: ImportReport {
source: SourceSummary {
format: "mspdi".into(),
project_guid: source.guid.map(encoding::format_guid),
name: source.name.clone(),
scope: scope.into(),
},
target_project: dpm_model::Key::new(options.project_key.clone()),
items: outline.reports,
links: links.reports,
removed_dependencies: links.removed,
calendars: calendar_reports,
rejected,
retained,
},
})
}
fn require_source_scope(
source: &source::SourceProject,
options: &ImportOptions,
) -> Result<(), InterchangeError> {
if source.guid.is_some() || options.key_prefix.is_some() {
return Ok(());
}
let anonymous: Vec<i64> = source
.tasks
.iter()
.filter(|t| {
t.guid.is_none()
&& t.metadata.is_none()
&& t.exclusion.is_none()
&& !t.is_project_summary()
})
.map(|t| t.uid)
.collect();
match anonymous.first() {
None => Ok(()),
Some(first) => Err(InterchangeError::SourceScopeRequired {
count: anonymous.len(),
first_uid: *first,
}),
}
}
fn project_id(plan: &Plan, key: &str) -> Result<ProjectId, InterchangeError> {
plan.projects
.values()
.find(|p| p.key.0 == key)
.map(|p| p.id)
.ok_or_else(|| InterchangeError::UnknownProject { key: key.into() })
}
#[cfg(test)]
mod tests;