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//! Which rule found the item decides what the copy records there. A copy that got its
18//! target from rule 1 is a copy-back — the destination is the *original*, and the item
19//! being copied is the one that came from it — so the destination keeps the origin it
20//! already holds, holding none included. Every other copy records the id it was copied
21//! from. See [`recorded`] for what stamping a copy-back's own id there costs.
22//!
23//! A destination write is at the user's explicit request, names its destination, goes
24//! through that source's own write interface into that source's own store, and is never
25//! read back to answer a query. That is what makes it a write and not a cache.
26
27use std::collections::{BTreeMap, BTreeSet};
28
29use onetaskgraph_plugin_api::{
30    Cursor, DependencyEdge, DependencyEndpoint, DependencyKind, Direction, Document, DocumentQuery,
31    ItemKind, ItemWrite, Location, NativeId, Page, PageRequest, Project, ProjectQuery, Repository,
32    SourceError, SourceName, Task, TaskQuery,
33};
34use schemars::JsonSchema;
35use serde::{Deserialize, Serialize};
36use serde_json::Value;
37
38use crate::GlobalId;
39use crate::resolve::ResolvedSource;
40
41use super::fetch::{fits, unrepeated};
42use super::local::ProjectSelector;
43use super::{
44    DocumentFilters, DocumentRequest, Engine, EngineError, Filters, LeftBehind, Paging, Qualified,
45    TaskRequest,
46};
47
48/// A request to copy work into one configured destination.
49#[derive(Debug, Clone)]
50pub struct CopyRequest {
51    /// The qualified items to copy, in the order they were named.
52    pub items: CopyItems,
53    /// What those ids name, and what comes with them.
54    pub scope: CopyScope,
55    /// The configured source to copy into — a source name, never a qualified id.
56    pub destination: SourceName,
57    /// How to re-establish a correspondence the two origin rules cannot find.
58    pub match_by: Option<MatchBy>,
59    /// Whether an origin naming nothing at the destination falls through to the search
60    /// rule instead of refusing.
61    pub recreate: bool,
62    /// Whether to perform every read and no write.
63    pub dry_run: bool,
64}
65
66/// The items one copy names: at least one, because a copy naming none is not a copy.
67///
68/// A newtype rather than a bare `Vec`, for the reason [`Repository`] is one: the empty
69/// list is not a copy of nothing, it is a caller mistake, and a type that can hold it
70/// leaves every reader to decide what it means — a report with no entries, an error, a
71/// silent success. None of those is better than not being able to say it.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct CopyItems(Vec<GlobalId>);
74
75impl CopyItems {
76    /// The items a caller named, or `None` when they named none.
77    #[must_use]
78    pub fn new(items: Vec<GlobalId>) -> Option<Self> {
79        (!items.is_empty()).then_some(Self(items))
80    }
81
82    /// The items, in the order they were named.
83    #[must_use]
84    pub fn as_slice(&self) -> &[GlobalId] {
85        &self.0
86    }
87}
88
89/// What the ids a copy names are, and what travels with them.
90///
91/// One value rather than a kind beside a flag, because three of the four combinations
92/// those two would make are real and the fourth — tasks, with the tasks of each also
93/// copied — means nothing.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum CopyScope {
96    /// The ids name tasks, and only those tasks are copied.
97    Tasks,
98    /// The ids name projects.
99    Projects {
100        /// Whether the tasks in each project are copied too.
101        tasks: bool,
102    },
103    /// The ids name documents, and only those documents are copied.
104    ///
105    /// Nothing travels with a document: it takes part in no dependency graph, and it holds
106    /// nothing of its own the way a project holds tasks.
107    Documents,
108}
109
110/// The caller-named escape for a correspondence neither origin rule can find.
111///
112/// A person editing Markdown who deletes or corrupts the origin key leaves an item rule 1
113/// cannot use and rule 2 cannot find, and the next copy would create a second item. This
114/// is how that is re-established without hand-editing ids.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum MatchBy {
117    /// Match the item whose title is the same.
118    Title,
119    /// Match the item whose value at this metadata key is the same.
120    Metadata(String),
121}
122
123impl MatchBy {
124    /// The spelling a caller types, `title` or any metadata key.
125    #[must_use]
126    pub fn parse(key: &str) -> Self {
127        if key == "title" {
128            Self::Title
129        } else {
130            Self::Metadata(key.to_owned())
131        }
132    }
133}
134
135/// What a copy did, one entry per item.
136///
137/// The same per-item outcomes reach every consumer: the machine-readable output renders
138/// this, the rendered output renders this, and a Rust caller is handed it.
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
140pub struct CopyReport {
141    /// One entry per item the copy considered, in the order it considered them.
142    pub items: Vec<CopyOutcome>,
143    // Three flat scalars rather than one nested object, and a `!skip_serializing_if` beside
144    // every skip: both are load-bearing for the generated SDKs rather than matters of
145    // taste, and AGENTS.md's note on what a copied document's references are pointed at is
146    // where that reasoning lives.
147    /// Reference occurrences the copy rewrote to the destination's own location for the
148    /// record they name.
149    ///
150    /// A silent bound is indistinguishable from a bug, so the copy says what it did to the
151    /// references the documents it carried hold. This and the two below are totals over the
152    /// whole invocation rather than figures per document, and all three default to zero, so
153    /// a consumer written against the output before they existed is unaffected.
154    ///
155    /// **What these figures do not claim.** The referent set is a document's own project,
156    /// so a reference to a record in a *different* project is never recognised at all and
157    /// cannot appear in [`Self::references_unresolved`] either. These are the references
158    /// the copy recognised; they are not a census of every reference a document holds.
159    /// Noticing an out-of-scope reference would need exactly the unbounded destination walk
160    /// this design refuses.
161    #[serde(default, skip_serializing_if = "nothing_to_report")]
162    #[schemars(!skip_serializing_if)]
163    pub references_rewritten: u64,
164    /// Reference occurrences the copy recognised and left byte-for-byte as they were,
165    /// because the correspondence could not be established.
166    #[serde(default, skip_serializing_if = "nothing_to_report")]
167    #[schemars(!skip_serializing_if)]
168    pub references_unresolved: u64,
169    /// How many of [`Self::references_unresolved`] were left alone because the
170    /// correspondence was **ambiguous** rather than merely absent. A sub-count, never
171    /// larger than it.
172    ///
173    /// Split out because the two mean different things to a reader. A reference with no
174    /// counterpart is ordinary and expected under the bound above — the design working. An
175    /// ambiguous one says the destination holds duplicate records for one work item, or the
176    /// source reports one location for two records, and re-running the copy will never
177    /// clear it.
178    #[serde(default, skip_serializing_if = "nothing_to_report")]
179    #[schemars(!skip_serializing_if)]
180    // llmlint: ignore[invalid_states_unrepresentable] JSON Schema cannot express an
181    // inequality between two numbers, so a private constructor here would hold this in one
182    // consumer of three while both SDKs' generated models went on admitting it. What holds
183    // it is `substitute`: `Resolution` has no variant that counts an occurrence ambiguous
184    // without counting it unresolved.
185    pub references_ambiguous: u64,
186}
187
188/// Whether one of [`CopyReport`]'s reference figures has anything to say.
189///
190/// A copy that recognised no reference reports that by leaving the figure out rather than
191/// by writing a nought, so the machine output of a task or project copy is exactly what it
192/// was before these figures existed. The human rendering says it in words either way,
193/// because a reader there needs to be told the copy looked.
194fn nothing_to_report(figure: &u64) -> bool {
195    *figure == 0
196}
197
198/// One document's reference figures, before they are folded into the invocation's.
199#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
200struct Counted {
201    /// Occurrences rewritten.
202    rewritten: u64,
203    /// Occurrences recognised and left alone.
204    unresolved: u64,
205    /// How many of those were ambiguous.
206    ambiguous: u64,
207}
208
209impl Counted {
210    /// Fold one document's figures into the invocation's.
211    fn add(&mut self, other: Self) {
212        self.rewritten += other.rewritten;
213        self.unresolved += other.unresolved;
214        self.ambiguous += other.ambiguous;
215    }
216}
217
218/// What happened to one item.
219///
220/// `action` and `destination` are one value rather than two fields side by side: an
221/// updated item without a destination id, or an orphan without one, are states this type
222/// must not be able to say — the id *is* what those outcomes are about. The one outcome
223/// that legitimately has none is a dry run that would create, because nothing was
224/// created and there is no id to report.
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
226pub struct CopyOutcome {
227    /// The qualified id the item was read from.
228    pub source: GlobalId,
229    /// What happened to it, and where.
230    #[serde(flatten)]
231    pub action: CopyAction,
232}
233
234impl CopyOutcome {
235    /// The qualified id this outcome landed on, when it landed on one.
236    #[must_use]
237    pub fn destination(&self) -> Option<&GlobalId> {
238        self.action.destination()
239    }
240}
241
242/// The four things a copy can do to one item, and the id each of them is about.
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
244#[serde(tag = "action", rename_all = "kebab-case")]
245pub enum CopyAction {
246    // llmlint: ignore[names_match_behavior] `created` is Contract D's serialized action
247    // for both a completed create and a dry run that would create; the optional destination
248    // distinguishes those cases, and renaming this public variant would break Rust callers.
249    /// The destination held no counterpart, so one was created.
250    Created {
251        /// The id it was created under, or `null` for a dry run that would have created
252        /// one — there is no id, because nothing was.
253        destination: Option<GlobalId>,
254    },
255    /// The destination held a counterpart and it now reads as the source does.
256    Updated {
257        /// The item that was updated.
258        destination: GlobalId,
259    },
260    /// The destination held a counterpart that already read that way; nothing was written.
261    Unchanged {
262        /// The item that already said it.
263        destination: GlobalId,
264    },
265    /// The destination holds a counterpart the source no longer does. A copy never
266    /// deletes, so it was left exactly as it is.
267    Orphaned {
268        /// The item that was left alone.
269        destination: GlobalId,
270    },
271}
272
273impl CopyAction {
274    /// The qualified id this action is about, when there is one.
275    #[must_use]
276    pub fn destination(&self) -> Option<&GlobalId> {
277        match self {
278            Self::Created { destination } => destination.as_ref(),
279            Self::Updated { destination }
280            | Self::Unchanged { destination }
281            | Self::Orphaned { destination } => Some(destination),
282        }
283    }
284
285    /// The word this action serializes as, taken from its own `Serialize`.
286    ///
287    /// Read back off the wire form rather than written out again in a `match`, for the
288    /// reason `render::wire` gives: a second spelling of `unchanged` would be a second
289    /// place for it to drift from the one a caller reads.
290    #[must_use]
291    pub fn name(&self) -> String {
292        serde_json::to_value(self).expect("a contract enum serialises")["action"]
293            .as_str()
294            .expect("an internally tagged enum carries its tag")
295            .to_owned()
296    }
297}
298
299/// Where one item is going at the destination.
300enum Target {
301    /// Update the destination item with this id, reached by the rule named.
302    Update {
303        /// The destination item this copy updates.
304        id: NativeId,
305        /// Which rule found it.
306        found: Found,
307    },
308    /// Create one.
309    Create,
310}
311
312/// Which of the rules above found the destination item a copy is updating.
313///
314/// The two are the same instruction — update that item — and a different answer about the
315/// origin, which is why the distinction is carried this far rather than dropped where it
316/// is made. See [`recorded`].
317#[derive(Clone, Copy, PartialEq, Eq)]
318enum Found {
319    /// Rule 1: the item being copied already named it, so this copy is a copy-back.
320    Origin,
321    /// Rule 2 or the caller's matching escape: the destination was searched for it.
322    Search,
323}
324
325/// What a scan of the destination is looking for.
326enum Wanted {
327    /// An item recording this qualified id as its origin.
328    Origin(String),
329    /// An item whose title is this.
330    Title(String),
331    /// An item holding this value at this metadata key.
332    Metadata(String, Value),
333}
334
335impl Wanted {
336    /// Whether one destination item is the one being looked for.
337    fn found(&self, title: &str, metadata: &BTreeMap<String, Value>) -> bool {
338        match self {
339            Self::Origin(id) => {
340                metadata.get(GlobalId::ORIGIN_KEY) == Some(&Value::String(id.clone()))
341            }
342            Self::Title(wanted) => title == wanted,
343            Self::Metadata(key, value) => metadata.get(key) == Some(value),
344        }
345    }
346}
347
348/// What the destination held before this copy touched one item.
349///
350/// Read once, in [`Engine::land`], and used three times over: to decide whether the write
351/// would change anything, to repair the item's edges once the rest of the copy has landed,
352/// and — if the copy cannot finish — to put the item back exactly as it was.
353#[derive(Clone)]
354struct Prior {
355    /// The item as the destination held it.
356    item: Item,
357    /// Its forward edges there.
358    edges: Vec<DependencyEdge>,
359}
360
361/// One item that landed with an edge whose far end was not written yet.
362///
363/// Held until every item of the whole copy has landed, because the far end may be in
364/// another project of the same command: a copy of two projects at once is one copied set,
365/// not two, and an edge across them is remapped rather than written as a foreign id.
366struct Deferred {
367    /// The item, as it was read and resolved.
368    item: Planned,
369    /// The destination project it was filed under.
370    filed: Option<NativeId>,
371    /// Where it landed.
372    destination: NativeId,
373    /// What the destination held there before, when it held anything.
374    prior: Option<Prior>,
375}
376
377/// What one item's undo has to do to put the destination back.
378enum Undo {
379    /// The copy created it, so undoing means removing it.
380    Created {
381        /// Which write interface removes it.
382        kind: Level,
383        /// The destination id it was created under.
384        id: NativeId,
385    },
386    /// The copy overwrote something, so undoing means writing that something back.
387    ///
388    /// No `kind` beside the id, unlike the variant above: what was there says which of the
389    /// two write interfaces takes it back, and a second spelling of that could disagree
390    /// with it.
391    Updated {
392        /// The destination id that was overwritten.
393        id: NativeId,
394        /// What was there before.
395        prior: Prior,
396    },
397}
398
399impl Undo {
400    /// The destination id this entry is about.
401    fn id(&self) -> &NativeId {
402        match self {
403            Self::Created { id, .. } | Self::Updated { id, .. } => id,
404        }
405    }
406
407    /// Which of the destination's three write interfaces this entry belongs to.
408    ///
409    /// An id alone does not identify a destination item: nothing stops a destination
410    /// numbering its tasks and its projects in one namespace, and a local-Markdown store
411    /// filing `alpha.md` under both is the ordinary case rather than the contrived one.
412    /// So this pairs with `id` wherever one entry has to be told from another.
413    fn kind(&self) -> Level {
414        match self {
415            Self::Created { kind, .. } => *kind,
416            // Read off what was there, for the reason the variant carries no `kind` of
417            // its own: two spellings of one fact can disagree, and this one cannot.
418            Self::Updated { prior, .. } => prior.item.level(),
419        }
420    }
421}
422
423/// Everything one copy has written, in the order it wrote it, so a copy that cannot finish
424/// can undo its own writes.
425///
426/// This is not state the engine keeps: it lives for the length of one `copy` call and is
427/// dropped with it, so the invariant that nothing of a user's work is written down outside
428/// the plugin that owns it is untouched.
429#[derive(Default)]
430struct Journal {
431    /// One entry per destination item this copy first touched, in that order.
432    entries: Vec<Undo>,
433}
434
435impl Journal {
436    /// Record what has to happen to put one destination item back.
437    ///
438    /// The *first* entry for an id is the one that matters and later ones are dropped: an
439    /// item written twice — once as it lands, once when its edges are repaired — was only
440    /// ever one thing before this copy started, and that is what undoing it restores.
441    fn record(&mut self, entry: Undo) {
442        if self
443            .entries
444            .iter()
445            .any(|held| held.kind() == entry.kind() && held.id() == entry.id())
446        {
447            return;
448        }
449        self.entries.push(entry);
450    }
451}
452
453/// One item, read and resolved, on its way into the destination.
454struct Planned {
455    /// Where it came from.
456    source: GlobalId,
457    /// The item as its source reported it.
458    item: Item,
459    /// Its forward edges, as its source reported them.
460    edges: Vec<DependencyEdge>,
461    /// Where it is going.
462    target: Target,
463}
464
465/// A task, a project or a document, so the copy path is written once.
466#[derive(Clone)]
467enum Item {
468    /// A task.
469    Task(Box<Task>),
470    /// A project.
471    Project(Box<Project>),
472    /// A document.
473    Document(Box<Document>),
474}
475
476impl Item {
477    fn id(&self) -> &NativeId {
478        match self {
479            Self::Task(task) => &task.id,
480            Self::Project(project) => &project.id,
481            Self::Document(document) => &document.id,
482        }
483    }
484
485    fn level(&self) -> Level {
486        match self {
487            Self::Task(_) => Level::Task,
488            Self::Project(_) => Level::Project,
489            Self::Document(_) => Level::Document,
490        }
491    }
492}
493
494/// Which of a destination's three read-and-write interfaces one item belongs to.
495///
496/// Deliberately not [`ItemKind`]: that enum names what a *dependency endpoint* points at,
497/// and the contract gives it no document variant because nothing may point at a document.
498/// This one names which pair of methods reads and writes an item, which is a different
499/// question with a third answer.
500///
501/// Ordered so it can key a map of what a destination holds, per interface: an id alone
502/// does not identify a destination item, for the reason [`Undo::kind`] records.
503#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
504enum Level {
505    /// `get_task`, `write_task`, `delete_task`.
506    Task,
507    /// `get_project`, `write_project`, `delete_project`.
508    Project,
509    /// `get_document`, `write_document`, `delete_document`.
510    Document,
511}
512
513/// One record filed under a document's own project, and the location string its source
514/// reports for it.
515///
516/// A reference is a **literal occurrence, in a document's content, of the exact location
517/// string the source reports for a related record** — the `String` inside
518/// [`Location::Path`] or [`Location::Url`]. Both ends of a rewrite come from the plugins'
519/// own reported [`Location`]: nothing here composes an address out of a name, an id or a
520/// root, because a source that reports a canonical absolute path and a source that reports
521/// an issue link are the two things the contract lets this ask about.
522#[derive(Clone)]
523struct Referent {
524    /// Its qualified id at the source.
525    id: GlobalId,
526    /// The origin it records in its own metadata, when it records one.
527    origin: Option<GlobalId>,
528    /// Which of the destination's three interfaces its counterpart would be read from.
529    level: Level,
530    /// The non-empty location string its source reports for it.
531    location: String,
532}
533
534impl Referent {
535    /// The two keys a destination record's own recorded origin is matched against.
536    ///
537    /// **A destination record is this referent's counterpart when its
538    /// [`GlobalId::ORIGIN_KEY`] equals either this referent's own qualified source id, or
539    /// the origin this referent itself records.** In plainer terms: when the destination
540    /// record was copied directly from this referent, or directly from the one predecessor
541    /// this referent itself records.
542    ///
543    /// That reach is exactly one recorded hop of ancestry on each side, and nothing more:
544    ///
545    /// - **A single hop resolves.** The destination record came straight from the referent.
546    /// - **A one-level fan-out resolves.** A record copied from one store into two, with
547    ///   the document arriving by one route and naming records that arrived by the other,
548    ///   so both sides trace to one common predecessor. That is what the second key buys,
549    ///   and it is the only thing it buys.
550    /// - **A chain of two or more hops does not resolve**, on either side, and that is
551    ///   permanent rather than pending: only one origin is ever recorded and every hop
552    ///   overwrites it. [`carried`] removes the key from the incoming metadata outright and
553    ///   [`recorded`] writes the id at the *immediate* source on every path but the
554    ///   copy-back, so A → B → C leaves C keyed by B and A's id gone.
555    ///
556    /// The second key costs no read — the referent's metadata is already in hand. Chasing
557    /// the chain further would need the intermediate stores configured and reachable, which
558    /// would put a third party's availability inside a copy; a durable lineage id would
559    /// identify only records written after it landed, so it would resolve nothing already
560    /// on a destination.
561    fn keys(&self) -> Vec<String> {
562        let mut keys = vec![self.id.to_string()];
563        if let Some(origin) = &self.origin {
564            let recorded = origin.to_string();
565            if recorded != keys[0] {
566                keys.push(recorded);
567            }
568        }
569        keys
570    }
571}
572
573/// What every whole-reference occurrence of one location string becomes.
574///
575/// Three variants rather than a rewrite beside a flag, because the two ways of leaving an
576/// occurrence alone are what the two figures a copy reports are *about*: one is the design
577/// working and the other says the destination or the source holds something a re-run will
578/// never fix. A bool would let a reader of this type read them as the same outcome.
579enum Resolution {
580    /// The destination's own location string for the counterpart.
581    Rewrite(String),
582    /// The destination holds no counterpart, or holds one it reports no location for, so
583    /// the occurrence is left byte-for-byte. Ordinary and expected under the bound this
584    /// design works to.
585    NoCounterpart,
586    /// The correspondence could not be established **confidently** — more than one
587    /// destination record matches the referent's two keys, or two referents report this one
588    /// location string. The occurrence is left byte-for-byte, no record is chosen, and a
589    /// re-run will never clear it.
590    Ambiguous,
591}
592
593/// Every destination record that records an origin, read **once per copy invocation**.
594///
595/// Several documents in one project is the ordinary case, and a walk per document would
596/// multiply reads against a rate limiter for no gain — so this is built once, for the
597/// levels the invocation's own documents really name, and every document and every
598/// referent of that invocation is answered out of it. A copy whose documents hold no
599/// candidate reference builds none at all.
600///
601/// **This is a stricter discipline than [`Engine::scan`], deliberately.** That lookup takes
602/// the first hit and stops, and every consumer of the copy already depends on it doing so;
603/// it chooses the copy's own target, where a caller named the item. This one edits the
604/// content of somebody's document, where a wrong answer is silent corruption of prose a
605/// person will act on — so where more than one record matches, it chooses none. The two
606/// lookups answer different questions and are meant to disagree on a destination holding
607/// duplicates.
608#[derive(Default)]
609struct Counterparts {
610    /// The records at one interface recording one origin.
611    by_origin: BTreeMap<(Level, String), Vec<Held>>,
612}
613
614/// One record a destination walk found: where it is there, and the location string that
615/// destination reports for it — `None` when it reports none.
616type Held = (NativeId, Option<String>);
617
618impl Counterparts {
619    /// Record one destination item, when it records an origin at all.
620    fn note(
621        &mut self,
622        level: Level,
623        id: &NativeId,
624        location: Option<&Location>,
625        metadata: &BTreeMap<String, Value>,
626    ) {
627        let Some(origin) = origin_of(metadata) else {
628            return;
629        };
630        self.by_origin
631            .entry((level, origin.to_string()))
632            .or_default()
633            .push((id.clone(), located(location)));
634    }
635
636    /// What one referent's occurrences become, by the two-key rule.
637    ///
638    /// Where the correspondence cannot be established **confidently**, the text is left
639    /// exactly as it is and no record is chosen — see the note on this type.
640    fn resolve(&self, referent: &Referent) -> Resolution {
641        let mut candidates: Vec<&Held> = Vec::new();
642        for key in referent.keys() {
643            for record in self
644                .by_origin
645                .get(&(referent.level, key))
646                .into_iter()
647                .flatten()
648            {
649                // One destination record matching both keys is one record, not two.
650                if !candidates.iter().any(|held| held.0 == record.0) {
651                    candidates.push(record);
652                }
653            }
654        }
655        match candidates.as_slice() {
656            [] => Resolution::NoCounterpart,
657            // A counterpart the destination reports no location for names nowhere a reader
658            // could go, so the source's own string is left standing rather than removed.
659            [(_, location)] => location
660                .clone()
661                .map_or(Resolution::NoCounterpart, Resolution::Rewrite),
662            _ => Resolution::Ambiguous,
663        }
664    }
665}
666
667impl Engine {
668    /// Copy every item a request names into one configured destination.
669    ///
670    /// This is the whole of the verb, and the command line drives exactly this: a copy a
671    /// Rust caller makes and a copy typed at a shell are the same call, so the two cannot
672    /// answer the same copy differently.
673    ///
674    /// # Errors
675    ///
676    /// Returns [`EngineError`] when the destination is not configured, cannot be built,
677    /// cannot be written, or — for a document copy — declares it has no documents; when an
678    /// id names nothing; when an origin names an item the
679    /// destination no longer holds and `--recreate` was not given; and when the
680    /// destination refuses the write — including a field or a metadata key it cannot
681    /// carry, which it names rather than dropping.
682    pub async fn copy(&self, request: &CopyRequest) -> Result<CopyReport, EngineError> {
683        let destination = self.writable(&request.destination)?;
684        // Before anything is read, and from the declaration rather than from a failed
685        // write: a destination that says it has no documents has nowhere to put one.
686        if request.scope == CopyScope::Documents {
687            documentary(destination)?;
688        }
689        let mut journal = Journal::default();
690        match self.copy_all(destination, request, &mut journal).await {
691            Ok(report) => Ok(report),
692            Err(error) => Err(self.undo(destination, journal, error).await),
693        }
694    }
695
696    /// The copy itself, with everything it writes recorded so a failure can be undone.
697    ///
698    /// The ids named together are **one** copied set, and that is what makes an edge
699    /// between any two of them a real edge at the destination: a copy of two projects at
700    /// once knows that a task in the first depends on a task in the second, and a task
701    /// knows that the project it belongs to is being created beside it. Copying them one
702    /// at a time could not, and wrote the far end as the id it had at its *source* — a
703    /// dangling reference to somewhere the destination has never heard of.
704    async fn copy_all(
705        &self,
706        destination: &ResolvedSource,
707        request: &CopyRequest,
708        journal: &mut Journal,
709    ) -> Result<CopyReport, EngineError> {
710        // Keyed by the qualified id's own rendering, which is what a recorded origin holds
711        // anyway — making `GlobalId` orderable for one local map would put an ordering on
712        // a contract type for a reason no caller of it has.
713        let mut written: BTreeMap<String, NativeId> = BTreeMap::new();
714        let mut deferred: Vec<Deferred> = Vec::new();
715        // One total for the whole invocation rather than one per document.
716        let mut references = Counted::default();
717        // The whole copied set, established before anything is written. For a project
718        // copy that means reading every named project's membership first: the set is the
719        // whole request rather than one project of it.
720        let mut membership = Vec::new();
721        let mut copied = Vec::new();
722        match request.scope {
723            CopyScope::Tasks | CopyScope::Documents => {
724                copied.extend(request.items.as_slice().iter().cloned());
725            }
726            CopyScope::Projects { tasks } => {
727                for id in request.items.as_slice() {
728                    let members = if tasks {
729                        self.project_members(id).await?
730                    } else {
731                        Vec::new()
732                    };
733                    copied.push(id.clone());
734                    copied.extend(members.iter().cloned());
735                    membership.push((id.clone(), members));
736                }
737            }
738        }
739        let items = match request.scope {
740            CopyScope::Tasks | CopyScope::Documents => {
741                self.copy_items(
742                    destination,
743                    request,
744                    match request.scope {
745                        CopyScope::Documents => Level::Document,
746                        _ => Level::Task,
747                    },
748                    request.items.as_slice(),
749                    None,
750                    &copied,
751                    &mut written,
752                    &mut deferred,
753                    journal,
754                    &mut references,
755                )
756                .await?
757            }
758            CopyScope::Projects { tasks } => {
759                let mut items = Vec::new();
760                for (id, members) in &membership {
761                    items.extend(
762                        self.copy_project(
763                            destination,
764                            request,
765                            id,
766                            members,
767                            tasks,
768                            &copied,
769                            &mut written,
770                            &mut deferred,
771                            journal,
772                            &mut references,
773                        )
774                        .await?,
775                    );
776                }
777                items
778            }
779        };
780        self.repair(destination, request, &copied, &written, deferred, journal)
781            .await?;
782        Ok(CopyReport {
783            items,
784            references_rewritten: references.rewritten,
785            references_unresolved: references.unresolved,
786            references_ambiguous: references.ambiguous,
787        })
788    }
789
790    /// Write every deferred item again, now that every destination id is known.
791    ///
792    /// This is the second half of the two passes an edge between two items of one copy
793    /// needs: the far end's destination id does not exist until it has been created, so
794    /// the item that points at it lands first without that edge and is completed here.
795    /// It runs once for the whole request rather than once per project, because a far end
796    /// may be in a project this copy has not reached yet.
797    async fn repair(
798        &self,
799        destination: &ResolvedSource,
800        request: &CopyRequest,
801        copied: &[GlobalId],
802        written: &BTreeMap<String, NativeId>,
803        deferred: Vec<Deferred>,
804        journal: &mut Journal,
805    ) -> Result<(), EngineError> {
806        if request.dry_run {
807            return Ok(());
808        }
809        for entry in deferred {
810            let edges = mapped_edges(
811                &entry.item.edges,
812                &entry.item.source.source,
813                destination,
814                copied,
815                written,
816            );
817            self.write(
818                destination,
819                &entry.item,
820                Some(entry.destination),
821                entry.filed,
822                &resolved(&edges),
823                entry.prior,
824                journal,
825            )
826            .await?;
827        }
828        Ok(())
829    }
830
831    /// Put the destination back the way this copy found it, then report why it failed.
832    ///
833    /// Undone in reverse, and an item this copy created is removed rather than restored —
834    /// the entry recording what it looked like a moment after creation is not a state
835    /// anybody asked for. When the destination cannot take one of them back, the refusal
836    /// says so and names what is still there, because a user told "the copy failed" about
837    /// a destination that is not as they left it will copy again over a tree nobody
838    /// described.
839    async fn undo(
840        &self,
841        destination: &ResolvedSource,
842        journal: Journal,
843        error: EngineError,
844    ) -> EngineError {
845        let created: Vec<(Level, &NativeId)> = journal
846            .entries
847            .iter()
848            .filter_map(|entry| match entry {
849                Undo::Created { kind, id } => Some((*kind, id)),
850                Undo::Updated { .. } => None,
851            })
852            .collect();
853        // The ids and the refusal are one value rather than two, because they are one
854        // fact: an item is only left behind because the destination refused to take it
855        // back, so the first refusal carries the first id and neither half can be
856        // recorded without the other.
857        let mut unrestored: Option<(LeftBehind, SourceError)> = None;
858        for entry in journal.entries.iter().rev() {
859            let outcome = match entry {
860                Undo::Created { kind, id } => remove(destination, *kind, id).await,
861                Undo::Updated { id, prior, .. } if !created.contains(&(prior.item.level(), id)) => {
862                    restore(destination, id, prior).await
863                }
864                Undo::Updated { .. } => Ok(()),
865            };
866            if let Err(problem) = outcome {
867                let id = GlobalId::new(destination.name().clone(), entry.id().clone());
868                match &mut unrestored {
869                    Some((left_behind, _)) => left_behind.push(id),
870                    None => unrestored = Some((LeftBehind::new(id), problem)),
871                }
872            }
873        }
874        match unrestored {
875            None => error,
876            Some((left_behind, refusal)) => EngineError::CopyNotUndone {
877                error: Box::new(error),
878                left_behind,
879                refusal,
880            },
881        }
882    }
883
884    /// The destination source, once it is established it exists and can be written.
885    fn writable(&self, name: &SourceName) -> Result<&ResolvedSource, EngineError> {
886        let name = self.known(name)?;
887        if let Some(unavailable) = self.unavailable().find(|source| source.name() == &name) {
888            return Err(EngineError::DestinationUnavailable {
889                name: name.to_string(),
890                error: unavailable.error().clone(),
891            });
892        }
893        let source = self
894            .ready()
895            .find(|source| source.name() == &name)
896            .ok_or(EngineError::NoSources)?;
897        if !source.source().writes().is_supported() {
898            return Err(EngineError::NotWritable {
899                name: name.to_string(),
900                kind: source.kind().to_owned(),
901            });
902        }
903        Ok(source)
904    }
905
906    /// Copy one project and, unless they are excluded, every task in it.
907    // llmlint: ignore[suppressions_justified] Five of these are the copy's own running
908    // state — the copied set, the ids written so far, the items held back for repair and
909    // the undo journal — and every one of them is shared across the whole request rather
910    // than per project, which is the defect this signature exists to close. Bundling them
911    // into a context struct would put a lifetime and a borrow split around state that is
912    // threaded through three call sites and read nowhere else.
913    #[allow(clippy::too_many_arguments)]
914    async fn copy_project(
915        &self,
916        destination: &ResolvedSource,
917        request: &CopyRequest,
918        id: &GlobalId,
919        members: &[GlobalId],
920        tasks: bool,
921        copied: &[GlobalId],
922        written: &mut BTreeMap<String, NativeId>,
923        deferred: &mut Vec<Deferred>,
924        journal: &mut Journal,
925        references: &mut Counted,
926    ) -> Result<Vec<CopyOutcome>, EngineError> {
927        // On a repeat copy, compare the project with its final remapped edges before the
928        // first pass temporarily rewrites it. This preserves an `unchanged` outcome when
929        // the project and every copied member already have counterparts.
930        let project_plan = self.plan(destination, request, Level::Project, id).await?;
931        let mut known = BTreeMap::new();
932        if let Target::Update { id: target, .. } = &project_plan.target {
933            known.insert(id.to_string(), target.clone());
934        }
935        for member in members {
936            let member_plan = self.plan(destination, request, Level::Task, member).await?;
937            if let Target::Update { id: target, .. } = member_plan.target {
938                known.insert(member.to_string(), target);
939            }
940        }
941        let project_was_unchanged = if let Target::Update { id: target, .. } = &project_plan.target
942        {
943            let edges = mapped_edges(&project_plan.edges, &id.source, destination, copied, &known);
944            let held = self.prior(destination, Level::Project, target).await?;
945            !edges.iter().any(Option::is_none)
946                && !changes(
947                    held.as_ref(),
948                    &project_plan,
949                    target,
950                    &None,
951                    &resolved(&edges),
952                )
953        } else {
954            false
955        };
956        let mut outcomes = self
957            .copy_items(
958                destination,
959                request,
960                Level::Project,
961                std::slice::from_ref(id),
962                None,
963                copied,
964                written,
965                deferred,
966                journal,
967                references,
968            )
969            .await?;
970        if !tasks {
971            return Ok(outcomes);
972        }
973        // `None` when a dry run would have created the project: nothing was written, so
974        // there is no destination project id to file the tasks under. Every task is still
975        // read and still reported, because that is what a dry run is for.
976        let project = outcomes.first().and_then(CopyOutcome::destination).cloned();
977        let task_outcomes = self
978            .copy_items(
979                destination,
980                request,
981                Level::Task,
982                members,
983                project.as_ref().map(|project| project.native.clone()),
984                copied,
985                written,
986                deferred,
987                journal,
988                references,
989            )
990            .await?;
991        outcomes.extend(task_outcomes);
992        if let Some(project) = project {
993            if project_was_unchanged {
994                outcomes[0].action = CopyAction::Unchanged {
995                    destination: project.clone(),
996                };
997            }
998            outcomes.extend(
999                self.orphans(destination, id, &project.native, members)
1000                    .await?,
1001            );
1002        }
1003        Ok(outcomes)
1004    }
1005
1006    /// Every task the source holds in `project`, by qualified id.
1007    async fn project_members(&self, project: &GlobalId) -> Result<Vec<GlobalId>, EngineError> {
1008        Ok(self
1009            .project_member_tasks(project)
1010            .await?
1011            .into_iter()
1012            .map(|task| task.id)
1013            .collect())
1014    }
1015
1016    /// Every task the source holds in `project`, as the source reported it.
1017    ///
1018    /// The ids alone are what a copy files under a project; the whole task is what a
1019    /// document's references need, because the location a reference names is a field of it.
1020    async fn project_member_tasks(
1021        &self,
1022        project: &GlobalId,
1023    ) -> Result<Vec<Qualified<Task>>, EngineError> {
1024        let mut request = TaskRequest {
1025            sources: vec![project.source.clone()],
1026            filters: Filters::default(),
1027            project: ProjectSelector::Qualified(project.clone()),
1028            paging: Paging {
1029                limit: PROJECT_PAGE,
1030                token: None,
1031            },
1032        };
1033        let mut members = Vec::new();
1034        // Pages by this engine's own token rather than by a source cursor, and the
1035        // asymmetry with the three walks below is deliberate. A source answering two
1036        // cursors with each other advances on every page, so `unrepeated` under the list
1037        // verb never fires; the cycle shows only as a token handed back unchanged, which
1038        // is what this loop pages by. Point it at the source and a project copy spins.
1039        //
1040        // No page bound here for the same reason: `Engine::tasks` merges under the budget
1041        // it was asked, so nothing longer than `PROJECT_PAGE` can arrive. `fits` in
1042        // `fetch::walk` refuses the page a source can really overrun, its own, while
1043        // these very members are read.
1044        let misbehaved = |error| EngineError::SourceRefused {
1045            name: project.source.to_string(),
1046            error,
1047        };
1048        loop {
1049            let asked = request.paging.token.clone();
1050            let response = self.tasks(&request).await?;
1051            if let Some(failure) = response.errors.first() {
1052                return Err(EngineError::SourceRefused {
1053                    name: failure.source.to_string(),
1054                    error: failure.error.clone(),
1055                });
1056            }
1057            unrepeated(
1058                response.next.as_ref(),
1059                asked.as_ref(),
1060                "the tasks of a project were being read for a copy",
1061            )
1062            .map_err(misbehaved)?;
1063            members.extend(response.items);
1064            match response.next {
1065                Some(token) => request.paging.token = Some(token),
1066                None => return Ok(members),
1067            }
1068        }
1069    }
1070
1071    /// Every document the source holds in `project`, as the source reported it.
1072    ///
1073    /// Paged by this engine's own token for the reason the member walk above is, and the
1074    /// note there says why.
1075    async fn project_documents(
1076        &self,
1077        project: &GlobalId,
1078    ) -> Result<Vec<Qualified<Document>>, EngineError> {
1079        let mut request = DocumentRequest {
1080            sources: vec![project.source.clone()],
1081            filters: DocumentFilters::default(),
1082            project: ProjectSelector::Qualified(project.clone()),
1083            paging: Paging {
1084                limit: PROJECT_PAGE,
1085                token: None,
1086            },
1087        };
1088        let mut held = Vec::new();
1089        let misbehaved = |error| EngineError::SourceRefused {
1090            name: project.source.to_string(),
1091            error,
1092        };
1093        loop {
1094            let asked = request.paging.token.clone();
1095            let response = self.documents(&request).await?;
1096            if let Some(failure) = response.errors.first() {
1097                return Err(EngineError::SourceRefused {
1098                    name: failure.source.to_string(),
1099                    error: failure.error.clone(),
1100                });
1101            }
1102            unrepeated(
1103                response.next.as_ref(),
1104                asked.as_ref(),
1105                "the documents of a project were being read for a copy",
1106            )
1107            .map_err(misbehaved)?;
1108            held.extend(response.items);
1109            match response.next {
1110                Some(token) => request.paging.token = Some(token),
1111                None => return Ok(held),
1112            }
1113        }
1114    }
1115
1116    /// Point every reference the documents of this copy hold at the destination's own
1117    /// records, and say how many it could not.
1118    ///
1119    /// A document copied out of a local Markdown store used to arrive naming absolute paths
1120    /// under one checkout on one machine, dead for the only reader the copy exists for,
1121    /// while the destination held its own record for every one of them the whole time. This
1122    /// is what closes that, and it is deliberately **not** a Markdown-link parser: the
1123    /// artifact that motivated it holds bare absolute paths inside backticks in a table
1124    /// cell, which `[text](target)` matching would have left exactly as it found them.
1125    ///
1126    /// The correspondence is re-established from what the *destination* records at
1127    /// [`GlobalId::ORIGIN_KEY`], not from the mapping this copy holds. That mapping is not
1128    /// available when it is needed: `project copy` and `document copy` are separate verbs,
1129    /// one invocation carries one [`CopyScope`], and a project copy carries no documents —
1130    /// so by the time the document is copied, the tasks were written by a process that has
1131    /// exited. Reading the destination is also what makes a document copied on its own
1132    /// work, which a same-run mapping never could.
1133    ///
1134    /// Tasks and projects are not touched. Only a document's content is rewritten, and
1135    /// nothing else about it changes.
1136    async fn rewrite_references(
1137        &self,
1138        destination: &ResolvedSource,
1139        planned: &mut [Planned],
1140        counts: &mut Counted,
1141    ) -> Result<(), EngineError> {
1142        // Read at the source, once per project rather than once per document: several
1143        // documents of one project is the ordinary case.
1144        let mut by_project: BTreeMap<String, Vec<Referent>> = BTreeMap::new();
1145        let mut named: Vec<Vec<Referent>> = Vec::new();
1146        for item in planned.iter() {
1147            named.push(self.named_referents(item, &mut by_project).await?);
1148        }
1149        // The destination is walked only for a copy that really names something, and only
1150        // for the interfaces those referents are read from.
1151        let mut levels: Vec<Level> = Vec::new();
1152        for referent in named.iter().flatten() {
1153            if !levels.contains(&referent.level) {
1154                levels.push(referent.level);
1155            }
1156        }
1157        if levels.is_empty() {
1158            return Ok(());
1159        }
1160        let counterparts = self.counterparts(destination, &levels).await?;
1161        for (item, referents) in planned.iter_mut().zip(named) {
1162            let Item::Document(document) = &mut item.item else {
1163                continue;
1164            };
1165            let Some(content) = &document.content else {
1166                continue;
1167            };
1168            let (rewritten, made) = substitute(content, &table_for(&referents, &counterparts));
1169            document.content = Some(rewritten);
1170            counts.add(made);
1171        }
1172        Ok(())
1173    }
1174
1175    /// The records of one document's own project whose location string its content really
1176    /// holds, as a whole reference.
1177    ///
1178    /// A document with no content, or with no project at the source, names nothing: the
1179    /// referent set is the document's own project, its tasks and its other documents, and
1180    /// there is no such set without a project.
1181    async fn named_referents(
1182        &self,
1183        item: &Planned,
1184        by_project: &mut BTreeMap<String, Vec<Referent>>,
1185    ) -> Result<Vec<Referent>, EngineError> {
1186        let Item::Document(document) = &item.item else {
1187            return Ok(Vec::new());
1188        };
1189        let (Some(content), Some(project)) =
1190            (document.content.as_deref(), document.project.as_ref())
1191        else {
1192            return Ok(Vec::new());
1193        };
1194        if content.is_empty() {
1195            return Ok(Vec::new());
1196        }
1197        let project = GlobalId::new(item.source.source.clone(), project.clone());
1198        let key = project.to_string();
1199        if !by_project.contains_key(&key) {
1200            let read = self.referents(&project).await?;
1201            by_project.insert(key.clone(), read);
1202        }
1203        Ok(by_project[&key]
1204            .iter()
1205            // A document does not name itself: the referent set is every *other* record
1206            // filed under the project. Told by the interface as well as the id, because an
1207            // id alone does not identify a record — a folder of Markdown filing `A.md`
1208            // under both `tasks/` and `documents/` is the ordinary case rather than the
1209            // contrived one, and excluding by id alone would drop that task from the set.
1210            .filter(|referent| referent.level != Level::Document || referent.id != item.source)
1211            .filter(|referent| holds(content, &referent.location))
1212            .cloned()
1213            .collect())
1214    }
1215
1216    /// Every record filed under one project at its own source, with the location string
1217    /// that source reports for it.
1218    ///
1219    /// The project record itself, every task filed under it, and every document filed under
1220    /// it. All three are reads this engine already knows how to make.
1221    async fn referents(&self, project: &GlobalId) -> Result<Vec<Referent>, EngineError> {
1222        let source = self.readable(&project.source)?;
1223        let mut referents = Vec::new();
1224        if let Some(held) = source
1225            .source()
1226            .get_project(&project.native)
1227            .await
1228            .map_err(|error| refused(source, error))?
1229        {
1230            note(
1231                &mut referents,
1232                project.clone(),
1233                Level::Project,
1234                held.location.as_ref(),
1235                &held.metadata,
1236            );
1237        }
1238        for task in self.project_member_tasks(project).await? {
1239            note(
1240                &mut referents,
1241                task.id,
1242                Level::Task,
1243                task.item.location.as_ref(),
1244                &task.item.metadata,
1245            );
1246        }
1247        for document in self.project_documents(project).await? {
1248            note(
1249                &mut referents,
1250                document.id,
1251                Level::Document,
1252                document.item.location.as_ref(),
1253                &document.item.metadata,
1254            );
1255        }
1256        Ok(referents)
1257    }
1258
1259    /// Walk the destination once for every record it holds at the levels named, one page
1260    /// at a time.
1261    ///
1262    /// One page is *read* at a time, and what is kept from each is three fields of each
1263    /// record — its id, the origin it records and the location the destination reports —
1264    /// never the page. That is more than [`Engine::scan`] keeps, and deliberately: the whole
1265    /// point of this walk is that one pass answers every document and every referent of the
1266    /// invocation, so what it learns has to outlive the page it learned it from. Nothing is
1267    /// written down and the index is dropped with the call.
1268    ///
1269    /// The other difference from `scan` is the *answer*: this one keeps every match, so a
1270    /// destination holding two records for one work item is reported as ambiguous rather
1271    /// than resolved to the first.
1272    async fn counterparts(
1273        &self,
1274        destination: &ResolvedSource,
1275        levels: &[Level],
1276    ) -> Result<Counterparts, EngineError> {
1277        let mut found = Counterparts::default();
1278        for level in levels {
1279            // Every cursor this level has already been sent. `unrepeated` below catches a
1280            // source that hands back the cursor it was just given, and its own note says
1281            // why it catches no more than that: a source cycling through two cursors
1282            // advances on every page, and seeing it needs memory the walks that share that
1283            // helper do not keep. This walk does keep memory — it is building an index that
1284            // outlives each page — so here the memory exists and the cycle is caught. The
1285            // walks one level up catch the same defect as a page token handed back
1286            // unchanged; this one pages by the source's own cursor and has no level above
1287            // it, so nothing else would.
1288            let mut asked_before: BTreeSet<String> = BTreeSet::new();
1289            let mut cursor: Option<Cursor> = None;
1290            loop {
1291                if let Some(next) = &cursor
1292                    && !asked_before.insert(next.0.clone())
1293                {
1294                    return Err(refused(
1295                        destination,
1296                        SourceError::Malformed {
1297                            message: "the source returned a cursor it had already been \
1298                                      given while the destination was being walked for the \
1299                                      records a document's references name, so the walk \
1300                                      would never end"
1301                                .to_owned(),
1302                        },
1303                    ));
1304                }
1305                let asked = cursor.clone();
1306                let request = request_for(destination, cursor);
1307                let next = match level {
1308                    Level::Task => {
1309                        let page = destination
1310                            .source()
1311                            .query_tasks(&TaskQuery::default(), &request)
1312                            .await
1313                            .map_err(|error| refused(destination, error))?;
1314                        fits(page.items.len(), request.limit)
1315                            .map_err(|error| refused(destination, error))?;
1316                        for task in &page.items {
1317                            found.note(*level, &task.id, task.location.as_ref(), &task.metadata);
1318                        }
1319                        page.next
1320                    }
1321                    Level::Project => {
1322                        let page = destination
1323                            .source()
1324                            .query_projects(&ProjectQuery::default(), &request)
1325                            .await
1326                            .map_err(|error| refused(destination, error))?;
1327                        fits(page.items.len(), request.limit)
1328                            .map_err(|error| refused(destination, error))?;
1329                        for project in &page.items {
1330                            found.note(
1331                                *level,
1332                                &project.id,
1333                                project.location.as_ref(),
1334                                &project.metadata,
1335                            );
1336                        }
1337                        page.next
1338                    }
1339                    Level::Document => {
1340                        let page = destination
1341                            .source()
1342                            .query_documents(&DocumentQuery::default(), &request)
1343                            .await
1344                            .map_err(|error| refused(destination, error))?;
1345                        fits(page.items.len(), request.limit)
1346                            .map_err(|error| refused(destination, error))?;
1347                        for document in &page.items {
1348                            found.note(
1349                                *level,
1350                                &document.id,
1351                                document.location.as_ref(),
1352                                &document.metadata,
1353                            );
1354                        }
1355                        page.next
1356                    }
1357                };
1358                unrepeated(
1359                    next.as_ref(),
1360                    asked.as_ref(),
1361                    "the destination was being walked for the records a document's \
1362                     references name",
1363                )
1364                .map_err(|error| refused(destination, error))?;
1365                match next {
1366                    Some(next) => cursor = Some(next),
1367                    None => break,
1368                }
1369            }
1370        }
1371        Ok(found)
1372    }
1373
1374    /// Destination tasks filed under the copied project whose origin the source no longer
1375    /// holds.
1376    ///
1377    /// A copy never deletes, so each is left exactly as it is and reported.
1378    async fn orphans(
1379        &self,
1380        destination: &ResolvedSource,
1381        project: &GlobalId,
1382        at_destination: &NativeId,
1383        copied: &[GlobalId],
1384    ) -> Result<Vec<CopyOutcome>, EngineError> {
1385        let mut orphans = Vec::new();
1386        let mut cursor: Option<Cursor> = None;
1387        loop {
1388            let asked = cursor.clone();
1389            let request = request_for(destination, cursor);
1390            let page: Page<Task> = destination
1391                .source()
1392                .query_tasks(&TaskQuery::default(), &request)
1393                .await
1394                .map_err(|error| refused(destination, error))?;
1395            fits(page.items.len(), request.limit).map_err(|error| refused(destination, error))?;
1396            for task in &page.items {
1397                if task.project.as_ref() != Some(at_destination) {
1398                    continue;
1399                }
1400                let Some(origin) = origin_of(&task.metadata) else {
1401                    continue;
1402                };
1403                if origin.source != project.source || copied.contains(&origin) {
1404                    continue;
1405                }
1406                orphans.push(CopyOutcome {
1407                    source: origin,
1408                    action: CopyAction::Orphaned {
1409                        destination: GlobalId::new(destination.name().clone(), task.id.clone()),
1410                    },
1411                });
1412            }
1413            unrepeated(
1414                page.next.as_ref(),
1415                asked.as_ref(),
1416                "the destination was being read for items the copy left behind",
1417            )
1418            .map_err(|error| refused(destination, error))?;
1419            match page.next {
1420                Some(next) => cursor = Some(next),
1421                None => return Ok(orphans),
1422            }
1423        }
1424    }
1425
1426    /// Read, resolve and write every item named, holding back the ones whose edges are
1427    /// not resolvable yet.
1428    ///
1429    /// An edge between two items of one copy can point at a member whose destination id
1430    /// does not exist until it has been created, so the item that points at it lands
1431    /// without that edge and is handed to `deferred`. [`Engine::repair`] finishes it once
1432    /// the *whole* request has landed — not once this call has, because the far end may
1433    /// be in another project of the same command.
1434    // llmlint: ignore[suppressions_justified] The same running state `copy_project` threads,
1435    // for the same reason: it belongs to one `copy` call and is shared across every item of
1436    // it, and a struct around it would add a borrow split for no reader's benefit.
1437    #[allow(clippy::too_many_arguments)]
1438    async fn copy_items(
1439        &self,
1440        destination: &ResolvedSource,
1441        request: &CopyRequest,
1442        kind: Level,
1443        items: &[GlobalId],
1444        project: Option<NativeId>,
1445        copied: &[GlobalId],
1446        written: &mut BTreeMap<String, NativeId>,
1447        deferred: &mut Vec<Deferred>,
1448        journal: &mut Journal,
1449        references: &mut Counted,
1450    ) -> Result<Vec<CopyOutcome>, EngineError> {
1451        let mut planned = Vec::new();
1452        for id in items {
1453            planned.push(self.plan(destination, request, kind, id).await?);
1454        }
1455
1456        // Only a document's own content names other records, and only once every document
1457        // of this call has been read: the destination is walked once for all of them, and
1458        // the content the rest of this call lands is the rewritten one — which is what
1459        // makes a repeat copy of an already-rewritten document report `unchanged`.
1460        if kind == Level::Document {
1461            self.rewrite_references(destination, &mut planned, references)
1462                .await?;
1463        }
1464
1465        for item in &planned {
1466            if let Target::Update { id, .. } = &item.target {
1467                written.insert(item.source.to_string(), id.clone());
1468            }
1469        }
1470
1471        // Resolved once per item, and used by both passes: the repair pass writes the
1472        // same item again, and re-deriving this there could file it somewhere else.
1473        let mut filed = Vec::new();
1474        for item in &planned {
1475            filed.push(self.filed(destination, item, project.clone()).await?);
1476        }
1477
1478        let mut outcomes = Vec::new();
1479        let mut unresolved = Vec::new();
1480        let mut priors = Vec::new();
1481        for (index, item) in planned.iter().enumerate() {
1482            let edges = mapped_edges(
1483                &item.edges,
1484                &item.source.source,
1485                destination,
1486                copied,
1487                written,
1488            );
1489            if edges.iter().any(Option::is_none) {
1490                unresolved.push(index);
1491            }
1492            let (outcome, prior) = self
1493                .land(
1494                    destination,
1495                    request,
1496                    item,
1497                    filed[index].clone(),
1498                    &edges,
1499                    journal,
1500                )
1501                .await?;
1502            if let Some(id) = outcome.destination() {
1503                written.insert(item.source.to_string(), id.native.clone());
1504            }
1505            outcomes.push(outcome);
1506            priors.push(prior);
1507        }
1508
1509        if !request.dry_run {
1510            for (index, item) in planned.into_iter().enumerate() {
1511                if !unresolved.contains(&index) {
1512                    continue;
1513                }
1514                // Every item a copy that is not a dry run lands has a destination id: the
1515                // one outcome without one is a dry run that would have created, and this
1516                // block does not run for a dry run.
1517                let id = outcomes[index]
1518                    .destination()
1519                    .expect("a copy that writes lands every item it planned")
1520                    .clone();
1521                deferred.push(Deferred {
1522                    item,
1523                    filed: filed[index].clone(),
1524                    destination: id.native,
1525                    prior: priors[index].clone(),
1526                });
1527            }
1528        }
1529        Ok(outcomes)
1530    }
1531
1532    /// Read one item and its forward edges, and decide where it is going.
1533    async fn plan(
1534        &self,
1535        destination: &ResolvedSource,
1536        request: &CopyRequest,
1537        kind: Level,
1538        id: &GlobalId,
1539    ) -> Result<Planned, EngineError> {
1540        let source = self.readable(&id.source)?;
1541        if kind == Level::Document {
1542            documentary(source)?;
1543        }
1544        let item = match kind {
1545            Level::Task => source
1546                .source()
1547                .get_task(&id.native)
1548                .await
1549                .map_err(|error| refused(source, error))?
1550                .map(|task| Item::Task(Box::new(task))),
1551            Level::Project => source
1552                .source()
1553                .get_project(&id.native)
1554                .await
1555                .map_err(|error| refused(source, error))?
1556                .map(|project| Item::Project(Box::new(project))),
1557            Level::Document => source
1558                .source()
1559                .get_document(&id.native)
1560                .await
1561                .map_err(|error| refused(source, error))?
1562                .map(|document| Item::Document(Box::new(document))),
1563        }
1564        .ok_or_else(|| EngineError::NoSuchItem { id: id.to_string() })?;
1565        let edges = forward_edges(source, &id.native, item.level()).await?;
1566        let target = self.target(destination, request, id, &item).await?;
1567        Ok(Planned {
1568            source: id.clone(),
1569            item,
1570            edges,
1571            target,
1572        })
1573    }
1574
1575    /// Which destination item this one corresponds to, by the two origin rules and the
1576    /// caller's escape.
1577    async fn target(
1578        &self,
1579        destination: &ResolvedSource,
1580        request: &CopyRequest,
1581        id: &GlobalId,
1582        item: &Item,
1583    ) -> Result<Target, EngineError> {
1584        let (title, metadata) = described(item);
1585        if let Some(origin) = origin_of(metadata)
1586            && &origin.source == destination.name()
1587        {
1588            if exists(destination, &origin.native, item.level()).await? {
1589                return Ok(Target::Update {
1590                    id: origin.native,
1591                    found: Found::Origin,
1592                });
1593            }
1594            if !request.recreate {
1595                return Err(EngineError::StaleOrigin {
1596                    item: id.to_string(),
1597                    origin: origin.to_string(),
1598                });
1599            }
1600        }
1601        if let Some(found) = self
1602            .scan(destination, item.level(), &Wanted::Origin(id.to_string()))
1603            .await?
1604        {
1605            return Ok(Target::Update {
1606                id: found,
1607                found: Found::Search,
1608            });
1609        }
1610        let wanted = match &request.match_by {
1611            Some(MatchBy::Title) => Some(Wanted::Title(title.to_owned())),
1612            Some(MatchBy::Metadata(key)) => metadata
1613                .get(key)
1614                .map(|value| Wanted::Metadata(key.clone(), value.clone())),
1615            None => None,
1616        };
1617        if let Some(wanted) = wanted
1618            && let Some(found) = self.scan(destination, item.level(), &wanted).await?
1619        {
1620            return Ok(Target::Update {
1621                id: found,
1622                found: Found::Search,
1623            });
1624        }
1625        Ok(Target::Create)
1626    }
1627
1628    /// Walk the destination one page at a time, looking for `wanted`.
1629    ///
1630    /// One page is held at a time and nothing is written down, which is the same bound
1631    /// every other compensation in this engine works under.
1632    async fn scan(
1633        &self,
1634        destination: &ResolvedSource,
1635        kind: Level,
1636        wanted: &Wanted,
1637    ) -> Result<Option<NativeId>, EngineError> {
1638        let mut cursor: Option<Cursor> = None;
1639        loop {
1640            let asked = cursor.clone();
1641            let request = request_for(destination, cursor);
1642            let next = match kind {
1643                Level::Task => {
1644                    let page = destination
1645                        .source()
1646                        .query_tasks(&TaskQuery::default(), &request)
1647                        .await
1648                        .map_err(|error| refused(destination, error))?;
1649                    fits(page.items.len(), request.limit)
1650                        .map_err(|error| refused(destination, error))?;
1651                    for task in &page.items {
1652                        if wanted.found(&task.title, &task.metadata) {
1653                            return Ok(Some(task.id.clone()));
1654                        }
1655                    }
1656                    page.next
1657                }
1658                Level::Project => {
1659                    let page = destination
1660                        .source()
1661                        .query_projects(&ProjectQuery::default(), &request)
1662                        .await
1663                        .map_err(|error| refused(destination, error))?;
1664                    fits(page.items.len(), request.limit)
1665                        .map_err(|error| refused(destination, error))?;
1666                    for project in &page.items {
1667                        if wanted.found(&project.title, &project.metadata) {
1668                            return Ok(Some(project.id.clone()));
1669                        }
1670                    }
1671                    page.next
1672                }
1673                Level::Document => {
1674                    let page = destination
1675                        .source()
1676                        .query_documents(&DocumentQuery::default(), &request)
1677                        .await
1678                        .map_err(|error| refused(destination, error))?;
1679                    fits(page.items.len(), request.limit)
1680                        .map_err(|error| refused(destination, error))?;
1681                    for document in &page.items {
1682                        if wanted.found(&document.title, &document.metadata) {
1683                            return Ok(Some(document.id.clone()));
1684                        }
1685                    }
1686                    page.next
1687                }
1688            };
1689            unrepeated(
1690                next.as_ref(),
1691                asked.as_ref(),
1692                "the destination was being scanned for the item to update",
1693            )
1694            .map_err(|error| refused(destination, error))?;
1695            match next {
1696                Some(next) => cursor = Some(next),
1697                None => return Ok(None),
1698            }
1699        }
1700    }
1701
1702    /// Write one planned item, or say what a dry run would have done.
1703    ///
1704    /// Answers with what the destination held there beforehand as well, which is what
1705    /// makes an item written twice restorable to what it was rather than to what this
1706    /// copy's first pass left.
1707    async fn land(
1708        &self,
1709        destination: &ResolvedSource,
1710        request: &CopyRequest,
1711        item: &Planned,
1712        project: Option<NativeId>,
1713        edges: &[Option<DependencyEdge>],
1714        journal: &mut Journal,
1715    ) -> Result<(CopyOutcome, Option<Prior>), EngineError> {
1716        let target = match &item.target {
1717            Target::Update { id, .. } => Some(id.clone()),
1718            Target::Create => None,
1719        };
1720        // One read of the destination item, used to decide whether the write changes
1721        // anything and — if the copy cannot finish — to put that item back.
1722        let prior = match &target {
1723            Some(id) => self.prior(destination, item.item.level(), id).await?,
1724            None => None,
1725        };
1726        let edges = resolved(edges);
1727        let qualified = |native: NativeId| GlobalId::new(destination.name().clone(), native);
1728        if let Some(id) = &target
1729            && !changes(prior.as_ref(), item, id, &project, &edges)
1730        {
1731            return Ok((
1732                CopyOutcome {
1733                    source: item.source.clone(),
1734                    action: CopyAction::Unchanged {
1735                        destination: qualified(id.clone()),
1736                    },
1737                },
1738                prior,
1739            ));
1740        }
1741        if request.dry_run {
1742            return Ok((
1743                CopyOutcome {
1744                    source: item.source.clone(),
1745                    action: match target {
1746                        Some(id) => CopyAction::Updated {
1747                            destination: qualified(id),
1748                        },
1749                        // Null only here: nothing was created, so there is no id to report.
1750                        None => CopyAction::Created { destination: None },
1751                    },
1752                },
1753                prior,
1754            ));
1755        }
1756        let updating = target.is_some();
1757        let written = qualified(
1758            self.write(
1759                destination,
1760                item,
1761                target,
1762                project,
1763                &edges,
1764                prior.clone(),
1765                journal,
1766            )
1767            .await?,
1768        );
1769        Ok((
1770            CopyOutcome {
1771                source: item.source.clone(),
1772                action: if updating {
1773                    CopyAction::Updated {
1774                        destination: written,
1775                    }
1776                } else {
1777                    CopyAction::Created {
1778                        destination: Some(written),
1779                    }
1780                },
1781            },
1782            prior,
1783        ))
1784    }
1785
1786    /// Which destination project this item is filed under, when it is filed at all.
1787    ///
1788    /// A task copied as part of a project copy is filed under that project's counterpart,
1789    /// which the copy has just established. A task copied on its own has to find it.
1790    async fn filed(
1791        &self,
1792        destination: &ResolvedSource,
1793        item: &Planned,
1794        project: Option<NativeId>,
1795    ) -> Result<Option<NativeId>, EngineError> {
1796        match (&item.item, project) {
1797            (Item::Task(task), None) => {
1798                self.counterpart(destination, item, task.project.as_ref())
1799                    .await
1800            }
1801            (Item::Document(document), None) => {
1802                self.counterpart(destination, item, document.project.as_ref())
1803                    .await
1804            }
1805            (Item::Task(_) | Item::Document(_), filed) => Ok(filed),
1806            (Item::Project(_), _) => Ok(None),
1807        }
1808    }
1809
1810    /// The destination project this task's own project corresponds to, when there is one.
1811    ///
1812    /// A task copied on its own keeps its source's project id when the destination holds
1813    /// no counterpart: the field is opaque to this engine, and dropping it would lose
1814    /// what the source said.
1815    async fn counterpart(
1816        &self,
1817        destination: &ResolvedSource,
1818        item: &Planned,
1819        project: Option<&NativeId>,
1820    ) -> Result<Option<NativeId>, EngineError> {
1821        let Some(project) = project else {
1822            return Ok(None);
1823        };
1824        let qualified = GlobalId::new(item.source.source.clone(), project.clone());
1825        let found = self
1826            .scan(
1827                destination,
1828                Level::Project,
1829                &Wanted::Origin(qualified.to_string()),
1830            )
1831            .await?;
1832        Ok(Some(found.unwrap_or_else(|| project.clone())))
1833    }
1834
1835    /// What the destination holds at one id, item and forward edges together.
1836    ///
1837    /// One read for both purposes it serves — deciding whether a write changes anything,
1838    /// and putting the item back if the copy cannot finish — because a second read of the
1839    /// same item is a second round trip against a hosted destination for nothing.
1840    async fn prior(
1841        &self,
1842        destination: &ResolvedSource,
1843        kind: Level,
1844        id: &NativeId,
1845    ) -> Result<Option<Prior>, EngineError> {
1846        let held = match kind {
1847            Level::Task => destination
1848                .source()
1849                .get_task(id)
1850                .await
1851                .map_err(|error| refused(destination, error))?
1852                .map(|task| Item::Task(Box::new(task))),
1853            Level::Project => destination
1854                .source()
1855                .get_project(id)
1856                .await
1857                .map_err(|error| refused(destination, error))?
1858                .map(|project| Item::Project(Box::new(project))),
1859            Level::Document => destination
1860                .source()
1861                .get_document(id)
1862                .await
1863                .map_err(|error| refused(destination, error))?
1864                .map(|document| Item::Document(Box::new(document))),
1865        };
1866        let Some(item) = held else {
1867            return Ok(None);
1868        };
1869        let edges = forward_edges(destination, id, kind).await?;
1870        Ok(Some(Prior { item, edges }))
1871    }
1872
1873    /// Hand one item to the destination's own write interface, recording how to take it
1874    /// back.
1875    // llmlint: ignore[suppressions_justified] A write is the item, where it is going, what
1876    // it is filed under, its edges, what was there before and the journal that records how
1877    // to put it back. Each is a distinct decision made by a different part of the copy, and
1878    // grouping them would only move the argument list to a constructor.
1879    #[allow(clippy::too_many_arguments)]
1880    async fn write(
1881        &self,
1882        destination: &ResolvedSource,
1883        item: &Planned,
1884        target: Option<NativeId>,
1885        project: Option<NativeId>,
1886        edges: &[DependencyEdge],
1887        prior: Option<Prior>,
1888        journal: &mut Journal,
1889    ) -> Result<NativeId, EngineError> {
1890        let created_kind = item.item.level();
1891        let suggested = target.clone().unwrap_or_else(|| item.item.id().clone());
1892        // Settled before the journal takes `prior`, and from that same read: what the
1893        // destination holds at the origin key is what a copy-back leaves there.
1894        let origin = recorded(item, prior.as_ref());
1895        // Recorded *before* the write rather than after it. A destination's own write is
1896        // several calls — `docs/plugin-protocol.md` §4.9 — and one of them failing leaves
1897        // the ones before it applied. No source can put those back, because only this
1898        // journal holds what was there; recorded after a successful write, an update that
1899        // stopped part way was the one way a copy could end and leave the destination
1900        // altered. A restore of an item the write never reached rewrites what is already
1901        // there, which costs one mutation and is what "either complete or it never
1902        // happened" is worth.
1903        if let (Some(id), Some(prior)) = (target.clone(), prior) {
1904            journal.record(Undo::Updated { id, prior });
1905        }
1906        let landed = match outgoing(item, suggested, project, &origin) {
1907            Item::Task(task) => destination
1908                .source()
1909                .write_task(&ItemWrite {
1910                    target: target.clone(),
1911                    item: *task,
1912                    depends_on: edges.to_vec(),
1913                })
1914                .await
1915                .map_err(|error| refused(destination, error))?,
1916            Item::Project(project) => destination
1917                .source()
1918                .write_project(&ItemWrite {
1919                    target: target.clone(),
1920                    item: *project,
1921                    depends_on: edges.to_vec(),
1922                })
1923                .await
1924                .map_err(|error| refused(destination, error))?,
1925            // No edges, and that is the contract: a document takes part in no dependency
1926            // graph, so there is nothing here for `depends_on` to carry.
1927            Item::Document(document) => destination
1928                .source()
1929                .write_document(&ItemWrite {
1930                    target: target.clone(),
1931                    item: *document,
1932                    depends_on: Vec::new(),
1933                })
1934                .await
1935                .map_err(|error| refused(destination, error))?,
1936        };
1937        // A created item can only be journalled here: its id is what the write answers
1938        // with. A create that fails leaves nothing behind — §4.9 makes taking the item
1939        // back the source's own duty, because a write that refused must not leave an item
1940        // nobody asked for.
1941        if target.is_none() {
1942            journal.record(Undo::Created {
1943                kind: created_kind,
1944                id: landed.clone(),
1945            });
1946        }
1947        Ok(landed)
1948    }
1949
1950    /// A configured source that built, for reading an item out of.
1951    fn readable(&self, name: &SourceName) -> Result<&ResolvedSource, EngineError> {
1952        let name = self.known(name)?;
1953        if let Some(unavailable) = self.unavailable().find(|source| source.name() == &name) {
1954            return Err(EngineError::SourceRefused {
1955                name: name.to_string(),
1956                error: unavailable.error().clone(),
1957            });
1958        }
1959        self.ready()
1960            .find(|source| source.name() == &name)
1961            .ok_or(EngineError::NoSources)
1962    }
1963}
1964
1965/// How many tasks of a project are read at once while walking it.
1966const PROJECT_PAGE: std::num::NonZeroU32 = std::num::NonZeroU32::new(50).expect("50 is not zero");
1967
1968/// One page request against `source`, at the largest page it will serve.
1969fn request_for(source: &ResolvedSource, cursor: Option<Cursor>) -> PageRequest {
1970    PageRequest {
1971        cursor,
1972        limit: source.source().capabilities().max_page_size.max(1),
1973    }
1974}
1975
1976/// Whether writing this item would change what the destination already holds.
1977///
1978/// A free function over the state already read rather than a method that reads it again:
1979/// the same answer is wanted where the item is landed and where a repeat copy of a project
1980/// decides whether it settled, and a second read there is a second round trip for nothing.
1981fn changes(
1982    held: Option<&Prior>,
1983    item: &Planned,
1984    target: &NativeId,
1985    project: &Option<NativeId>,
1986    edges: &[DependencyEdge],
1987) -> bool {
1988    let Some(held) = held else {
1989        return true;
1990    };
1991    let outgoing = outgoing(
1992        item,
1993        target.clone(),
1994        project.clone(),
1995        &recorded(item, Some(held)),
1996    );
1997    !same(&held.item, &outgoing) || !same_edges(&held.edges, edges)
1998}
1999
2000/// Remove one item this copy created, through the destination's own write interface.
2001async fn remove(
2002    destination: &ResolvedSource,
2003    kind: Level,
2004    id: &NativeId,
2005) -> Result<(), SourceError> {
2006    match kind {
2007        Level::Task => destination.source().delete_task(id).await,
2008        Level::Project => destination.source().delete_project(id).await,
2009        Level::Document => destination.source().delete_document(id).await,
2010    }
2011}
2012
2013/// Write one item back exactly as the destination held it before this copy.
2014async fn restore(
2015    destination: &ResolvedSource,
2016    id: &NativeId,
2017    prior: &Prior,
2018) -> Result<(), SourceError> {
2019    match &prior.item {
2020        Item::Task(task) => destination
2021            .source()
2022            .write_task(&ItemWrite {
2023                target: Some(id.clone()),
2024                item: (**task).clone(),
2025                depends_on: prior.edges.clone(),
2026            })
2027            .await
2028            .map(|_| ()),
2029        Item::Project(project) => destination
2030            .source()
2031            .write_project(&ItemWrite {
2032                target: Some(id.clone()),
2033                item: (**project).clone(),
2034                depends_on: prior.edges.clone(),
2035            })
2036            .await
2037            .map(|_| ()),
2038        Item::Document(document) => destination
2039            .source()
2040            .write_document(&ItemWrite {
2041                target: Some(id.clone()),
2042                item: (**document).clone(),
2043                depends_on: Vec::new(),
2044            })
2045            .await
2046            .map(|_| ()),
2047    }
2048}
2049
2050/// Refuse a document copy addressed to a source that declares it has none.
2051///
2052/// Read off the declaration rather than by asking, which is what "not asked" means: the
2053/// engine learned at the handshake that this source holds no documents, so it refuses
2054/// naming the source and its plugin instead of sending a read that would be refused there.
2055/// Applied at both ends of a copy — a source with no documents holds nothing to copy out,
2056/// and a destination with none has nowhere to put one.
2057fn documentary(source: &ResolvedSource) -> Result<(), EngineError> {
2058    if source.source().capabilities().documents.is_native() {
2059        return Ok(());
2060    }
2061    Err(EngineError::NoDocuments {
2062        name: source.name().to_string(),
2063        kind: source.kind().to_owned(),
2064    })
2065}
2066
2067/// One source failing while a copy was mid-flight.
2068fn refused(source: &ResolvedSource, error: SourceError) -> EngineError {
2069    EngineError::SourceRefused {
2070        name: source.name().to_string(),
2071        error,
2072    }
2073}
2074
2075/// Every forward edge at one item, walked to exhaustion one page at a time.
2076async fn forward_edges(
2077    source: &ResolvedSource,
2078    id: &NativeId,
2079    kind: Level,
2080) -> Result<Vec<DependencyEdge>, EngineError> {
2081    // A document has no edges to walk, and asking for them would mean asking a source for
2082    // a graph the contract says nothing may point into.
2083    if kind == Level::Document {
2084        return Ok(Vec::new());
2085    }
2086    let mut edges = Vec::new();
2087    let mut cursor: Option<Cursor> = None;
2088    loop {
2089        let asked = cursor.clone();
2090        let request = request_for(source, cursor);
2091        let page = match kind {
2092            Level::Task | Level::Document => {
2093                source
2094                    .source()
2095                    .task_dependencies(id, Direction::DependsOn, &request)
2096                    .await
2097            }
2098            Level::Project => {
2099                source
2100                    .source()
2101                    .project_dependencies(id, Direction::DependsOn, &request)
2102                    .await
2103            }
2104        }
2105        .map_err(|error| refused(source, error))?;
2106        fits(page.items.len(), request.limit).map_err(|error| refused(source, error))?;
2107        edges.extend(page.items);
2108        unrepeated(
2109            page.next.as_ref(),
2110            asked.as_ref(),
2111            "an item's dependencies were being read for a copy",
2112        )
2113        .map_err(|error| refused(source, error))?;
2114        match page.next {
2115            Some(next) => cursor = Some(next),
2116            None => return Ok(edges),
2117        }
2118    }
2119}
2120
2121/// The location string one record reports, when it reports a usable one.
2122///
2123/// Either variant's own `String`, and `None` for a record the source gave no location for
2124/// or gave an empty string for: there is nothing to look for in a document's content and
2125/// nothing to point a reader at.
2126fn located(location: Option<&Location>) -> Option<String> {
2127    let (Location::Path(held) | Location::Url(held)) = location?;
2128    (!held.is_empty()).then(|| held.clone())
2129}
2130
2131/// Record one candidate referent, when its source said where it is.
2132fn note(
2133    into: &mut Vec<Referent>,
2134    id: GlobalId,
2135    level: Level,
2136    location: Option<&Location>,
2137    metadata: &BTreeMap<String, Value>,
2138) {
2139    if let Some(location) = located(location) {
2140        into.push(Referent {
2141            id,
2142            origin: origin_of(metadata),
2143            level,
2144            location,
2145        });
2146    }
2147}
2148
2149/// What every location string this document names becomes, longest first.
2150///
2151/// Longest first because a shorter location may start where a longer one does — a
2152/// project's directory and a task's file under it — and the longer of the two is the
2153/// record that occurrence names.
2154fn table_for(referents: &[Referent], counterparts: &Counterparts) -> Vec<(String, Resolution)> {
2155    let mut table: Vec<(String, Resolution)> = Vec::new();
2156    for referent in referents {
2157        // Two referents reporting one location string: an occurrence of it cannot be
2158        // attributed to either, and a rewrite would be *confidently wrong* rather than
2159        // merely unhelpful. So neither is chosen and both occurrences are counted.
2160        if let Some(held) = table
2161            .iter_mut()
2162            .find(|(location, _)| location == &referent.location)
2163        {
2164            held.1 = Resolution::Ambiguous;
2165            continue;
2166        }
2167        table.push((referent.location.clone(), counterparts.resolve(referent)));
2168    }
2169    table.sort_by_key(|(location, _)| std::cmp::Reverse(location.len()));
2170    table
2171}
2172
2173/// Whether `content` holds `location` at least once, stopped on both sides.
2174fn holds(content: &str, location: &str) -> bool {
2175    (0..content.len()).any(|at| delimited_at(content, at, location))
2176}
2177
2178/// Whether `location` occurs at `at` **stopped on both sides** — by
2179/// [`stops_a_location`], or by the end of the content — rather than as part of a longer
2180/// location-like string.
2181///
2182/// A location string occurring inside a longer one is a different string naming a
2183/// different record: `/…/tasks/p/t.md` must not be rewritten inside `/…/tasks/p/t.md.bak`,
2184/// `https://example.invalid/1` must not be rewritten inside `https://example.invalid/12`,
2185/// and a project's location that is a directory prefix of a task's must not be rewritten
2186/// inside that task's. What deciding it this way costs is stated on [`stops_a_location`].
2187fn delimited_at(content: &str, at: usize, location: &str) -> bool {
2188    if !content.is_char_boundary(at) || !content[at..].starts_with(location) {
2189        return false;
2190    }
2191    let before = content[..at].chars().next_back();
2192    let after = content[at + location.len()..].chars().next();
2193    stops_a_location(before) && stops_a_location(after)
2194}
2195
2196/// Whether a character cannot continue a path or a link, so a location string beside one
2197/// ends there.
2198///
2199/// Stated as what *stops* a location rather than as what one may contain, because the
2200/// second list is unbounded — a path may hold very nearly any byte, and a URL more. Every
2201/// character not named here continues, which is what leaves the three cases above alone;
2202/// the end of the content counts as a stop. The set is what the artifact this exists for
2203/// really wraps a bare path in — a backtick in a table cell — plus the delimiters prose
2204/// and Markdown put next to one.
2205///
2206/// **Sentence punctuation is deliberately absent, and that is a stated cost rather than an
2207/// oversight.** `.`, `!`, `?` and `:` each equally *continue* a real location — `/…/t.md`
2208/// and `/…/t.md.bak` are two files, `…/1` and `…/1?q=2` two pages — so admitting them as
2209/// stops would rewrite one record's location into another's. The price is that a location
2210/// written bare at the end of a sentence is not recognised at all: its text is left
2211/// byte-for-byte and it is counted in neither figure, exactly as a reference to another
2212/// project's record is. That is the direction to be wrong in, because this edits the
2213/// content of somebody's document, where a confidently wrong rewrite is worse than one
2214/// that never happens.
2215fn stops_a_location(character: Option<char>) -> bool {
2216    match character {
2217        None => true,
2218        Some(character) => character.is_whitespace() || "`\"'()[]{}<>|,;".contains(character),
2219    }
2220}
2221
2222/// One document's content with every whole reference rewritten, and what that took.
2223///
2224/// A location string with no confident counterpart is left **byte-for-byte** as it was
2225/// rather than removed or guessed at, and so is every character of the content that is not
2226/// a rewritten reference. Nothing is added and nothing is reformatted.
2227fn substitute(content: &str, table: &[(String, Resolution)]) -> (String, Counted) {
2228    let mut written = String::with_capacity(content.len());
2229    let mut counts = Counted::default();
2230    let mut at = 0;
2231    while at < content.len() {
2232        if let Some((location, resolution)) = table
2233            .iter()
2234            .find(|(location, _)| delimited_at(content, at, location))
2235        {
2236            match resolution {
2237                Resolution::Rewrite(there) => {
2238                    written.push_str(there);
2239                    counts.rewritten += 1;
2240                }
2241                // Both left-alone outcomes count as unresolved on the branch that counts
2242                // them, which is what really holds `ambiguous` at or below `unresolved`.
2243                Resolution::NoCounterpart => {
2244                    written.push_str(location);
2245                    counts.unresolved += 1;
2246                }
2247                Resolution::Ambiguous => {
2248                    written.push_str(location);
2249                    counts.unresolved += 1;
2250                    counts.ambiguous += 1;
2251                }
2252            }
2253            at += location.len();
2254            continue;
2255        }
2256        let character = content[at..]
2257            .chars()
2258            .next()
2259            .expect("a character at a boundary this walk only ever lands on");
2260        written.push(character);
2261        at += character.len_utf8();
2262    }
2263    (written, counts)
2264}
2265
2266/// The origin one item records, when it records a usable one.
2267fn origin_of(metadata: &BTreeMap<String, Value>) -> Option<GlobalId> {
2268    metadata
2269        .get(GlobalId::ORIGIN_KEY)?
2270        .as_str()?
2271        .parse::<GlobalId>()
2272        .ok()
2273}
2274
2275/// The title and metadata of either kind of item.
2276fn described(item: &Item) -> (&str, &BTreeMap<String, Value>) {
2277    match item {
2278        Item::Task(task) => (&task.title, &task.metadata),
2279        Item::Project(project) => (&project.title, &project.metadata),
2280        Item::Document(document) => (&document.title, &document.metadata),
2281    }
2282}
2283
2284/// The item as the destination should hold it.
2285///
2286/// `url`, `location`, `created_at` and `updated_at` are the destination's own and are
2287/// never written — where the *source* holds an item says nothing about where the
2288/// destination does, which is why a copied document does not arrive claiming the path or
2289/// the link its source reported. The two reserved keys this product encodes typed fields
2290/// under are removed, because those fields travel as themselves — leaving the encoding
2291/// beside them would have the destination hold one thing twice, and disagree with itself
2292/// the moment one changed.
2293fn outgoing(item: &Planned, id: NativeId, project: Option<NativeId>, origin: &Origin) -> Item {
2294    match &item.item {
2295        Item::Task(task) => Item::Task(Box::new(Task {
2296            id,
2297            url: None,
2298            location: None,
2299            created_at: None,
2300            updated_at: None,
2301            project,
2302            metadata: carried(&task.metadata, origin),
2303            ..(**task).clone()
2304        })),
2305        Item::Project(project) => Item::Project(Box::new(Project {
2306            id,
2307            url: None,
2308            location: None,
2309            created_at: None,
2310            updated_at: None,
2311            metadata: carried(&project.metadata, origin),
2312            ..(**project).clone()
2313        })),
2314        Item::Document(document) => Item::Document(Box::new(Document {
2315            id,
2316            url: None,
2317            location: None,
2318            created_at: None,
2319            updated_at: None,
2320            project,
2321            metadata: carried(&document.metadata, origin),
2322            ..(**document).clone()
2323        })),
2324    }
2325}
2326
2327/// The metadata a copy carries: the caller's own keys untouched, and the origin settled.
2328///
2329/// The key is removed before it is settled rather than overwritten, because the item being
2330/// copied carries an origin of its own and [`Origin::Keeps`] must not let it through.
2331fn carried(metadata: &BTreeMap<String, Value>, origin: &Origin) -> BTreeMap<String, Value> {
2332    let mut carried = metadata.clone();
2333    carried.remove(Repository::METADATA_KEY);
2334    carried.remove(DependencyEdge::RECORDED_KEY);
2335    carried.remove(GlobalId::ORIGIN_KEY);
2336    let held = match origin {
2337        Origin::Records(id) => Some(Value::String(id.to_string())),
2338        Origin::Keeps(held) => held.clone(),
2339    };
2340    if let Some(held) = held {
2341        carried.insert(GlobalId::ORIGIN_KEY.to_owned(), held);
2342    }
2343    carried
2344}
2345
2346/// What one landed item records at [`GlobalId::ORIGIN_KEY`].
2347enum Origin {
2348    /// The qualified id this item was copied from, as the id type rather than as its
2349    /// spelling: the key holds a [`GlobalId`] and nothing else may be recorded there.
2350    Records(GlobalId),
2351    /// Whatever the destination already holds there — `None` when it holds nothing, which
2352    /// is written as the key being absent rather than as a null.
2353    ///
2354    /// A [`Value`] and not a [`GlobalId`], because this variant does not interpret what it
2355    /// carries: it is the destination's own metadata entry, held for the length of one
2356    /// write and put back exactly as it was read. Parsing it would turn a value a
2357    /// destination holds and this engine cannot read into a value this engine deletes,
2358    /// which is the opposite of what keeping it means.
2359    Keeps(Option<Value>),
2360}
2361
2362/// Which of the two a copy of this item does.
2363///
2364/// A copy that reached its target by rule 1 is a copy-back: the item being copied names
2365/// the destination item, so the destination is the *original* and the id being copied
2366/// belongs to the copy that came out of it. Recording that id there would overwrite the
2367/// original's own provenance — and with it the correspondence every later copy from the
2368/// source it was authored in depends on. That copy would then match nothing and create a
2369/// second item beside the one it meant to update, which is the whole failure: nothing is
2370/// reported, and whoever reads that board now has two. So a copy-back leaves the
2371/// destination's origin exactly as the destination holds it, absent included, and every
2372/// other copy records the id it was copied from.
2373fn recorded(item: &Planned, held: Option<&Prior>) -> Origin {
2374    if let Target::Update {
2375        found: Found::Origin,
2376        ..
2377    } = &item.target
2378    {
2379        return Origin::Keeps(
2380            held.and_then(|held| described(&held.item).1.get(GlobalId::ORIGIN_KEY).cloned()),
2381        );
2382    }
2383    Origin::Records(item.source.clone())
2384}
2385
2386/// Whether the destination already reads exactly as this copy would leave it.
2387///
2388/// The destination's own `url` and timestamps are excluded because a copy never writes
2389/// them, so a difference there is not one this copy would close.
2390fn same(held: &Item, outgoing: &Item) -> bool {
2391    match (held, outgoing) {
2392        (Item::Task(held), Item::Task(outgoing)) => {
2393            held.title == outgoing.title
2394                && held.content == outgoing.content
2395                && held.status == outgoing.status
2396                && held.labels == outgoing.labels
2397                && held.project == outgoing.project
2398                && held.metadata == outgoing.metadata
2399                && held.repositories == outgoing.repositories
2400        }
2401        (Item::Project(held), Item::Project(outgoing)) => {
2402            held.title == outgoing.title
2403                && held.content == outgoing.content
2404                && held.status == outgoing.status
2405                && held.labels == outgoing.labels
2406                && held.metadata == outgoing.metadata
2407                && held.repositories == outgoing.repositories
2408        }
2409        // No status, because a document has none; no edges, because it is in no graph.
2410        (Item::Document(held), Item::Document(outgoing)) => {
2411            held.title == outgoing.title
2412                && held.content == outgoing.content
2413                && held.labels == outgoing.labels
2414                && held.project == outgoing.project
2415                && held.metadata == outgoing.metadata
2416                && held.repositories == outgoing.repositories
2417        }
2418        _ => false,
2419    }
2420}
2421
2422/// Whether the destination's forward edges already say what this copy would write.
2423fn same_edges(held: &[DependencyEdge], outgoing: &[DependencyEdge]) -> bool {
2424    let ends = |edges: &[DependencyEdge]| {
2425        let mut ends: Vec<(String, ItemKind, DependencyKind)> = edges
2426            .iter()
2427            .map(|edge| (edge.to.id().to_owned(), edge.to.kind, edge.kind))
2428            .collect();
2429        ends.sort_by(|left, right| left.0.cmp(&right.0));
2430        ends
2431    };
2432    ends(held) == ends(outgoing)
2433}
2434
2435/// Each read edge as the destination should record it, or `None` when its far end is a
2436/// member of this copy whose destination id is not known yet.
2437fn mapped_edges(
2438    edges: &[DependencyEdge],
2439    origin: &SourceName,
2440    destination: &ResolvedSource,
2441    copied: &[GlobalId],
2442    written: &BTreeMap<String, NativeId>,
2443) -> Vec<Option<DependencyEdge>> {
2444    edges
2445        .iter()
2446        .map(|edge| {
2447            let far = GlobalId::new(origin.clone(), NativeId(edge.to.id().to_owned()));
2448            let id = if let Some(native) = names(&edge.to, destination.name()) {
2449                // A far end already qualified to the destination's own source is that
2450                // source's own item, so it is written the way that source names its own:
2451                // unqualified. Leaving it qualified would have the destination hold an
2452                // edge into itself written as if it left, which is the one spelling the
2453                // reserved key exists to keep for edges that really do.
2454                Some(native)
2455            } else if edge.to.is_qualified() || origin == destination.name() {
2456                // Already naming a source of its own, or a copy inside one source where
2457                // the far end's own id is the destination's id.
2458                Some(edge.to.id().to_owned())
2459            } else if copied.contains(&far) {
2460                written.get(&far.to_string()).map(|native| native.0.clone())
2461            } else {
2462                Some(far.to_string())
2463            }?;
2464            DependencyEndpoint::new(id, edge.to.kind)
2465                .ok()
2466                .map(|to| DependencyEdge {
2467                    from: edge.from.clone(),
2468                    to,
2469                    kind: edge.kind,
2470                })
2471        })
2472        .collect()
2473}
2474
2475/// The native id a qualified endpoint names at `destination`, when it names one there.
2476fn names(endpoint: &DependencyEndpoint, destination: &SourceName) -> Option<String> {
2477    if !endpoint.is_qualified() {
2478        return None;
2479    }
2480    let id: GlobalId = endpoint.id().parse().ok()?;
2481    (&id.source == destination).then_some(id.native.0)
2482}
2483
2484/// The edges that could be resolved, which is every one of them on the second pass.
2485fn resolved(edges: &[Option<DependencyEdge>]) -> Vec<DependencyEdge> {
2486    edges.iter().flatten().cloned().collect()
2487}
2488
2489/// Whether the destination holds an item with this id.
2490async fn exists(
2491    destination: &ResolvedSource,
2492    id: &NativeId,
2493    kind: Level,
2494) -> Result<bool, EngineError> {
2495    let found = match kind {
2496        Level::Task => destination
2497            .source()
2498            .get_task(id)
2499            .await
2500            .map_err(|error| refused(destination, error))?
2501            .is_some(),
2502        Level::Project => destination
2503            .source()
2504            .get_project(id)
2505            .await
2506            .map_err(|error| refused(destination, error))?
2507            .is_some(),
2508        Level::Document => destination
2509            .source()
2510            .get_document(id)
2511            .await
2512            .map_err(|error| refused(destination, error))?
2513            .is_some(),
2514    };
2515    Ok(found)
2516}