Skip to main content

dpm_interchange/
mspdi.rs

1//! Microsoft Project XML (MSPDI) subset.
2//!
3//! Supported: tasks, summary tasks, milestones, outline hierarchy, names, notes, priority,
4//! durations and carried three-point estimates, FS/SS/FF/SF predecessor links with hour-based lags,
5//! and, given a time zone, working calendars. Everything else found in a document is reported per
6//! item and not imported.
7
8mod calendars;
9mod conditional;
10mod encoding;
11mod export;
12mod items;
13mod links;
14mod metadata;
15mod refusal;
16mod report;
17mod source;
18mod unsupported;
19
20use crate::InterchangeError;
21use dpm_model::{Plan, ProjectId};
22use serde::{Deserialize, Serialize};
23
24pub use export::{ExportResult, export_mspdi};
25pub use refusal::{LinkEndpoint, SourceChange, SourceLinkChange};
26pub use report::{
27    CalendarOutcome, CalendarReport, DependencyChange, DependencyExportReport, ExportReport,
28    FieldChange, Finding, ImportReport, ItemExportReport, ItemOutcome, ItemReport, LinkOutcome,
29    LinkReport, RemovedDependency, SourceSummary, WorkReference,
30};
31
32/// Stated in every import report so no reader mistakes the candidate for a dated schedule.
33const IMPORT_SCOPE: &str = concat!(
34    "Imports plan structure (outline, kinds, names, notes, priority, durations, relations and ",
35    "lags), not calendar dates. Working-time durations and lags become continuous elapsed hours; ",
36    "calendars (including resource calendars), resources, assignments and date constraints are ",
37    "reported as not imported; source start and finish dates are ignored because DPM derives ",
38    "dates from the graph."
39);
40
41/// Import scope when the caller names the time zone, so calendars are imported too.
42const IMPORT_SCOPE_CALENDARS: &str = concat!(
43    "Imports plan structure (outline, kinds, names, notes, priority, durations, relations and ",
44    "lags) and working calendars in the given time zone, not calendar dates. Working-time ",
45    "durations and lags count working hours of the task's calendar; elapsed ones stay elapsed. ",
46    "Resource calendars, resources, assignments and date constraints are reported as not ",
47    "imported; source start and finish dates are ignored because DPM derives dates from the graph."
48);
49
50/// Where imported work goes and how new work is keyed.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct ImportOptions {
54    /// Existing project that receives the imported work.
55    pub project_key: String,
56    /// Prefix for keys of new work (`PREFIX-UID`); defaults to the project key. A document without
57    /// any GUID requires it: it names the source, and GUID-less identities derive from it.
58    #[serde(default)]
59    pub key_prefix: Option<String>,
60    /// How a task without a GUID may match existing work in the target project; `None` never
61    /// matches, so such a task always maps to its derived identity.
62    #[serde(default)]
63    pub match_existing_by: Option<ExistingMatch>,
64    /// Keep the local priority of existing work whatever the source says, and report the source
65    /// value instead. OmniPlan rescales priorities by the highest one in the document, so an
66    /// unedited round trip without a P0 raises every band.
67    #[serde(default)]
68    pub keep_existing_priority: bool,
69    /// IANA time zone in which the document's calendars are read; MSPDI carries none. Without it
70    /// calendars are not imported and working-time values are approximated as elapsed hours.
71    #[serde(default)]
72    pub time_zone: Option<String>,
73}
74
75/// Explicit rule for mapping GUID-less source tasks onto existing work, for files that went
76/// through a tool which drops GUIDs (OmniPlan) and come back to the plan they were exported from.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "kebab-case")]
79pub enum ExistingMatch {
80    /// A task matches the one work item in the target project whose titles from the project root
81    /// down equal the task's outline path of names; a path shared by several local items, or by
82    /// several GUID-less tasks that could match, refuses the import.
83    TitlePath,
84}
85
86/// Candidate plan and its report; the candidate is reviewed and applied through the plan-change
87/// path, never written directly.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct ImportResult {
90    /// Full candidate snapshot at the current revision.
91    pub candidate: Plan,
92    /// Per-item preserved, approximated and rejected source data.
93    pub report: ImportReport,
94}
95
96/// Build a candidate plan that maps an MSPDI document onto `current` without changing it.
97///
98/// Namespaced DPM metadata preserves identity, keys and estimates. Otherwise identity is the task GUID; without one, an identity derived from the project GUID and task
99/// UID; without either GUID, one derived from the target project, the explicit key prefix and the
100/// task UID. Importing the same document again (with the same prefix) updates the same work. Lifecycle,
101/// ownership, progress, evidence and acceptance of existing work are never touched, and new
102/// tasks are `Proposed` because MSPDI carries no acceptance criteria.
103pub fn import_mspdi(
104    current: &Plan,
105    xml: &str,
106    options: &ImportOptions,
107) -> Result<ImportResult, InterchangeError> {
108    let source = source::parse(xml)?;
109    let project = project_id(current, &options.project_key)?;
110    require_source_scope(&source, options)?;
111    let prefix = options
112        .key_prefix
113        .clone()
114        .unwrap_or_else(|| options.project_key.clone());
115    let resolver = items::Resolver::new(
116        current,
117        &source,
118        (project, &options.project_key),
119        &prefix,
120        options.match_existing_by,
121    )?;
122    // A workspace with calendars schedules imported work on them, so its own zone applies when
123    // the request names none; without calendars a zone is what opts into importing them.
124    let zone = options
125        .time_zone
126        .clone()
127        .or_else(|| current.calendars.as_ref().map(|c| c.time_zone.clone()));
128    let calendars = zone
129        .as_deref()
130        .map(|zone| calendars::import(current, &source, zone))
131        .transpose()?;
132    let fields = items::FieldPolicy {
133        keep_existing_priority: options.keep_existing_priority,
134        calendars: calendars.as_ref().map(|c| &c.imported),
135    };
136    let outline = items::map(current, &source, &resolver, (project, &prefix), fields)?;
137    let mut candidate = current.clone();
138    for mapped in outline.mapped() {
139        candidate.work_items.insert(mapped.id, mapped.clone());
140    }
141    let links = links::map(current, &source, &outline, calendars.is_some());
142    candidate.dependencies = links.dependencies;
143    let mut rejected = source.rejected;
144    let (scope, calendar_reports) = match calendars {
145        None => (IMPORT_SCOPE, Vec::new()),
146        Some(imported) => {
147            candidate.calendars = Some(imported.imported.calendars);
148            rejected.retain(|finding| finding.field != "calendars");
149            rejected.splice(0..0, imported.findings);
150            (IMPORT_SCOPE_CALENDARS, imported.reports)
151        }
152    };
153    let mut retained: Vec<_> = current
154        .work_items
155        .values()
156        .filter(|w| w.project == project && !outline.contains(w.id))
157        .map(|w| w.key.clone())
158        .collect();
159    retained.sort();
160    Ok(ImportResult {
161        candidate,
162        report: ImportReport {
163            source: SourceSummary {
164                format: "mspdi".into(),
165                project_guid: source.guid.map(encoding::format_guid),
166                name: source.name.clone(),
167                scope: scope.into(),
168            },
169            target_project: dpm_model::Key::new(options.project_key.clone()),
170            items: outline.reports,
171            links: links.reports,
172            removed_dependencies: links.removed,
173            calendars: calendar_reports,
174            rejected,
175            retained,
176        },
177    })
178}
179
180/// Without any GUID, the key prefix is the only name of the source, so it must be stated rather
181/// than defaulted: a default would make every GUID-less file imported into the project the same
182/// source and silently merge unrelated tasks that share a UID.
183fn require_source_scope(
184    source: &source::SourceProject,
185    options: &ImportOptions,
186) -> Result<(), InterchangeError> {
187    if source.guid.is_some() || options.key_prefix.is_some() {
188        return Ok(());
189    }
190    let anonymous: Vec<i64> = source
191        .tasks
192        .iter()
193        .filter(|t| {
194            t.guid.is_none()
195                && t.metadata.is_none()
196                && t.exclusion.is_none()
197                && !t.is_project_summary()
198        })
199        .map(|t| t.uid)
200        .collect();
201    match anonymous.first() {
202        None => Ok(()),
203        Some(first) => Err(InterchangeError::SourceScopeRequired {
204            count: anonymous.len(),
205            first_uid: *first,
206        }),
207    }
208}
209
210fn project_id(plan: &Plan, key: &str) -> Result<ProjectId, InterchangeError> {
211    plan.projects
212        .values()
213        .find(|p| p.key.0 == key)
214        .map(|p| p.id)
215        .ok_or_else(|| InterchangeError::UnknownProject { key: key.into() })
216}
217
218#[cfg(test)]
219mod tests;