Skip to main content

dpm_interchange/mspdi/
refusal.rs

1//! Source context for a refused import candidate.
2//!
3//! Review names only local work or a dependency identity; an importer needs the source task or
4//! link and the change it attempted to fix the document or plan follow-up work.
5
6use super::ImportResult;
7use super::report::ItemReport;
8use dpm_model::{Dependency, Key, Plan, WorkItemId};
9use serde::{Deserialize, Serialize};
10use std::fmt;
11
12/// A source task and every change the candidate makes at the work it maps to.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct SourceChange {
15    /// Source task UID.
16    pub uid: i64,
17    /// Source task GUID, when present.
18    pub guid: Option<String>,
19    /// Local work key.
20    pub key: Key,
21    /// Field and dependency changes the candidate makes at this work.
22    pub attempted: Vec<String>,
23}
24
25impl fmt::Display for SourceChange {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        write!(
28            f,
29            "MSPDI task UID {} (GUID {}) maps to {}",
30            self.uid,
31            self.guid.as_deref().unwrap_or("none"),
32            self.key
33        )?;
34        if self.attempted.is_empty() {
35            write!(
36                f,
37                " and changes none of its fields or incoming dependencies"
38            )
39        } else {
40            write!(f, " and would change {}", self.attempted.join("; "))
41        }
42    }
43}
44
45/// One end of a source link: the source task and the local work it maps to.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct LinkEndpoint {
48    /// Source task UID.
49    pub uid: i64,
50    /// Source task GUID, when present.
51    pub guid: Option<String>,
52    /// Local work key.
53    pub key: Key,
54}
55
56impl fmt::Display for LinkEndpoint {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        write!(
59            f,
60            "task UID {} (GUID {}, {})",
61            self.uid,
62            self.guid.as_deref().unwrap_or("none"),
63            self.key
64        )
65    }
66}
67
68/// A source link, or its absence, and the change the candidate makes to one local dependency.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct SourceLinkChange {
71    /// Source predecessor task.
72    pub predecessor: LinkEndpoint,
73    /// Source successor task: the task holding the link.
74    pub successor: LinkEndpoint,
75    /// Whether the document contains this link; `false` when the candidate removes the dependency
76    /// because the document omits it.
77    pub in_source: bool,
78    /// Change the candidate makes to the dependency.
79    pub attempted: String,
80}
81
82impl fmt::Display for SourceLinkChange {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        if self.in_source {
85            write!(
86                f,
87                "MSPDI link from {} to {} would {}",
88                self.predecessor, self.successor, self.attempted
89            )
90        } else {
91            write!(
92                f,
93                "MSPDI document has no link from {} to {}, so the import would {}",
94                self.predecessor, self.successor, self.attempted
95            )
96        }
97    }
98}
99
100impl ImportResult {
101    /// Source task and attempted changes at local work `key`, when an imported task maps to it.
102    #[must_use]
103    pub fn source_change(&self, current: &Plan, key: &str) -> Option<SourceChange> {
104        let item = self
105            .report
106            .items
107            .iter()
108            .find(|i| i.work.as_ref().is_some_and(|w| w.key.0 == key))?;
109        let id = item.work.as_ref()?.id;
110        let mut attempted: Vec<String> = item
111            .changes
112            .iter()
113            .map(|c| format!("{} {:?} -> {:?}", c.field, c.before, c.after))
114            .collect();
115        attempted.extend(edge_changes(current, &self.candidate, id));
116        Some(SourceChange {
117            uid: item.uid,
118            guid: item.guid.clone(),
119            key: item.work.as_ref()?.key.clone(),
120            attempted,
121        })
122    }
123
124    /// Source link and attempted change at local dependency `dependency` (its identity as text),
125    /// when the candidate keeps it for a source link or removes it between imported work.
126    #[must_use]
127    pub fn source_link_change(&self, current: &Plan, dependency: &str) -> Option<SourceLinkChange> {
128        let local = current
129            .dependencies
130            .iter()
131            .find(|d| d.id.to_string() == dependency)?;
132        let describe = |d: &Dependency| describe(current, &self.candidate, d);
133        let Some(after) = self.candidate.find_dependency(local.id) else {
134            let by_work =
135                |id: WorkItemId| self.endpoint(|i| i.work.as_ref().is_some_and(|w| w.id == id));
136            return Some(SourceLinkChange {
137                predecessor: by_work(local.predecessor)?,
138                successor: by_work(local.successor)?,
139                in_source: false,
140                attempted: format!("remove dependency {}", describe(local)),
141            });
142        };
143        let link = self
144            .report
145            .links
146            .iter()
147            .find(|l| l.dependencies.contains(&local.id))?;
148        let predecessor_uid = link.predecessor_uid?;
149        let attempted = if local == after {
150            format!("keep dependency {} unchanged", describe(local))
151        } else {
152            format!(
153                "change dependency {} to {}",
154                describe(local),
155                describe(after)
156            )
157        };
158        Some(SourceLinkChange {
159            predecessor: self.endpoint(|i| i.uid == predecessor_uid)?,
160            successor: self.endpoint(|i| i.uid == link.successor_uid)?,
161            in_source: true,
162            attempted,
163        })
164    }
165
166    fn endpoint(&self, matches: impl Fn(&ItemReport) -> bool) -> Option<LinkEndpoint> {
167        let item = self.report.items.iter().find(|i| matches(i))?;
168        Some(LinkEndpoint {
169            uid: item.uid,
170            guid: item.guid.clone(),
171            key: item.work.as_ref()?.key.clone(),
172        })
173    }
174}
175
176/// Relation, local work keys and lag of one dependency.
177fn describe(current: &Plan, candidate: &Plan, d: &Dependency) -> String {
178    let key = |id: WorkItemId| {
179        candidate
180            .work_items
181            .get(&id)
182            .or_else(|| current.work_items.get(&id))
183            .map_or_else(|| id.to_string(), |w| w.key.0.clone())
184    };
185    format!(
186        "{} {} -> {} lag {} h",
187        d.kind.abbreviation(),
188        key(d.predecessor),
189        key(d.successor),
190        d.lag_hours
191    )
192}
193
194/// Added, removed and re-lagged dependencies into `id`: the prerequisite basis review protects.
195fn edge_changes(current: &Plan, candidate: &Plan, id: WorkItemId) -> Vec<String> {
196    let into = |plan: &'_ Plan| -> Vec<Dependency> {
197        plan.dependencies
198            .iter()
199            .filter(|d| d.successor == id)
200            .cloned()
201            .collect()
202    };
203    let describe = |d: &Dependency| describe(current, candidate, d);
204    let (before, after) = (into(current), into(candidate));
205    let mut found = Vec::new();
206    for edge in &after {
207        match before.iter().find(|d| d.id == edge.id) {
208            None => found.push(format!("add dependency {}", describe(edge))),
209            Some(local) if local != edge => found.push(format!(
210                "change dependency {} to {}",
211                describe(local),
212                describe(edge)
213            )),
214            Some(_) => {}
215        }
216    }
217    for edge in before
218        .iter()
219        .filter(|d| !after.iter().any(|a| a.id == d.id))
220    {
221        found.push(format!("remove dependency {}", describe(edge)));
222    }
223    found
224}