onetaskgraph_core/engine/copy.rs
1//! The copy verb: one item out of one source and into another, by the rules that make a
2//! second copy an update rather than a duplicate.
3//!
4//! Correspondence lives on the item and never in a table. A copied item carries
5//! [`GlobalId::ORIGIN_KEY`], whose value is the qualified id it was copied from, and the
6//! two match rules below read exactly that — so nothing here is written down outside the
7//! plugin that owns the item, and the invariant this engine is built around is untouched.
8//!
9//! 1. **Follow the origin.** An item already carrying an origin whose source half is the
10//! destination names the destination item *directly*, and the copy updates it. This is
11//! the half that makes an edit's copy-back an update: the local file came from the
12//! remote item and knows which one.
13//! 2. **Search by origin.** Otherwise the destination is scanned, one page at a time, for
14//! an item whose origin is the id being copied. Found, the copy updates it; not found,
15//! the copy creates one carrying that origin.
16//!
17//! A destination write is at the user's explicit request, names its destination, goes
18//! through that source's own write interface into that source's own store, and is never
19//! read back to answer a query. That is what makes it a write and not a cache.
20
21use std::collections::BTreeMap;
22
23use onetaskgraph_plugin_api::{
24 DependencyEdge, DependencyEndpoint, DependencyKind, Direction, ItemKind, ItemWrite, NativeId,
25 Page, PageRequest, Project, ProjectQuery, Repository, SourceError, SourceName, Task, TaskQuery,
26};
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30
31use crate::GlobalId;
32use crate::resolve::ResolvedSource;
33
34use super::local::ProjectSelector;
35use super::{Engine, EngineError, Filters, LeftBehind, Paging, TaskRequest};
36
37/// A request to copy work into one configured destination.
38#[derive(Debug, Clone)]
39pub struct CopyRequest {
40 /// The qualified items to copy, in the order they were named.
41 pub items: CopyItems,
42 /// What those ids name, and what comes with them.
43 pub scope: CopyScope,
44 /// The configured source to copy into — a source name, never a qualified id.
45 pub destination: SourceName,
46 /// How to re-establish a correspondence the two origin rules cannot find.
47 pub match_by: Option<MatchBy>,
48 /// Whether an origin naming nothing at the destination falls through to the search
49 /// rule instead of refusing.
50 pub recreate: bool,
51 /// Whether to perform every read and no write.
52 pub dry_run: bool,
53}
54
55/// The items one copy names: at least one, because a copy naming none is not a copy.
56///
57/// A newtype rather than a bare `Vec`, for the reason [`Repository`] is one: the empty
58/// list is not a copy of nothing, it is a caller mistake, and a type that can hold it
59/// leaves every reader to decide what it means — a report with no entries, an error, a
60/// silent success. None of those is better than not being able to say it.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct CopyItems(Vec<GlobalId>);
63
64impl CopyItems {
65 /// The items a caller named, or `None` when they named none.
66 #[must_use]
67 pub fn new(items: Vec<GlobalId>) -> Option<Self> {
68 (!items.is_empty()).then_some(Self(items))
69 }
70
71 /// The items, in the order they were named.
72 #[must_use]
73 pub fn as_slice(&self) -> &[GlobalId] {
74 &self.0
75 }
76}
77
78/// What the ids a copy names are, and what travels with them.
79///
80/// One value rather than a kind beside a flag, because three of the four combinations
81/// those two would make are real and the fourth — tasks, with the tasks of each also
82/// copied — means nothing.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum CopyScope {
85 /// The ids name tasks, and only those tasks are copied.
86 Tasks,
87 /// The ids name projects.
88 Projects {
89 /// Whether the tasks in each project are copied too.
90 tasks: bool,
91 },
92}
93
94/// The caller-named escape for a correspondence neither origin rule can find.
95///
96/// A person editing Markdown who deletes or corrupts the origin key leaves an item rule 1
97/// cannot use and rule 2 cannot find, and the next copy would create a second item. This
98/// is how that is re-established without hand-editing ids.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum MatchBy {
101 /// Match the item whose title is the same.
102 Title,
103 /// Match the item whose value at this metadata key is the same.
104 Metadata(String),
105}
106
107impl MatchBy {
108 /// The spelling a caller types, `title` or any metadata key.
109 #[must_use]
110 pub fn parse(key: &str) -> Self {
111 if key == "title" {
112 Self::Title
113 } else {
114 Self::Metadata(key.to_owned())
115 }
116 }
117}
118
119/// What a copy did, one entry per item.
120///
121/// The same per-item outcomes reach every consumer: the machine-readable output renders
122/// this, the rendered output renders this, and a Rust caller is handed it.
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
124pub struct CopyReport {
125 /// One entry per item the copy considered, in the order it considered them.
126 pub items: Vec<CopyOutcome>,
127}
128
129/// What happened to one item.
130///
131/// `action` and `destination` are one value rather than two fields side by side: an
132/// updated item without a destination id, or an orphan without one, are states this type
133/// must not be able to say — the id *is* what those outcomes are about. The one outcome
134/// that legitimately has none is a dry run that would create, because nothing was
135/// created and there is no id to report.
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
137pub struct CopyOutcome {
138 /// The qualified id the item was read from.
139 pub source: GlobalId,
140 /// What happened to it, and where.
141 #[serde(flatten)]
142 pub action: CopyAction,
143}
144
145impl CopyOutcome {
146 /// The qualified id this outcome landed on, when it landed on one.
147 #[must_use]
148 pub fn destination(&self) -> Option<&GlobalId> {
149 self.action.destination()
150 }
151}
152
153/// The four things a copy can do to one item, and the id each of them is about.
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
155#[serde(tag = "action", rename_all = "kebab-case")]
156pub enum CopyAction {
157 // llmlint: ignore[names_match_behavior] `created` is Contract D's serialized action
158 // for both a completed create and a dry run that would create; the optional destination
159 // distinguishes those cases, and renaming this public variant would break Rust callers.
160 /// The destination held no counterpart, so one was created.
161 Created {
162 /// The id it was created under, or `null` for a dry run that would have created
163 /// one — there is no id, because nothing was.
164 destination: Option<GlobalId>,
165 },
166 /// The destination held a counterpart and it now reads as the source does.
167 Updated {
168 /// The item that was updated.
169 destination: GlobalId,
170 },
171 /// The destination held a counterpart that already read that way; nothing was written.
172 Unchanged {
173 /// The item that already said it.
174 destination: GlobalId,
175 },
176 /// The destination holds a counterpart the source no longer does. A copy never
177 /// deletes, so it was left exactly as it is.
178 Orphaned {
179 /// The item that was left alone.
180 destination: GlobalId,
181 },
182}
183
184impl CopyAction {
185 /// The qualified id this action is about, when there is one.
186 #[must_use]
187 pub fn destination(&self) -> Option<&GlobalId> {
188 match self {
189 Self::Created { destination } => destination.as_ref(),
190 Self::Updated { destination }
191 | Self::Unchanged { destination }
192 | Self::Orphaned { destination } => Some(destination),
193 }
194 }
195
196 /// The word this action serializes as, taken from its own `Serialize`.
197 ///
198 /// Read back off the wire form rather than written out again in a `match`, for the
199 /// reason `render::wire` gives: a second spelling of `unchanged` would be a second
200 /// place for it to drift from the one a caller reads.
201 #[must_use]
202 pub fn name(&self) -> String {
203 serde_json::to_value(self).expect("a contract enum serialises")["action"]
204 .as_str()
205 .expect("an internally tagged enum carries its tag")
206 .to_owned()
207 }
208}
209
210/// Where one item is going at the destination.
211enum Target {
212 /// Update the destination item with this id.
213 Update(NativeId),
214 /// Create one.
215 Create,
216}
217
218/// What a scan of the destination is looking for.
219enum Wanted {
220 /// An item recording this qualified id as its origin.
221 Origin(String),
222 /// An item whose title is this.
223 Title(String),
224 /// An item holding this value at this metadata key.
225 Metadata(String, Value),
226}
227
228impl Wanted {
229 /// Whether one destination item is the one being looked for.
230 fn found(&self, title: &str, metadata: &BTreeMap<String, Value>) -> bool {
231 match self {
232 Self::Origin(id) => {
233 metadata.get(GlobalId::ORIGIN_KEY) == Some(&Value::String(id.clone()))
234 }
235 Self::Title(wanted) => title == wanted,
236 Self::Metadata(key, value) => metadata.get(key) == Some(value),
237 }
238 }
239}
240
241/// What the destination held before this copy touched one item.
242///
243/// Read once, in [`Engine::land`], and used three times over: to decide whether the write
244/// would change anything, to repair the item's edges once the rest of the copy has landed,
245/// and — if the copy cannot finish — to put the item back exactly as it was.
246#[derive(Clone)]
247struct Prior {
248 /// The item as the destination held it.
249 item: Item,
250 /// Its forward edges there.
251 edges: Vec<DependencyEdge>,
252}
253
254/// One item that landed with an edge whose far end was not written yet.
255///
256/// Held until every item of the whole copy has landed, because the far end may be in
257/// another project of the same command: a copy of two projects at once is one copied set,
258/// not two, and an edge across them is remapped rather than written as a foreign id.
259struct Deferred {
260 /// The item, as it was read and resolved.
261 item: Planned,
262 /// The destination project it was filed under.
263 filed: Option<NativeId>,
264 /// Where it landed.
265 destination: NativeId,
266 /// What the destination held there before, when it held anything.
267 prior: Option<Prior>,
268}
269
270/// What one item's undo has to do to put the destination back.
271enum Undo {
272 /// The copy created it, so undoing means removing it.
273 Created {
274 /// Which write interface removes it.
275 kind: ItemKind,
276 /// The destination id it was created under.
277 id: NativeId,
278 },
279 /// The copy overwrote something, so undoing means writing that something back.
280 ///
281 /// No `kind` beside the id, unlike the variant above: what was there says which of the
282 /// two write interfaces takes it back, and a second spelling of that could disagree
283 /// with it.
284 Updated {
285 /// The destination id that was overwritten.
286 id: NativeId,
287 /// What was there before.
288 prior: Prior,
289 },
290}
291
292impl Undo {
293 /// The destination id this entry is about.
294 fn id(&self) -> &NativeId {
295 match self {
296 Self::Created { id, .. } | Self::Updated { id, .. } => id,
297 }
298 }
299
300 /// Which of the destination's two write interfaces this entry belongs to.
301 ///
302 /// An id alone does not identify a destination item: nothing stops a destination
303 /// numbering its tasks and its projects in one namespace, and a local-Markdown store
304 /// filing `alpha.md` under both is the ordinary case rather than the contrived one.
305 /// So this pairs with `id` wherever one entry has to be told from another.
306 fn kind(&self) -> ItemKind {
307 match self {
308 Self::Created { kind, .. } => *kind,
309 // Read off what was there, for the reason the variant carries no `kind` of
310 // its own: two spellings of one fact can disagree, and this one cannot.
311 Self::Updated { prior, .. } => prior.item.kind(),
312 }
313 }
314}
315
316/// Everything one copy has written, in the order it wrote it, so a copy that cannot finish
317/// can undo its own writes.
318///
319/// This is not state the engine keeps: it lives for the length of one `copy` call and is
320/// dropped with it, so the invariant that nothing of a user's work is written down outside
321/// the plugin that owns it is untouched.
322#[derive(Default)]
323struct Journal {
324 /// One entry per destination item this copy first touched, in that order.
325 entries: Vec<Undo>,
326}
327
328impl Journal {
329 /// Record what has to happen to put one destination item back.
330 ///
331 /// The *first* entry for an id is the one that matters and later ones are dropped: an
332 /// item written twice — once as it lands, once when its edges are repaired — was only
333 /// ever one thing before this copy started, and that is what undoing it restores.
334 fn record(&mut self, entry: Undo) {
335 if self
336 .entries
337 .iter()
338 .any(|held| held.kind() == entry.kind() && held.id() == entry.id())
339 {
340 return;
341 }
342 self.entries.push(entry);
343 }
344}
345
346/// One item, read and resolved, on its way into the destination.
347struct Planned {
348 /// Where it came from.
349 source: GlobalId,
350 /// The item as its source reported it.
351 item: Item,
352 /// Its forward edges, as its source reported them.
353 edges: Vec<DependencyEdge>,
354 /// Where it is going.
355 target: Target,
356}
357
358/// A task or a project, so the copy path is written once.
359#[derive(Clone)]
360enum Item {
361 /// A task.
362 Task(Box<Task>),
363 /// A project.
364 Project(Box<Project>),
365}
366
367impl Item {
368 fn id(&self) -> &NativeId {
369 match self {
370 Self::Task(task) => &task.id,
371 Self::Project(project) => &project.id,
372 }
373 }
374
375 fn kind(&self) -> ItemKind {
376 match self {
377 Self::Task(_) => ItemKind::Task,
378 Self::Project(_) => ItemKind::Project,
379 }
380 }
381}
382
383impl Engine {
384 /// Copy every item a request names into one configured destination.
385 ///
386 /// This is the whole of the verb, and the command line drives exactly this: a copy a
387 /// Rust caller makes and a copy typed at a shell are the same call, so the two cannot
388 /// answer the same copy differently.
389 ///
390 /// # Errors
391 ///
392 /// Returns [`EngineError`] when the destination is not configured, cannot be built or
393 /// cannot be written; when an id names nothing; when an origin names an item the
394 /// destination no longer holds and `--recreate` was not given; and when the
395 /// destination refuses the write — including a field or a metadata key it cannot
396 /// carry, which it names rather than dropping.
397 pub async fn copy(&self, request: &CopyRequest) -> Result<CopyReport, EngineError> {
398 let destination = self.writable(&request.destination)?;
399 let mut journal = Journal::default();
400 match self.copy_all(destination, request, &mut journal).await {
401 Ok(report) => Ok(report),
402 Err(error) => Err(self.undo(destination, journal, error).await),
403 }
404 }
405
406 /// The copy itself, with everything it writes recorded so a failure can be undone.
407 ///
408 /// The ids named together are **one** copied set, and that is what makes an edge
409 /// between any two of them a real edge at the destination: a copy of two projects at
410 /// once knows that a task in the first depends on a task in the second, and a task
411 /// knows that the project it belongs to is being created beside it. Copying them one
412 /// at a time could not, and wrote the far end as the id it had at its *source* — a
413 /// dangling reference to somewhere the destination has never heard of.
414 async fn copy_all(
415 &self,
416 destination: &ResolvedSource,
417 request: &CopyRequest,
418 journal: &mut Journal,
419 ) -> Result<CopyReport, EngineError> {
420 // Keyed by the qualified id's own rendering, which is what a recorded origin holds
421 // anyway — making `GlobalId` orderable for one local map would put an ordering on
422 // a contract type for a reason no caller of it has.
423 let mut written: BTreeMap<String, NativeId> = BTreeMap::new();
424 let mut deferred: Vec<Deferred> = Vec::new();
425 // The whole copied set, established before anything is written. For a project
426 // copy that means reading every named project's membership first: the set is the
427 // whole request rather than one project of it.
428 let mut membership = Vec::new();
429 let mut copied = Vec::new();
430 match request.scope {
431 CopyScope::Tasks => copied.extend(request.items.as_slice().iter().cloned()),
432 CopyScope::Projects { tasks } => {
433 for id in request.items.as_slice() {
434 let members = if tasks {
435 self.project_members(id).await?
436 } else {
437 Vec::new()
438 };
439 copied.push(id.clone());
440 copied.extend(members.iter().cloned());
441 membership.push((id.clone(), members));
442 }
443 }
444 }
445 let items = match request.scope {
446 CopyScope::Tasks => {
447 self.copy_items(
448 destination,
449 request,
450 ItemKind::Task,
451 request.items.as_slice(),
452 None,
453 &copied,
454 &mut written,
455 &mut deferred,
456 journal,
457 )
458 .await?
459 }
460 CopyScope::Projects { tasks } => {
461 let mut items = Vec::new();
462 for (id, members) in &membership {
463 items.extend(
464 self.copy_project(
465 destination,
466 request,
467 id,
468 members,
469 tasks,
470 &copied,
471 &mut written,
472 &mut deferred,
473 journal,
474 )
475 .await?,
476 );
477 }
478 items
479 }
480 };
481 self.repair(destination, request, &copied, &written, deferred, journal)
482 .await?;
483 Ok(CopyReport { items })
484 }
485
486 /// Write every deferred item again, now that every destination id is known.
487 ///
488 /// This is the second half of the two passes an edge between two items of one copy
489 /// needs: the far end's destination id does not exist until it has been created, so
490 /// the item that points at it lands first without that edge and is completed here.
491 /// It runs once for the whole request rather than once per project, because a far end
492 /// may be in a project this copy has not reached yet.
493 async fn repair(
494 &self,
495 destination: &ResolvedSource,
496 request: &CopyRequest,
497 copied: &[GlobalId],
498 written: &BTreeMap<String, NativeId>,
499 deferred: Vec<Deferred>,
500 journal: &mut Journal,
501 ) -> Result<(), EngineError> {
502 if request.dry_run {
503 return Ok(());
504 }
505 for entry in deferred {
506 let edges = mapped_edges(
507 &entry.item.edges,
508 &entry.item.source.source,
509 destination,
510 copied,
511 written,
512 );
513 self.write(
514 destination,
515 &entry.item,
516 Some(entry.destination),
517 entry.filed,
518 &resolved(&edges),
519 entry.prior,
520 journal,
521 )
522 .await?;
523 }
524 Ok(())
525 }
526
527 /// Put the destination back the way this copy found it, then report why it failed.
528 ///
529 /// Undone in reverse, and an item this copy created is removed rather than restored —
530 /// the entry recording what it looked like a moment after creation is not a state
531 /// anybody asked for. When the destination cannot take one of them back, the refusal
532 /// says so and names what is still there, because a user told "the copy failed" about
533 /// a destination that is not as they left it will copy again over a tree nobody
534 /// described.
535 async fn undo(
536 &self,
537 destination: &ResolvedSource,
538 journal: Journal,
539 error: EngineError,
540 ) -> EngineError {
541 let created: Vec<(ItemKind, &NativeId)> = journal
542 .entries
543 .iter()
544 .filter_map(|entry| match entry {
545 Undo::Created { kind, id } => Some((*kind, id)),
546 Undo::Updated { .. } => None,
547 })
548 .collect();
549 // The ids and the refusal are one value rather than two, because they are one
550 // fact: an item is only left behind because the destination refused to take it
551 // back, so the first refusal carries the first id and neither half can be
552 // recorded without the other.
553 let mut unrestored: Option<(LeftBehind, SourceError)> = None;
554 for entry in journal.entries.iter().rev() {
555 let outcome = match entry {
556 Undo::Created { kind, id } => remove(destination, *kind, id).await,
557 Undo::Updated { id, prior, .. } if !created.contains(&(prior.item.kind(), id)) => {
558 restore(destination, id, prior).await
559 }
560 Undo::Updated { .. } => Ok(()),
561 };
562 if let Err(problem) = outcome {
563 let id = GlobalId::new(destination.name().clone(), entry.id().clone());
564 match &mut unrestored {
565 Some((left_behind, _)) => left_behind.push(id),
566 None => unrestored = Some((LeftBehind::new(id), problem)),
567 }
568 }
569 }
570 match unrestored {
571 None => error,
572 Some((left_behind, refusal)) => EngineError::CopyNotUndone {
573 error: Box::new(error),
574 left_behind,
575 refusal,
576 },
577 }
578 }
579
580 /// The destination source, once it is established it exists and can be written.
581 fn writable(&self, name: &SourceName) -> Result<&ResolvedSource, EngineError> {
582 let name = self.known(name)?;
583 if let Some(unavailable) = self.unavailable().find(|source| source.name() == &name) {
584 return Err(EngineError::DestinationUnavailable {
585 name: name.to_string(),
586 error: unavailable.error().clone(),
587 });
588 }
589 let source = self
590 .ready()
591 .find(|source| source.name() == &name)
592 .ok_or(EngineError::NoSources)?;
593 if !source.source().writes().is_supported() {
594 return Err(EngineError::NotWritable {
595 name: name.to_string(),
596 kind: source.kind().to_owned(),
597 });
598 }
599 Ok(source)
600 }
601
602 /// Copy one project and, unless they are excluded, every task in it.
603 // llmlint: ignore[suppressions_justified] Five of these are the copy's own running
604 // state — the copied set, the ids written so far, the items held back for repair and
605 // the undo journal — and every one of them is shared across the whole request rather
606 // than per project, which is the defect this signature exists to close. Bundling them
607 // into a context struct would put a lifetime and a borrow split around state that is
608 // threaded through three call sites and read nowhere else.
609 #[allow(clippy::too_many_arguments)]
610 async fn copy_project(
611 &self,
612 destination: &ResolvedSource,
613 request: &CopyRequest,
614 id: &GlobalId,
615 members: &[GlobalId],
616 tasks: bool,
617 copied: &[GlobalId],
618 written: &mut BTreeMap<String, NativeId>,
619 deferred: &mut Vec<Deferred>,
620 journal: &mut Journal,
621 ) -> Result<Vec<CopyOutcome>, EngineError> {
622 // On a repeat copy, compare the project with its final remapped edges before the
623 // first pass temporarily rewrites it. This preserves an `unchanged` outcome when
624 // the project and every copied member already have counterparts.
625 let project_plan = self
626 .plan(destination, request, ItemKind::Project, id)
627 .await?;
628 let mut known = BTreeMap::new();
629 if let Target::Update(target) = &project_plan.target {
630 known.insert(id.to_string(), target.clone());
631 }
632 for member in members {
633 let member_plan = self
634 .plan(destination, request, ItemKind::Task, member)
635 .await?;
636 if let Target::Update(target) = member_plan.target {
637 known.insert(member.to_string(), target);
638 }
639 }
640 let project_was_unchanged = if let Target::Update(target) = &project_plan.target {
641 let edges = mapped_edges(&project_plan.edges, &id.source, destination, copied, &known);
642 let held = self.prior(destination, ItemKind::Project, target).await?;
643 !edges.iter().any(Option::is_none)
644 && !changes(
645 held.as_ref(),
646 &project_plan,
647 target,
648 &None,
649 &resolved(&edges),
650 )
651 } else {
652 false
653 };
654 let mut outcomes = self
655 .copy_items(
656 destination,
657 request,
658 ItemKind::Project,
659 std::slice::from_ref(id),
660 None,
661 copied,
662 written,
663 deferred,
664 journal,
665 )
666 .await?;
667 if !tasks {
668 return Ok(outcomes);
669 }
670 // `None` when a dry run would have created the project: nothing was written, so
671 // there is no destination project id to file the tasks under. Every task is still
672 // read and still reported, because that is what a dry run is for.
673 let project = outcomes.first().and_then(CopyOutcome::destination).cloned();
674 let task_outcomes = self
675 .copy_items(
676 destination,
677 request,
678 ItemKind::Task,
679 members,
680 project.as_ref().map(|project| project.native.clone()),
681 copied,
682 written,
683 deferred,
684 journal,
685 )
686 .await?;
687 outcomes.extend(task_outcomes);
688 if let Some(project) = project {
689 if project_was_unchanged {
690 outcomes[0].action = CopyAction::Unchanged {
691 destination: project.clone(),
692 };
693 }
694 outcomes.extend(
695 self.orphans(destination, id, &project.native, members)
696 .await?,
697 );
698 }
699 Ok(outcomes)
700 }
701
702 /// Every task the source holds in `project`, by qualified id.
703 async fn project_members(&self, project: &GlobalId) -> Result<Vec<GlobalId>, EngineError> {
704 let mut request = TaskRequest {
705 sources: vec![project.source.clone()],
706 filters: Filters::default(),
707 project: ProjectSelector::Qualified(project.clone()),
708 paging: Paging {
709 limit: PROJECT_PAGE,
710 token: None,
711 },
712 };
713 let mut members = Vec::new();
714 loop {
715 let response = self.tasks(&request).await?;
716 if let Some(failure) = response.errors.first() {
717 return Err(EngineError::SourceRefused {
718 name: failure.source.to_string(),
719 error: failure.error.clone(),
720 });
721 }
722 members.extend(response.items.into_iter().map(|task| task.id));
723 match response.next {
724 Some(token) => request.paging.token = Some(token),
725 None => return Ok(members),
726 }
727 }
728 }
729
730 /// Destination tasks filed under the copied project whose origin the source no longer
731 /// holds.
732 ///
733 /// A copy never deletes, so each is left exactly as it is and reported.
734 async fn orphans(
735 &self,
736 destination: &ResolvedSource,
737 project: &GlobalId,
738 at_destination: &NativeId,
739 copied: &[GlobalId],
740 ) -> Result<Vec<CopyOutcome>, EngineError> {
741 let mut orphans = Vec::new();
742 let mut cursor = None;
743 loop {
744 let page: Page<Task> = destination
745 .source()
746 .query_tasks(&TaskQuery::default(), &request_for(destination, cursor))
747 .await
748 .map_err(|error| refused(destination, error))?;
749 for task in &page.items {
750 if task.project.as_ref() != Some(at_destination) {
751 continue;
752 }
753 let Some(origin) = origin_of(&task.metadata) else {
754 continue;
755 };
756 if origin.source != project.source || copied.contains(&origin) {
757 continue;
758 }
759 orphans.push(CopyOutcome {
760 source: origin,
761 action: CopyAction::Orphaned {
762 destination: GlobalId::new(destination.name().clone(), task.id.clone()),
763 },
764 });
765 }
766 match page.next {
767 Some(next) => cursor = Some(next),
768 None => return Ok(orphans),
769 }
770 }
771 }
772
773 /// Read, resolve and write every item named, holding back the ones whose edges are
774 /// not resolvable yet.
775 ///
776 /// An edge between two items of one copy can point at a member whose destination id
777 /// does not exist until it has been created, so the item that points at it lands
778 /// without that edge and is handed to `deferred`. [`Engine::repair`] finishes it once
779 /// the *whole* request has landed — not once this call has, because the far end may
780 /// be in another project of the same command.
781 // llmlint: ignore[suppressions_justified] The same running state `copy_project` threads,
782 // for the same reason: it belongs to one `copy` call and is shared across every item of
783 // it, and a struct around it would add a borrow split for no reader's benefit.
784 #[allow(clippy::too_many_arguments)]
785 async fn copy_items(
786 &self,
787 destination: &ResolvedSource,
788 request: &CopyRequest,
789 kind: ItemKind,
790 items: &[GlobalId],
791 project: Option<NativeId>,
792 copied: &[GlobalId],
793 written: &mut BTreeMap<String, NativeId>,
794 deferred: &mut Vec<Deferred>,
795 journal: &mut Journal,
796 ) -> Result<Vec<CopyOutcome>, EngineError> {
797 let mut planned = Vec::new();
798 for id in items {
799 planned.push(self.plan(destination, request, kind, id).await?);
800 }
801
802 for item in &planned {
803 if let Target::Update(id) = &item.target {
804 written.insert(item.source.to_string(), id.clone());
805 }
806 }
807
808 // Resolved once per item, and used by both passes: the repair pass writes the
809 // same item again, and re-deriving this there could file it somewhere else.
810 let mut filed = Vec::new();
811 for item in &planned {
812 filed.push(self.filed(destination, item, project.clone()).await?);
813 }
814
815 let mut outcomes = Vec::new();
816 let mut unresolved = Vec::new();
817 let mut priors = Vec::new();
818 for (index, item) in planned.iter().enumerate() {
819 let edges = mapped_edges(
820 &item.edges,
821 &item.source.source,
822 destination,
823 copied,
824 written,
825 );
826 if edges.iter().any(Option::is_none) {
827 unresolved.push(index);
828 }
829 let (outcome, prior) = self
830 .land(
831 destination,
832 request,
833 item,
834 filed[index].clone(),
835 &edges,
836 journal,
837 )
838 .await?;
839 if let Some(id) = outcome.destination() {
840 written.insert(item.source.to_string(), id.native.clone());
841 }
842 outcomes.push(outcome);
843 priors.push(prior);
844 }
845
846 if !request.dry_run {
847 for (index, item) in planned.into_iter().enumerate() {
848 if !unresolved.contains(&index) {
849 continue;
850 }
851 // Every item a copy that is not a dry run lands has a destination id: the
852 // one outcome without one is a dry run that would have created, and this
853 // block does not run for a dry run.
854 let id = outcomes[index]
855 .destination()
856 .expect("a copy that writes lands every item it planned")
857 .clone();
858 deferred.push(Deferred {
859 item,
860 filed: filed[index].clone(),
861 destination: id.native,
862 prior: priors[index].clone(),
863 });
864 }
865 }
866 Ok(outcomes)
867 }
868
869 /// Read one item and its forward edges, and decide where it is going.
870 async fn plan(
871 &self,
872 destination: &ResolvedSource,
873 request: &CopyRequest,
874 kind: ItemKind,
875 id: &GlobalId,
876 ) -> Result<Planned, EngineError> {
877 let source = self.readable(&id.source)?;
878 let item = match kind {
879 ItemKind::Task => source
880 .source()
881 .get_task(&id.native)
882 .await
883 .map_err(|error| refused(source, error))?
884 .map(|task| Item::Task(Box::new(task))),
885 ItemKind::Project => source
886 .source()
887 .get_project(&id.native)
888 .await
889 .map_err(|error| refused(source, error))?
890 .map(|project| Item::Project(Box::new(project))),
891 }
892 .ok_or_else(|| EngineError::NoSuchItem { id: id.to_string() })?;
893 let edges = forward_edges(source, &id.native, item.kind()).await?;
894 let target = self.target(destination, request, id, &item).await?;
895 Ok(Planned {
896 source: id.clone(),
897 item,
898 edges,
899 target,
900 })
901 }
902
903 /// Which destination item this one corresponds to, by the two origin rules and the
904 /// caller's escape.
905 async fn target(
906 &self,
907 destination: &ResolvedSource,
908 request: &CopyRequest,
909 id: &GlobalId,
910 item: &Item,
911 ) -> Result<Target, EngineError> {
912 let (title, metadata) = described(item);
913 if let Some(origin) = origin_of(metadata)
914 && &origin.source == destination.name()
915 {
916 if exists(destination, &origin.native, item.kind()).await? {
917 return Ok(Target::Update(origin.native));
918 }
919 if !request.recreate {
920 return Err(EngineError::StaleOrigin {
921 item: id.to_string(),
922 origin: origin.to_string(),
923 });
924 }
925 }
926 if let Some(found) = self
927 .scan(destination, item.kind(), &Wanted::Origin(id.to_string()))
928 .await?
929 {
930 return Ok(Target::Update(found));
931 }
932 let wanted = match &request.match_by {
933 Some(MatchBy::Title) => Some(Wanted::Title(title.to_owned())),
934 Some(MatchBy::Metadata(key)) => metadata
935 .get(key)
936 .map(|value| Wanted::Metadata(key.clone(), value.clone())),
937 None => None,
938 };
939 if let Some(wanted) = wanted
940 && let Some(found) = self.scan(destination, item.kind(), &wanted).await?
941 {
942 return Ok(Target::Update(found));
943 }
944 Ok(Target::Create)
945 }
946
947 /// Walk the destination one page at a time, looking for `wanted`.
948 ///
949 /// One page is held at a time and nothing is written down, which is the same bound
950 /// every other compensation in this engine works under.
951 async fn scan(
952 &self,
953 destination: &ResolvedSource,
954 kind: ItemKind,
955 wanted: &Wanted,
956 ) -> Result<Option<NativeId>, EngineError> {
957 let mut cursor = None;
958 loop {
959 let next = match kind {
960 ItemKind::Task => {
961 let page = destination
962 .source()
963 .query_tasks(&TaskQuery::default(), &request_for(destination, cursor))
964 .await
965 .map_err(|error| refused(destination, error))?;
966 for task in &page.items {
967 if wanted.found(&task.title, &task.metadata) {
968 return Ok(Some(task.id.clone()));
969 }
970 }
971 page.next
972 }
973 ItemKind::Project => {
974 let page = destination
975 .source()
976 .query_projects(&ProjectQuery::default(), &request_for(destination, cursor))
977 .await
978 .map_err(|error| refused(destination, error))?;
979 for project in &page.items {
980 if wanted.found(&project.title, &project.metadata) {
981 return Ok(Some(project.id.clone()));
982 }
983 }
984 page.next
985 }
986 };
987 match next {
988 Some(next) => cursor = Some(next),
989 None => return Ok(None),
990 }
991 }
992 }
993
994 /// Write one planned item, or say what a dry run would have done.
995 ///
996 /// Answers with what the destination held there beforehand as well, which is what
997 /// makes an item written twice restorable to what it was rather than to what this
998 /// copy's first pass left.
999 async fn land(
1000 &self,
1001 destination: &ResolvedSource,
1002 request: &CopyRequest,
1003 item: &Planned,
1004 project: Option<NativeId>,
1005 edges: &[Option<DependencyEdge>],
1006 journal: &mut Journal,
1007 ) -> Result<(CopyOutcome, Option<Prior>), EngineError> {
1008 let target = match &item.target {
1009 Target::Update(id) => Some(id.clone()),
1010 Target::Create => None,
1011 };
1012 // One read of the destination item, used to decide whether the write changes
1013 // anything and — if the copy cannot finish — to put that item back.
1014 let prior = match &target {
1015 Some(id) => self.prior(destination, item.item.kind(), id).await?,
1016 None => None,
1017 };
1018 let edges = resolved(edges);
1019 let qualified = |native: NativeId| GlobalId::new(destination.name().clone(), native);
1020 if let Some(id) = &target
1021 && !changes(prior.as_ref(), item, id, &project, &edges)
1022 {
1023 return Ok((
1024 CopyOutcome {
1025 source: item.source.clone(),
1026 action: CopyAction::Unchanged {
1027 destination: qualified(id.clone()),
1028 },
1029 },
1030 prior,
1031 ));
1032 }
1033 if request.dry_run {
1034 return Ok((
1035 CopyOutcome {
1036 source: item.source.clone(),
1037 action: match target {
1038 Some(id) => CopyAction::Updated {
1039 destination: qualified(id),
1040 },
1041 // Null only here: nothing was created, so there is no id to report.
1042 None => CopyAction::Created { destination: None },
1043 },
1044 },
1045 prior,
1046 ));
1047 }
1048 let updating = target.is_some();
1049 let written = qualified(
1050 self.write(
1051 destination,
1052 item,
1053 target,
1054 project,
1055 &edges,
1056 prior.clone(),
1057 journal,
1058 )
1059 .await?,
1060 );
1061 Ok((
1062 CopyOutcome {
1063 source: item.source.clone(),
1064 action: if updating {
1065 CopyAction::Updated {
1066 destination: written,
1067 }
1068 } else {
1069 CopyAction::Created {
1070 destination: Some(written),
1071 }
1072 },
1073 },
1074 prior,
1075 ))
1076 }
1077
1078 /// Which destination project this item is filed under, when it is filed at all.
1079 ///
1080 /// A task copied as part of a project copy is filed under that project's counterpart,
1081 /// which the copy has just established. A task copied on its own has to find it.
1082 async fn filed(
1083 &self,
1084 destination: &ResolvedSource,
1085 item: &Planned,
1086 project: Option<NativeId>,
1087 ) -> Result<Option<NativeId>, EngineError> {
1088 match (&item.item, project) {
1089 (Item::Task(task), None) => self.counterpart(destination, item, task).await,
1090 (Item::Task(_), filed) => Ok(filed),
1091 (Item::Project(_), _) => Ok(None),
1092 }
1093 }
1094
1095 /// The destination project this task's own project corresponds to, when there is one.
1096 ///
1097 /// A task copied on its own keeps its source's project id when the destination holds
1098 /// no counterpart: the field is opaque to this engine, and dropping it would lose
1099 /// what the source said.
1100 async fn counterpart(
1101 &self,
1102 destination: &ResolvedSource,
1103 item: &Planned,
1104 task: &Task,
1105 ) -> Result<Option<NativeId>, EngineError> {
1106 let Some(project) = &task.project else {
1107 return Ok(None);
1108 };
1109 let qualified = GlobalId::new(item.source.source.clone(), project.clone());
1110 let found = self
1111 .scan(
1112 destination,
1113 ItemKind::Project,
1114 &Wanted::Origin(qualified.to_string()),
1115 )
1116 .await?;
1117 Ok(Some(found.unwrap_or_else(|| project.clone())))
1118 }
1119
1120 /// What the destination holds at one id, item and forward edges together.
1121 ///
1122 /// One read for both purposes it serves — deciding whether a write changes anything,
1123 /// and putting the item back if the copy cannot finish — because a second read of the
1124 /// same item is a second round trip against a hosted destination for nothing.
1125 async fn prior(
1126 &self,
1127 destination: &ResolvedSource,
1128 kind: ItemKind,
1129 id: &NativeId,
1130 ) -> Result<Option<Prior>, EngineError> {
1131 let held = match kind {
1132 ItemKind::Task => destination
1133 .source()
1134 .get_task(id)
1135 .await
1136 .map_err(|error| refused(destination, error))?
1137 .map(|task| Item::Task(Box::new(task))),
1138 ItemKind::Project => destination
1139 .source()
1140 .get_project(id)
1141 .await
1142 .map_err(|error| refused(destination, error))?
1143 .map(|project| Item::Project(Box::new(project))),
1144 };
1145 let Some(item) = held else {
1146 return Ok(None);
1147 };
1148 let edges = forward_edges(destination, id, kind).await?;
1149 Ok(Some(Prior { item, edges }))
1150 }
1151
1152 /// Hand one item to the destination's own write interface, recording how to take it
1153 /// back.
1154 // llmlint: ignore[suppressions_justified] A write is the item, where it is going, what
1155 // it is filed under, its edges, what was there before and the journal that records how
1156 // to put it back. Each is a distinct decision made by a different part of the copy, and
1157 // grouping them would only move the argument list to a constructor.
1158 #[allow(clippy::too_many_arguments)]
1159 async fn write(
1160 &self,
1161 destination: &ResolvedSource,
1162 item: &Planned,
1163 target: Option<NativeId>,
1164 project: Option<NativeId>,
1165 edges: &[DependencyEdge],
1166 prior: Option<Prior>,
1167 journal: &mut Journal,
1168 ) -> Result<NativeId, EngineError> {
1169 let created_kind = item.item.kind();
1170 let suggested = target.clone().unwrap_or_else(|| item.item.id().clone());
1171 // Recorded *before* the write rather than after it. A destination's own write is
1172 // several calls — `docs/plugin-protocol.md` §4.9 — and one of them failing leaves
1173 // the ones before it applied. No source can put those back, because only this
1174 // journal holds what was there; recorded after a successful write, an update that
1175 // stopped part way was the one way a copy could end and leave the destination
1176 // altered. A restore of an item the write never reached rewrites what is already
1177 // there, which costs one mutation and is what "either complete or it never
1178 // happened" is worth.
1179 if let (Some(id), Some(prior)) = (target.clone(), prior) {
1180 journal.record(Undo::Updated { id, prior });
1181 }
1182 let landed = match outgoing(item, suggested, project) {
1183 Item::Task(task) => destination
1184 .source()
1185 .write_task(&ItemWrite {
1186 target: target.clone(),
1187 item: *task,
1188 depends_on: edges.to_vec(),
1189 })
1190 .await
1191 .map_err(|error| refused(destination, error))?,
1192 Item::Project(project) => destination
1193 .source()
1194 .write_project(&ItemWrite {
1195 target: target.clone(),
1196 item: *project,
1197 depends_on: edges.to_vec(),
1198 })
1199 .await
1200 .map_err(|error| refused(destination, error))?,
1201 };
1202 // A created item can only be journalled here: its id is what the write answers
1203 // with. A create that fails leaves nothing behind — §4.9 makes taking the item
1204 // back the source's own duty, because a write that refused must not leave an item
1205 // nobody asked for.
1206 if target.is_none() {
1207 journal.record(Undo::Created {
1208 kind: created_kind,
1209 id: landed.clone(),
1210 });
1211 }
1212 Ok(landed)
1213 }
1214
1215 /// A configured source that built, for reading an item out of.
1216 fn readable(&self, name: &SourceName) -> Result<&ResolvedSource, EngineError> {
1217 let name = self.known(name)?;
1218 if let Some(unavailable) = self.unavailable().find(|source| source.name() == &name) {
1219 return Err(EngineError::SourceRefused {
1220 name: name.to_string(),
1221 error: unavailable.error().clone(),
1222 });
1223 }
1224 self.ready()
1225 .find(|source| source.name() == &name)
1226 .ok_or(EngineError::NoSources)
1227 }
1228}
1229
1230/// How many tasks of a project are read at once while walking it.
1231const PROJECT_PAGE: std::num::NonZeroU32 = std::num::NonZeroU32::new(50).expect("50 is not zero");
1232
1233/// One page request against `source`, at the largest page it will serve.
1234fn request_for(
1235 source: &ResolvedSource,
1236 cursor: Option<onetaskgraph_plugin_api::Cursor>,
1237) -> PageRequest {
1238 PageRequest {
1239 cursor,
1240 limit: source.source().capabilities().max_page_size.max(1),
1241 }
1242}
1243
1244/// Whether writing this item would change what the destination already holds.
1245///
1246/// A free function over the state already read rather than a method that reads it again:
1247/// the same answer is wanted where the item is landed and where a repeat copy of a project
1248/// decides whether it settled, and a second read there is a second round trip for nothing.
1249fn changes(
1250 held: Option<&Prior>,
1251 item: &Planned,
1252 target: &NativeId,
1253 project: &Option<NativeId>,
1254 edges: &[DependencyEdge],
1255) -> bool {
1256 let Some(held) = held else {
1257 return true;
1258 };
1259 let outgoing = outgoing(item, target.clone(), project.clone());
1260 !same(&held.item, &outgoing) || !same_edges(&held.edges, edges)
1261}
1262
1263/// Remove one item this copy created, through the destination's own write interface.
1264async fn remove(
1265 destination: &ResolvedSource,
1266 kind: ItemKind,
1267 id: &NativeId,
1268) -> Result<(), SourceError> {
1269 match kind {
1270 ItemKind::Task => destination.source().delete_task(id).await,
1271 ItemKind::Project => destination.source().delete_project(id).await,
1272 }
1273}
1274
1275/// Write one item back exactly as the destination held it before this copy.
1276async fn restore(
1277 destination: &ResolvedSource,
1278 id: &NativeId,
1279 prior: &Prior,
1280) -> Result<(), SourceError> {
1281 match &prior.item {
1282 Item::Task(task) => destination
1283 .source()
1284 .write_task(&ItemWrite {
1285 target: Some(id.clone()),
1286 item: (**task).clone(),
1287 depends_on: prior.edges.clone(),
1288 })
1289 .await
1290 .map(|_| ()),
1291 Item::Project(project) => destination
1292 .source()
1293 .write_project(&ItemWrite {
1294 target: Some(id.clone()),
1295 item: (**project).clone(),
1296 depends_on: prior.edges.clone(),
1297 })
1298 .await
1299 .map(|_| ()),
1300 }
1301}
1302
1303/// One source failing while a copy was mid-flight.
1304fn refused(source: &ResolvedSource, error: SourceError) -> EngineError {
1305 EngineError::SourceRefused {
1306 name: source.name().to_string(),
1307 error,
1308 }
1309}
1310
1311/// Every forward edge at one item, walked to exhaustion one page at a time.
1312async fn forward_edges(
1313 source: &ResolvedSource,
1314 id: &NativeId,
1315 kind: ItemKind,
1316) -> Result<Vec<DependencyEdge>, EngineError> {
1317 let mut edges = Vec::new();
1318 let mut cursor = None;
1319 loop {
1320 let page = match kind {
1321 ItemKind::Task => {
1322 source
1323 .source()
1324 .task_dependencies(id, Direction::DependsOn, &request_for(source, cursor))
1325 .await
1326 }
1327 ItemKind::Project => {
1328 source
1329 .source()
1330 .project_dependencies(id, Direction::DependsOn, &request_for(source, cursor))
1331 .await
1332 }
1333 }
1334 .map_err(|error| refused(source, error))?;
1335 edges.extend(page.items);
1336 match page.next {
1337 Some(next) => cursor = Some(next),
1338 None => return Ok(edges),
1339 }
1340 }
1341}
1342
1343/// The origin one item records, when it records a usable one.
1344fn origin_of(metadata: &BTreeMap<String, Value>) -> Option<GlobalId> {
1345 metadata
1346 .get(GlobalId::ORIGIN_KEY)?
1347 .as_str()?
1348 .parse::<GlobalId>()
1349 .ok()
1350}
1351
1352/// The title and metadata of either kind of item.
1353fn described(item: &Item) -> (&str, &BTreeMap<String, Value>) {
1354 match item {
1355 Item::Task(task) => (&task.title, &task.metadata),
1356 Item::Project(project) => (&project.title, &project.metadata),
1357 }
1358}
1359
1360/// The item as the destination should hold it.
1361///
1362/// `url`, `created_at` and `updated_at` are the destination's own and are never written.
1363/// The two reserved keys this product encodes typed fields under are removed, because
1364/// those fields travel as themselves — leaving the encoding beside them would have the
1365/// destination hold one thing twice, and disagree with itself the moment one changed.
1366fn outgoing(item: &Planned, id: NativeId, project: Option<NativeId>) -> Item {
1367 let origin = item.source.to_string();
1368 match &item.item {
1369 Item::Task(task) => Item::Task(Box::new(Task {
1370 id,
1371 url: None,
1372 created_at: None,
1373 updated_at: None,
1374 project,
1375 metadata: carried(&task.metadata, &origin),
1376 ..(**task).clone()
1377 })),
1378 Item::Project(project) => Item::Project(Box::new(Project {
1379 id,
1380 url: None,
1381 created_at: None,
1382 updated_at: None,
1383 metadata: carried(&project.metadata, &origin),
1384 ..(**project).clone()
1385 })),
1386 }
1387}
1388
1389/// The metadata a copy carries: the caller's own keys untouched, and the origin recorded.
1390fn carried(metadata: &BTreeMap<String, Value>, origin: &str) -> BTreeMap<String, Value> {
1391 let mut carried = metadata.clone();
1392 carried.remove(Repository::METADATA_KEY);
1393 carried.remove(DependencyEdge::RECORDED_KEY);
1394 carried.insert(
1395 GlobalId::ORIGIN_KEY.to_owned(),
1396 Value::String(origin.to_owned()),
1397 );
1398 carried
1399}
1400
1401/// Whether the destination already reads exactly as this copy would leave it.
1402///
1403/// The destination's own `url` and timestamps are excluded because a copy never writes
1404/// them, so a difference there is not one this copy would close.
1405fn same(held: &Item, outgoing: &Item) -> bool {
1406 match (held, outgoing) {
1407 (Item::Task(held), Item::Task(outgoing)) => {
1408 held.title == outgoing.title
1409 && held.content == outgoing.content
1410 && held.status == outgoing.status
1411 && held.labels == outgoing.labels
1412 && held.project == outgoing.project
1413 && held.metadata == outgoing.metadata
1414 && held.repositories == outgoing.repositories
1415 }
1416 (Item::Project(held), Item::Project(outgoing)) => {
1417 held.title == outgoing.title
1418 && held.content == outgoing.content
1419 && held.status == outgoing.status
1420 && held.labels == outgoing.labels
1421 && held.metadata == outgoing.metadata
1422 && held.repositories == outgoing.repositories
1423 }
1424 _ => false,
1425 }
1426}
1427
1428/// Whether the destination's forward edges already say what this copy would write.
1429fn same_edges(held: &[DependencyEdge], outgoing: &[DependencyEdge]) -> bool {
1430 let ends = |edges: &[DependencyEdge]| {
1431 let mut ends: Vec<(String, ItemKind, DependencyKind)> = edges
1432 .iter()
1433 .map(|edge| (edge.to.id().to_owned(), edge.to.kind, edge.kind))
1434 .collect();
1435 ends.sort_by(|left, right| left.0.cmp(&right.0));
1436 ends
1437 };
1438 ends(held) == ends(outgoing)
1439}
1440
1441/// Each read edge as the destination should record it, or `None` when its far end is a
1442/// member of this copy whose destination id is not known yet.
1443fn mapped_edges(
1444 edges: &[DependencyEdge],
1445 origin: &SourceName,
1446 destination: &ResolvedSource,
1447 copied: &[GlobalId],
1448 written: &BTreeMap<String, NativeId>,
1449) -> Vec<Option<DependencyEdge>> {
1450 edges
1451 .iter()
1452 .map(|edge| {
1453 let far = GlobalId::new(origin.clone(), NativeId(edge.to.id().to_owned()));
1454 let id = if let Some(native) = names(&edge.to, destination.name()) {
1455 // A far end already qualified to the destination's own source is that
1456 // source's own item, so it is written the way that source names its own:
1457 // unqualified. Leaving it qualified would have the destination hold an
1458 // edge into itself written as if it left, which is the one spelling the
1459 // reserved key exists to keep for edges that really do.
1460 Some(native)
1461 } else if edge.to.is_qualified() || origin == destination.name() {
1462 // Already naming a source of its own, or a copy inside one source where
1463 // the far end's own id is the destination's id.
1464 Some(edge.to.id().to_owned())
1465 } else if copied.contains(&far) {
1466 written.get(&far.to_string()).map(|native| native.0.clone())
1467 } else {
1468 Some(far.to_string())
1469 }?;
1470 DependencyEndpoint::new(id, edge.to.kind)
1471 .ok()
1472 .map(|to| DependencyEdge {
1473 from: edge.from.clone(),
1474 to,
1475 kind: edge.kind,
1476 })
1477 })
1478 .collect()
1479}
1480
1481/// The native id a qualified endpoint names at `destination`, when it names one there.
1482fn names(endpoint: &DependencyEndpoint, destination: &SourceName) -> Option<String> {
1483 if !endpoint.is_qualified() {
1484 return None;
1485 }
1486 let id: GlobalId = endpoint.id().parse().ok()?;
1487 (&id.source == destination).then_some(id.native.0)
1488}
1489
1490/// The edges that could be resolved, which is every one of them on the second pass.
1491fn resolved(edges: &[Option<DependencyEdge>]) -> Vec<DependencyEdge> {
1492 edges.iter().flatten().cloned().collect()
1493}
1494
1495/// Whether the destination holds an item with this id.
1496async fn exists(
1497 destination: &ResolvedSource,
1498 id: &NativeId,
1499 kind: ItemKind,
1500) -> Result<bool, EngineError> {
1501 let found = match kind {
1502 ItemKind::Task => destination
1503 .source()
1504 .get_task(id)
1505 .await
1506 .map_err(|error| refused(destination, error))?
1507 .is_some(),
1508 ItemKind::Project => destination
1509 .source()
1510 .get_project(id)
1511 .await
1512 .map_err(|error| refused(destination, error))?
1513 .is_some(),
1514 };
1515 Ok(found)
1516}