1mod 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
32const 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
41const 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct ImportOptions {
54 pub project_key: String,
56 #[serde(default)]
59 pub key_prefix: Option<String>,
60 #[serde(default)]
63 pub match_existing_by: Option<ExistingMatch>,
64 #[serde(default)]
68 pub keep_existing_priority: bool,
69 #[serde(default)]
72 pub time_zone: Option<String>,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "kebab-case")]
79pub enum ExistingMatch {
80 TitlePath,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct ImportResult {
90 pub candidate: Plan,
92 pub report: ImportReport,
94}
95
96pub 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 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
180fn 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;