Skip to main content

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}