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 items 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//! item it was copied from carries [`MetadataKey::COPIES_KEY`], an object naming its
7//! counterpart at each destination it was copied into. The rules below read exactly those —
8//! so nothing here is written down outside the plugin that owns the item, and the invariant
9//! this engine is built around is untouched.
10//!
11//! 0. **Follow the link.** An item whose `onetaskgraph.copies` names an item at the
12//! destination has that item read by id, and when that item's own origin names this one
13//! back it is the target: one read, and no scan. A link naming nothing there any more is
14//! refused as `stale-link` unless `--recreate` is given, for the reason a stale origin
15//! is; a link naming an item that no longer names this one back — somebody re-pointed it
16//! — is ignored, and the rules below decide.
17//! 1. **Follow the origin.** An item already carrying an origin whose source half is the
18//! destination names the destination item *directly*, and the copy updates it. This is
19//! the half that makes an edit's copy-back an update: the local file came from the
20//! remote item and knows which one.
21//! 2. **Search by origin.** Otherwise the destination is scanned, one page at a time, for
22//! an item whose origin is the id being copied. Found, the copy updates it; not found,
23//! the copy creates one carrying that origin.
24//!
25//! Which rule found the item decides what the copy records there. A copy that got its
26//! target from rule 1 is a copy-back — the destination is the *original*, and the item
27//! being copied is the one that came from it — so the destination keeps the origin it
28//! already holds, holding none included. Every other copy records the id it was copied
29//! from. See [`recorded`] for what stamping a copy-back's own id there costs.
30//!
31//! The same distinction decides the link. Once the whole copy has landed, every item whose
32//! target was found by any rule but rule 1 — or was created — has its link for this
33//! destination recorded or refreshed through its source's narrow metadata write, and that
34//! write joins the undo journal like every destination write. Rule 1 records nothing: that
35//! correspondence is already written down, on the destination item.
36//!
37//! A destination write is at the user's explicit request, names its destination, goes
38//! through that source's own write interface into that source's own store, and is never
39//! read back to answer a query. That is what makes it a write and not a cache.
40
41use std::collections::{BTreeMap, BTreeSet};
42
43use onetaskgraph_plugin_api::{
44 Capabilities, Cursor, DependencyEdge, DependencyEndpoint, DependencyKind, Direction, Document,
45 DocumentQuery, ItemKind, ItemWrite, Location, MetadataKey, MetadataMatch, Metering, NativeId,
46 Page, PageRequest, Project, ProjectQuery, Repository, SourceError, SourceName, StatusCategory,
47 Task, TaskQuery, TaskRef, TextFields, TextQuery,
48};
49use schemars::JsonSchema;
50use serde::{Deserialize, Serialize};
51use serde_json::Value;
52
53use crate::GlobalId;
54use crate::config::Placement;
55use crate::resolve::ResolvedSource;
56use crate::template::{Sha256Digest, TemplateProvenance, body_digest};
57
58use super::delivery::{Delivered, targets};
59use super::fetch::{fits, unrepeated};
60use super::local::ProjectSelector;
61use super::narrow::holds_priority;
62use super::{
63 DocumentFilters, DocumentRequest, Engine, EngineError, Filters, LeftBehind, Paging, Qualified,
64 TaskRequest,
65};
66
67/// A request to copy work into one configured destination.
68#[derive(Debug, Clone)]
69pub struct CopyRequest {
70 /// The qualified items to copy, in the order they were named.
71 pub items: CopyItems,
72 /// What those ids name, and what comes with them.
73 pub scope: CopyScope,
74 /// The configured source to copy into — a source name, never a qualified id.
75 pub destination: SourceName,
76 /// How to re-establish a correspondence the two origin rules cannot find.
77 pub match_by: Option<MatchBy>,
78 /// Whether an origin naming nothing at the destination falls through to the search
79 /// rule instead of refusing.
80 pub recreate: bool,
81 /// Whether the caller asserts the destination holds no counterpart of any item named,
82 /// so each is created there without the correspondence lookup — no origin search of the
83 /// destination at all.
84 ///
85 /// Sound for a caller that has just asked the destination itself: the origin query is
86 /// an index that lags a write, so repeating it here would be behind by exactly as much as
87 /// the caller's own was and could find nothing the caller's did not. Refused beside
88 /// [`match_by`](Self::match_by) and [`recreate`](Self::recreate), which are ways of
89 /// looking, and for an item that itself records a counterpart at the destination — a
90 /// link or an origin naming it — which is a carrier the caller's assertion overlooked.
91 // llmlint: ignore-block[invalid_states_unrepresentable] One enum of the ways a copy finds its target would make `create` beside `match_by` or `recreate` unrepresentable, but only by replacing `match_by` and `recreate`, which every Rust caller of this request sets by name and the command line maps flag for flag — rewriting what each existing caller writes. `create` is one more field beside them, as `recreate` and `dry_run` were, and the one combination it makes possible that means nothing is refused by `Engine::copy`, as `EngineError::CreateWith`, before anything is read.
92 pub create: bool,
93 // llmlint: ignore-end[invalid_states_unrepresentable]
94 /// Whether to perform every read and no write.
95 pub dry_run: bool,
96}
97
98/// A way of looking for a counterpart that [`CopyRequest::create`] says not to take, named as
99/// the command line spells it.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum CopyLookup {
102 /// [`CopyRequest::match_by`], `--match-by`.
103 MatchBy,
104 /// [`CopyRequest::recreate`], `--recreate`.
105 Recreate,
106}
107
108impl std::fmt::Display for CopyLookup {
109 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 formatter.write_str(match self {
111 Self::MatchBy => "--match-by",
112 Self::Recreate => "--recreate",
113 })
114 }
115}
116
117/// The items one copy names: at least one, because a copy naming none is not a copy.
118///
119/// A newtype rather than a bare `Vec`, for the reason [`Repository`] is one: the empty
120/// list is not a copy of nothing, it is a caller mistake, and a type that can hold it
121/// leaves every reader to decide what it means — a report with no entries, an error, a
122/// silent success. None of those is better than not being able to say it.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct CopyItems(Vec<GlobalId>);
125
126impl CopyItems {
127 /// The items a caller named, or `None` when they named none.
128 #[must_use]
129 pub fn new(items: Vec<GlobalId>) -> Option<Self> {
130 (!items.is_empty()).then_some(Self(items))
131 }
132
133 /// The items, in the order they were named.
134 #[must_use]
135 pub fn as_slice(&self) -> &[GlobalId] {
136 &self.0
137 }
138}
139
140/// What the ids a copy names are, and what travels with them.
141///
142/// One value rather than a kind beside a flag, because three of the four combinations
143/// those two would make are real and the fourth — tasks, with the tasks of each also
144/// copied — means nothing. The member list is a variant for the same reason: it narrows a
145/// project copy that carries its tasks, and it has nothing to say to the other three.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum CopyScope {
148 /// The ids name tasks, and only those tasks are copied.
149 Tasks,
150 /// The ids name projects.
151 Projects {
152 /// Whether the tasks in each project are copied too.
153 tasks: bool,
154 },
155 /// The ids name projects, and of the tasks in them exactly these are copied.
156 ///
157 /// A member the list does not name is not read at the destination, not compared, not
158 /// written and not reported, and no walk for what the copy left behind runs. A named
159 /// member with no counterpart there is still created: the list narrows which members
160 /// are read and written, never which outcomes are possible.
161 ///
162 /// An edge from a copied item to a member the list does not name resolves to the
163 /// destination id that member's own [`GlobalId::ORIGIN_KEY`] records at the source,
164 /// without reading the destination for it — or, for a copy into a source with `routes`,
165 /// the id its own `onetaskgraph.copies` link records in any source the copy reaches.
166 /// When that member records neither, the copy is refused before anything is written.
167 Members(CopyItems),
168 /// The ids name documents, and only those documents are copied.
169 ///
170 /// Nothing travels with a document: it takes part in no dependency graph, and it holds
171 /// nothing of its own the way a project holds tasks.
172 Documents,
173}
174
175/// The caller-named escape for a correspondence neither origin rule can find.
176///
177/// A person editing Markdown who deletes or corrupts the origin key leaves an item rule 1
178/// cannot use and rule 2 cannot find, and the next copy would create a second item. This
179/// is how that is re-established without hand-editing ids.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum MatchBy {
182 /// Match the item whose title is the same.
183 Title,
184 /// Match the item whose value at this metadata key is the same.
185 Metadata(String),
186}
187
188impl MatchBy {
189 /// The spelling a caller types, `title` or any metadata key.
190 #[must_use]
191 pub fn parse(key: &str) -> Self {
192 if key == "title" {
193 Self::Title
194 } else {
195 Self::Metadata(key.to_owned())
196 }
197 }
198}
199
200/// What a copy did, one entry per item.
201///
202/// The same per-item outcomes reach every consumer: the machine-readable output renders
203/// this, the rendered output renders this, and a Rust caller is handed it.
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
205pub struct CopyReport {
206 /// One entry per item the copy considered, in the order it considered them.
207 pub items: Vec<CopyOutcome>,
208 // Three flat scalars rather than one nested object, and a `!skip_serializing_if` beside
209 // every skip: both are load-bearing for the generated SDKs rather than matters of
210 // taste, and AGENTS.md's note on what a copied document's references are pointed at is
211 // where that reasoning lives.
212 /// Reference occurrences the copy rewrote to the destination's own location for the
213 /// record they name.
214 ///
215 /// A silent bound is indistinguishable from a bug, so the copy says what it did to the
216 /// references the documents it carried hold. This and the two below are totals over the
217 /// whole invocation rather than figures per document, and all three default to zero, so
218 /// a consumer written against the output before they existed is unaffected.
219 ///
220 /// **What these figures do not claim.** The referent set is a document's own project,
221 /// so a reference to a record in a *different* project is never recognised at all and
222 /// cannot appear in [`Self::references_unresolved`] either. These are the references
223 /// the copy recognised; they are not a census of every reference a document holds.
224 /// Noticing an out-of-scope reference would need exactly the unbounded destination walk
225 /// this design refuses.
226 #[serde(default, skip_serializing_if = "nothing_to_report")]
227 #[schemars(!skip_serializing_if)]
228 pub references_rewritten: u64,
229 /// Reference occurrences the copy recognised and left byte-for-byte as they were,
230 /// because the correspondence could not be established.
231 #[serde(default, skip_serializing_if = "nothing_to_report")]
232 #[schemars(!skip_serializing_if)]
233 pub references_unresolved: u64,
234 /// How many of [`Self::references_unresolved`] were left alone because the
235 /// correspondence was **ambiguous** rather than merely absent. A sub-count, never
236 /// larger than it.
237 ///
238 /// Split out because the two mean different things to a reader. A reference with no
239 /// counterpart is ordinary and expected under the bound above — the design working. An
240 /// ambiguous one says the destination holds duplicate records for one work item, or the
241 /// source reports one location for two records, and re-running the copy will never
242 /// clear it.
243 #[serde(default, skip_serializing_if = "nothing_to_report")]
244 #[schemars(!skip_serializing_if)]
245 // llmlint: ignore[invalid_states_unrepresentable] JSON Schema cannot express an
246 // inequality between two numbers, so a private constructor here would hold this in one
247 // consumer of three while both SDKs' generated models went on admitting it. What holds
248 // it is `substitute`: `Resolution` has no variant that counts an occurrence ambiguous
249 // without counting it unresolved.
250 pub references_ambiguous: u64,
251 /// `delivers` entries the copy rewrote to the destination's own id for a member of the
252 /// copied set, over the whole invocation. Every other entry arrives qualified and is not
253 /// counted. Left out when zero, as the three figures above are.
254 #[serde(default, skip_serializing_if = "nothing_to_report")]
255 #[schemars(!skip_serializing_if)]
256 pub delivers_rewritten: u64,
257 /// One entry per delivered task the copy kept in step with a task it landed, after the
258 /// whole copy was complete — see `task status set`, which reports the same entries. Left
259 /// out when there were none.
260 ///
261 /// A failed entry does not undo the copy: the tasks it landed stay landed, and the
262 /// command exits `4`.
263 #[serde(default, skip_serializing_if = "Vec::is_empty")]
264 #[schemars(!skip_serializing_if)]
265 pub delivered: Vec<Delivered>,
266 /// What this copy spent, summed over the sources in the command that meter their own
267 /// requests — and absent, never zero, when none of them does.
268 ///
269 /// Omitted from the wire when absent, like the three figures above, so a consumer
270 /// written against the output before it existed reads the same document.
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub spent: Option<Spent>,
273}
274
275/// Whether one of [`CopyReport`]'s reference figures has anything to say.
276///
277/// A copy that recognised no reference reports that by leaving the figure out rather than
278/// by writing a nought, so the machine output of a task or project copy is exactly what it
279/// was before these figures existed. The human rendering says it in words either way,
280/// because a reader there needs to be told the copy looked.
281fn nothing_to_report(figure: &u64) -> bool {
282 *figure == 0
283}
284
285/// What one command spent, summed over the sources in it that meter their own requests.
286///
287/// **Source-owned.** Every figure is what a source said it sent and spent while the command
288/// ran, read through [`TaskSource::metering`](onetaskgraph_plugin_api::TaskSource::metering)
289/// before the command and again after it. The engine adds the differences up by name and
290/// interprets none of them, which is why the budget and unit names are open vocabulary.
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
292pub struct Spent {
293 /// How many HTTP requests those sources sent for this command.
294 pub requests: u64,
295 /// What those requests spent, one entry per budget and unit, ordered by budget name.
296 pub budgets: Vec<BudgetSpent>,
297}
298
299/// What one command spent against one budget.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
301pub struct BudgetSpent {
302 /// The budget, as the source names it — `graphql`, `rest`.
303 // llmlint: ignore[invalid_states_unrepresentable] The contract this report lands states `budget` and `unit` as strings in an open vocabulary the engine interprets none of, and both SDKs are generated from this type's schema; the one value such a vocabulary can refuse, the empty name, never reaches here, because `difference` below treats a source reporting one as not metering.
304 pub budget: String,
305 /// What it is metered in — `points`, `requests`.
306 // llmlint: ignore[invalid_states_unrepresentable] As `budget` above.
307 pub unit: String,
308 /// How much was spent against it, in that unit.
309 pub amount: u64,
310 /// Whether any part of `amount` was modelled by a source rather than reported by its
311 /// backend or counted, which makes `amount` a lower bound on what the backend charged
312 /// rather than a measurement of it.
313 pub lower_bound: bool,
314}
315
316/// One document's reference figures, before they are folded into the invocation's.
317#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
318struct Counted {
319 /// Occurrences rewritten.
320 rewritten: u64,
321 /// Occurrences recognised and left alone.
322 unresolved: u64,
323 /// How many of those were ambiguous.
324 ambiguous: u64,
325}
326
327impl Counted {
328 /// Fold one document's figures into the invocation's.
329 fn add(&mut self, other: Self) {
330 self.rewritten += other.rewritten;
331 self.unresolved += other.unresolved;
332 self.ambiguous += other.ambiguous;
333 }
334}
335
336/// What happened to one item.
337///
338/// `action` and `destination` are one value rather than two fields side by side: an
339/// updated item without a destination id, or an orphan without one, are states this type
340/// must not be able to say — the id *is* what those outcomes are about. The one outcome
341/// that legitimately has none is a dry run that would create, because nothing was
342/// created and there is no id to report.
343#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
344pub struct CopyOutcome {
345 /// The qualified id the item was read from.
346 pub source: GlobalId,
347 /// What happened to it, and where.
348 #[serde(flatten)]
349 pub action: CopyAction,
350 /// Which source a routed copy placed it in, and the index of the route entry that
351 /// matched — `null` there when none did.
352 ///
353 /// Present for every item a copy into a source declaring `routes` landed, a dry run's
354 /// included, so a would-be create still says where it would go; absent for a copy into
355 /// a source with none, whose output is what it always was, and for an orphan, which was
356 /// not placed at all.
357 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub placed: Option<Placement>,
359}
360
361/// Which rule found the destination counterpart an item was updated at, or found already
362/// reading as it does.
363///
364/// Tried in this order, and the first that answers is the one reported: the link the item
365/// records, its own origin, a search of the destination for an item recording it as its
366/// origin, and the caller's `--match-by`. When none answers, the item is created and says
367/// so as [`NoCounterpart`] instead.
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
369#[serde(rename_all = "kebab-case")]
370pub enum CopyVia {
371 /// The item's `onetaskgraph.copies` entry for the destination named an item whose own
372 /// origin names this one back. Found by one read by id, and no scan.
373 Link,
374 /// The item's own `onetaskgraph.origin` named the destination item: a copy-back.
375 Origin,
376 /// A search of the destination for an item recording this one as its origin.
377 Scan,
378 /// A search of the destination by the caller's `--match-by`.
379 Match,
380}
381
382/// What a created item reports as its `via`: no rule found a counterpart.
383///
384/// One word, and a type of its own rather than a fifth [`CopyVia`], so an item updated at a
385/// counterpart cannot say none was found and a created one cannot name a rule that found
386/// one.
387#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
388#[serde(rename_all = "kebab-case")]
389pub enum NoCounterpart {
390 /// No counterpart was found, so one was created — or, in a dry run, would have been.
391 #[default]
392 Created,
393}
394
395/// What a copy did to the `onetaskgraph.copies` entry the copied item holds for the
396/// destination.
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
398#[serde(rename_all = "kebab-case")]
399pub enum CopyLink {
400 /// The entry was written, because the item held none for this destination or held one
401 /// naming another item.
402 Recorded,
403 /// Nothing was written: the entry already named this destination item, or the item was
404 /// found by its own origin, where the correspondence is already recorded on the other
405 /// side.
406 Unchanged,
407 /// The item's source cannot hold the entry — it has no write side, or it refuses the
408 /// narrow metadata write — so the copy landed without recording it.
409 Unrecorded,
410}
411
412impl CopyOutcome {
413 /// The qualified id this outcome landed on, when it landed on one.
414 #[must_use]
415 pub fn destination(&self) -> Option<&GlobalId> {
416 self.action.destination()
417 }
418}
419
420/// The four things a copy can do to one item, and the id each of them is about.
421#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
422#[serde(tag = "action", rename_all = "kebab-case")]
423pub enum CopyAction {
424 // llmlint: ignore[names_match_behavior] `created` is Contract D's serialized action
425 // for both a completed create and a dry run that would create; the optional destination
426 // distinguishes those cases, and renaming this public variant would break Rust callers.
427 /// The destination held no counterpart, so one was created.
428 Created {
429 /// The id it was created under, or `null` for a dry run that would have created
430 /// one — there is no id, because nothing was.
431 destination: Option<GlobalId>,
432 /// That no rule found a counterpart. The one word it can be, so a report written
433 /// before there was a `via` reads as saying it.
434 #[serde(default)]
435 via: NoCounterpart,
436 /// What the copy did to the link the copied item records for this destination at
437 /// `onetaskgraph.copies`; absent for a dry run, which writes nothing.
438 #[serde(default, skip_serializing_if = "Option::is_none")]
439 // llmlint: ignore[invalid_states_unrepresentable] Whether a copy is a dry run is a
440 // property of the request, which the report does not carry, and the contract this
441 // lands — shared with the follow-up tooling built against it — says a dry run leaves
442 // `link` out rather than naming a sixth word for it. What holds it is `Engine::link`:
443 // the one writer of this field, run for every item a copy that writes has landed and
444 // for none of a dry run's, which `a_dry_run_says_which_rule_would_answer_and_records_no_link`
445 // and every `link` assertion in tests/copy_link.rs drive.
446 link: Option<CopyLink>,
447 },
448 /// The destination held a counterpart and it now reads as the source does.
449 Updated {
450 /// The item that was updated.
451 destination: GlobalId,
452 /// Which rule found it.
453 via: CopyVia,
454 /// What the copy did to the copied item's link; absent for a dry run.
455 #[serde(default, skip_serializing_if = "Option::is_none")]
456 // llmlint: ignore[invalid_states_unrepresentable] As `link` on `Created` above.
457 link: Option<CopyLink>,
458 },
459 /// The destination held a counterpart that already read that way; nothing was written.
460 Unchanged {
461 /// The item that already said it.
462 destination: GlobalId,
463 /// Which rule found it.
464 via: CopyVia,
465 /// What the copy did to the copied item's link; absent for a dry run.
466 #[serde(default, skip_serializing_if = "Option::is_none")]
467 // llmlint: ignore[invalid_states_unrepresentable] As `link` on `Created` above.
468 link: Option<CopyLink>,
469 },
470 /// The destination holds a counterpart the source no longer does. A copy never
471 /// deletes, so it was left exactly as it is — and no rule was asked where it goes, and
472 /// no link was touched, because it was not copied.
473 Orphaned {
474 /// The item that was left alone.
475 destination: GlobalId,
476 },
477}
478
479impl CopyAction {
480 /// The qualified id this action is about, when there is one.
481 #[must_use]
482 pub fn destination(&self) -> Option<&GlobalId> {
483 match self {
484 Self::Created { destination, .. } => destination.as_ref(),
485 Self::Updated { destination, .. }
486 | Self::Unchanged { destination, .. }
487 | Self::Orphaned { destination } => Some(destination),
488 }
489 }
490
491 /// Which rule found the counterpart, or `None` when none did — the item was created —
492 /// and for an orphan, which no rule was asked about.
493 #[must_use]
494 pub fn found_by(&self) -> Option<CopyVia> {
495 match self {
496 Self::Updated { via, .. } | Self::Unchanged { via, .. } => Some(*via),
497 Self::Created { .. } | Self::Orphaned { .. } => None,
498 }
499 }
500
501 /// What became of the copied item's link, or `None` for a dry run and for an orphan.
502 #[must_use]
503 pub fn link(&self) -> Option<CopyLink> {
504 match self {
505 Self::Created { link, .. }
506 | Self::Updated { link, .. }
507 | Self::Unchanged { link, .. } => *link,
508 Self::Orphaned { .. } => None,
509 }
510 }
511
512 /// The link slot of an item this copy landed, `None` for an orphan.
513 fn link_slot(&mut self) -> Option<&mut Option<CopyLink>> {
514 match self {
515 Self::Created { link, .. }
516 | Self::Updated { link, .. }
517 | Self::Unchanged { link, .. } => Some(link),
518 Self::Orphaned { .. } => None,
519 }
520 }
521
522 /// The word this action serializes as, taken from its own `Serialize`.
523 ///
524 /// Read back off the wire form rather than written out again in a `match`, for the
525 /// reason `render::wire` gives: a second spelling of `unchanged` would be a second
526 /// place for it to drift from the one a caller reads.
527 #[must_use]
528 pub fn name(&self) -> String {
529 serde_json::to_value(self).expect("a contract enum serialises")["action"]
530 .as_str()
531 .expect("an internally tagged enum carries its tag")
532 .to_owned()
533 }
534}
535
536/// What one item points at, resolved as far as the copy has got: its forward edges and the
537/// tasks it delivers.
538struct Pointing<'a> {
539 /// Its forward edges, `None` where the far end is a member not landed yet.
540 edges: &'a [Option<DependencyEdge>],
541 /// The tasks it delivers that can already be named at the destination.
542 delivers: &'a [TaskRef],
543}
544
545/// Where one item is going at the destination.
546enum Target {
547 /// Update the destination item with this id, reached by the rule named.
548 Update {
549 /// The destination item this copy updates.
550 id: NativeId,
551 /// Which rule found it.
552 found: Found,
553 },
554 /// Create one.
555 Create,
556}
557
558/// Which of the rules above found the destination item a copy is updating.
559///
560/// Every one is the same instruction — update that item — and rule 1 is a different answer
561/// about the origin, which is why the distinction is carried this far rather than dropped
562/// where it is made. See [`recorded`]. It is also what the report's [`CopyVia`] says and
563/// what decides whether the copied item's link is written.
564#[derive(Clone, Copy, PartialEq, Eq)]
565enum Found {
566 /// The item's own link named it, and it names the item back.
567 Link,
568 /// Rule 1: the item being copied already named it, so this copy is a copy-back.
569 Origin,
570 /// Rule 2: the destination was searched for an item recording this one as its origin.
571 Scan,
572 /// The caller's matching escape.
573 Match,
574}
575
576impl Found {
577 /// The word the report says it in.
578 fn via(self) -> CopyVia {
579 match self {
580 Self::Link => CopyVia::Link,
581 Self::Origin => CopyVia::Origin,
582 Self::Scan => CopyVia::Scan,
583 Self::Match => CopyVia::Match,
584 }
585 }
586}
587
588impl Target {
589 /// Which rule found this target, or `None` for one this copy creates.
590 fn found(&self) -> Option<Found> {
591 match self {
592 Self::Update { found, .. } => Some(*found),
593 Self::Create => None,
594 }
595 }
596}
597
598/// What a scan of the destination is looking for.
599enum Wanted {
600 /// An item recording this qualified id as its origin.
601 Origin(String),
602 /// An item whose title is this.
603 Title(String),
604 /// An item holding this value at this metadata key.
605 Metadata(String, Value),
606}
607
608impl Wanted {
609 /// Whether one destination item is the one being looked for.
610 fn found(&self, title: &str, metadata: &BTreeMap<String, Value>) -> bool {
611 match self {
612 Self::Origin(id) => {
613 metadata.get(GlobalId::ORIGIN_KEY) == Some(&Value::String(id.clone()))
614 }
615 Self::Title(wanted) => title == wanted,
616 Self::Metadata(key, value) => metadata.get(key) == Some(value),
617 }
618 }
619
620 /// The task query that asks `capabilities`' source for this item rather than for all of
621 /// them, when that source applies the predicate itself.
622 ///
623 /// Only ever a narrowing the source declared it applies, so a source that cannot is
624 /// asked for everything exactly as before; and never the last word, because
625 /// [`found`](Self::found) still confirms each row. A text search is wider than an equal
626 /// title, and a source may answer a match it cannot yet see as absent — which is the
627 /// same answer the whole-store walk gave for an item its own listing was behind on.
628 ///
629 /// A title or a value with no letter or digit is never pushed down: a source whose search
630 /// indexes words cannot answer it and may refuse it, and the walk it replaces answered it
631 /// already, so it is asked for everything as it always was.
632 fn task_query(&self, capabilities: &Capabilities) -> TaskQuery {
633 match self {
634 Self::Origin(id) if capabilities.filter_by_origin.is_native() => TaskQuery {
635 origin: Some(id.clone()),
636 ..TaskQuery::default()
637 },
638 Self::Title(title) if capabilities.search_title.is_native() && has_words(title) => {
639 TaskQuery {
640 text: Some(TextQuery {
641 terms: title.clone(),
642 fields: TextFields::Title,
643 }),
644 ..TaskQuery::default()
645 }
646 }
647 Self::Metadata(key, Value::String(value))
648 if capabilities.filter_by_metadata.is_native() && has_words(value) =>
649 {
650 MetadataMatch::new(key.clone(), Vec::new(), value.clone())
651 .map(|wanted| TaskQuery {
652 metadata: vec![wanted],
653 ..TaskQuery::default()
654 })
655 .unwrap_or_default()
656 }
657 _ => TaskQuery::default(),
658 }
659 }
660}
661
662/// Whether `terms` holds a letter or a digit — a word a search that indexes words can find.
663fn has_words(terms: &str) -> bool {
664 terms.chars().any(char::is_alphanumeric)
665}
666
667/// What the destination held before this copy touched one item.
668///
669/// Read once, in [`Engine::land`], and used three times over: to decide whether the write
670/// would change anything, to repair the item's edges once the rest of the copy has landed,
671/// and — if the copy cannot finish — to put the item back exactly as it was.
672#[derive(Clone)]
673struct Prior {
674 /// The item as the destination held it.
675 item: Item,
676 /// Its forward edges there.
677 edges: Vec<DependencyEdge>,
678}
679
680/// One item that landed with an edge whose far end was not written yet.
681///
682/// Held until every item of the whole copy has landed, because the far end may be in
683/// another project of the same command: a copy of two projects at once is one copied set,
684/// not two, and an edge across them is remapped rather than written as a foreign id.
685struct Deferred {
686 /// The item, as it was read and resolved.
687 item: Planned,
688 /// The destination project it was filed under.
689 filed: Option<NativeId>,
690 /// Where it landed.
691 destination: NativeId,
692 /// What the destination held there before, when it held anything.
693 prior: Option<Prior>,
694}
695
696/// What one item's undo has to do to put the destination back.
697///
698/// Each entry names the source it was written to, because a routed copy writes into more
699/// than one, and it is undone in every one of them.
700enum Undo {
701 /// The copy created it, so undoing means removing it.
702 Created {
703 /// The source it was created in.
704 at: SourceName,
705 /// Which write interface removes it.
706 kind: Level,
707 /// The destination id it was created under.
708 id: NativeId,
709 },
710 /// The copy overwrote something, so undoing means writing that something back.
711 ///
712 /// No `kind` beside the id, unlike the variant above: what was there says which of the
713 /// two write interfaces takes it back, and a second spelling of that could disagree
714 /// with it.
715 Updated {
716 /// The source it was overwritten in.
717 at: SourceName,
718 /// The destination id that was overwritten.
719 id: NativeId,
720 /// What was there before.
721 prior: Prior,
722 },
723}
724
725impl Undo {
726 /// The source this entry was written to.
727 fn at(&self) -> &SourceName {
728 match self {
729 Self::Created { at, .. } | Self::Updated { at, .. } => at,
730 }
731 }
732
733 /// The destination id this entry is about.
734 fn id(&self) -> &NativeId {
735 match self {
736 Self::Created { id, .. } | Self::Updated { id, .. } => id,
737 }
738 }
739
740 /// Which of the destination's three write interfaces this entry belongs to.
741 ///
742 /// An id alone does not identify a destination item: nothing stops a destination
743 /// numbering its tasks and its projects in one namespace, and a local-Markdown store
744 /// filing `alpha.md` under both is the ordinary case rather than the contrived one.
745 /// So this pairs with `id` wherever one entry has to be told from another.
746 fn kind(&self) -> Level {
747 match self {
748 Self::Created { kind, .. } => *kind,
749 // Read off what was there, for the reason the variant carries no `kind` of
750 // its own: two spellings of one fact can disagree, and this one cannot.
751 Self::Updated { prior, .. } => prior.item.level(),
752 }
753 }
754}
755
756/// Everything one copy has written, in the order it wrote it, so a copy that cannot finish
757/// can undo its own writes.
758///
759/// This is not state the engine keeps: it lives for the length of one `copy` call and is
760/// dropped with it, so the invariant that nothing of a user's work is written down outside
761/// the plugin that owns it is untouched.
762#[derive(Default)]
763struct Journal {
764 /// One entry per destination item this copy first touched, in that order.
765 entries: Vec<Undo>,
766 /// One entry per copied item whose `onetaskgraph.copies` link this copy wrote, in that
767 /// order.
768 ///
769 /// Kept apart from `entries` because these are the *sources'* items rather than the
770 /// destination's, and an id alone does not say which store it is in: a copy inside one
771 /// source can write a link on an item and land on another item with the same kind.
772 links: Vec<Unlink>,
773}
774
775/// How to take back one link this copy wrote on a copied item.
776struct Unlink {
777 /// The item, at its own source.
778 item: GlobalId,
779 /// Which of that source's interfaces holds it.
780 level: Level,
781 /// What it held at `onetaskgraph.copies` before, or `None` when it held nothing there.
782 before: Option<Value>,
783}
784
785impl Journal {
786 /// Record what has to happen to put one destination item back.
787 ///
788 /// The *first* entry for an id is the one that matters and later ones are dropped: an
789 /// item written twice — once as it lands, once when its edges are repaired — was only
790 /// ever one thing before this copy started, and that is what undoing it restores.
791 fn record(&mut self, entry: Undo) {
792 if self.entries.iter().any(|held| {
793 held.at() == entry.at() && held.kind() == entry.kind() && held.id() == entry.id()
794 }) {
795 return;
796 }
797 self.entries.push(entry);
798 }
799}
800
801/// What one copy invocation carries from the item it lands to the next.
802///
803/// Like the [`Journal`] beside it, this lives for the length of one `copy` call and is
804/// dropped with it: nothing here is state the engine keeps, and nothing in it is read back
805/// to answer a later query.
806#[derive(Default)]
807struct Running {
808 /// Every item an edge of this command resolves to a destination id rather than writing as
809 /// it was read: the ids named, what travels with them, and — for a member copy — each
810 /// member the copy does not carry whose own recorded origin already names its
811 /// destination item. That last kind is resolvable without being copied.
812 resolvable: Vec<GlobalId>,
813 /// The destination item each of those corresponds to, as soon as it is known — whether
814 /// this command found it, landed it, or read it off a member's recorded origin.
815 ///
816 /// Keyed by the qualified id's own rendering, which is what a recorded origin holds
817 /// anyway — making `GlobalId` orderable for a local map would put an ordering on a
818 /// contract type for a reason no caller of it has.
819 ///
820 /// Qualified, because a routed copy lands items in more than one source, and whether an
821 /// edge to one is written bare or qualified depends on where both of its ends landed.
822 counterparts: BTreeMap<String, GlobalId>,
823 /// The items held back for [`Engine::repair`], across the whole request.
824 deferred: Vec<Deferred>,
825 /// The reference figures, one total for the whole invocation rather than one per
826 /// document.
827 references: Counted,
828 /// The destination project each source project corresponds to, once this command has
829 /// looked for it — so a second task filed under the same project does not walk the
830 /// destination for it again.
831 ///
832 /// Keyed by the source project and the source the item lands in, because a routed task
833 /// lands in its home's member project rather than in the home.
834 filings: BTreeMap<(String, SourceName), Option<NativeId>>,
835 /// Every home project this copy has learned the member projects of, by the home's
836 /// qualified id: which members it holds, and whether this copy added one.
837 homes: BTreeMap<String, Home>,
838 /// `delivers` entries naming a member of the copied set, over the whole invocation.
839 delivers_rewritten: u64,
840 /// Every task this copy landed, for the relation rule once the whole copy is complete.
841 landed: Vec<LandedTask>,
842 /// Every item this copy landed, in the order it landed them, for the link each records
843 /// once the whole copy has landed.
844 linking: Vec<Linking>,
845}
846
847/// The member project a routed write was filed under, and what finding it wrote.
848pub(super) struct Filed {
849 /// The member project, or `None` when the home is not there to have one.
850 pub(super) project: Option<NativeId>,
851 /// The member created and the home's list rewritten, if either was.
852 journal: Journal,
853}
854
855/// One home project, and the member projects it names at `onetaskgraph.members`.
856struct Home {
857 /// The home, where it landed.
858 id: GlobalId,
859 /// Its title and status, which a member created for it takes.
860 title: String,
861 /// See `title`.
862 status: onetaskgraph_plugin_api::Status,
863 /// The members it names, at most one per source.
864 members: Vec<GlobalId>,
865 /// Whether this copy added or replaced one, so the home's own list is written once the
866 /// copy has landed.
867 grew: bool,
868}
869
870impl Home {
871 /// A home as its source holds it, with the members it already names.
872 ///
873 /// # Errors
874 ///
875 /// The home's own source refusing, as malformed, a member list [`members_of`] cannot
876 /// read: a copy filing into a plan it cannot read whole would add a second member beside
877 /// one it failed to see.
878 fn of(id: GlobalId, held: &Project) -> Result<Self, EngineError> {
879 let members =
880 members_of(&id, &held.metadata).map_err(|message| EngineError::SourceRefused {
881 name: id.source.to_string(),
882 error: SourceError::Malformed { message },
883 })?;
884 Ok(Self {
885 id,
886 title: held.title.clone(),
887 status: held.status.clone(),
888 members,
889 grew: false,
890 })
891 }
892}
893
894/// The member projects the home `home` records at [`MetadataKey::MEMBERS_KEY`]; none when it
895/// records the key not at all.
896///
897/// # Errors
898///
899/// Why, when the key holds anything but a list of qualified ids, each in a different source
900/// from the home and from the others — the one shape the routed write that keeps it writes.
901pub(crate) fn members_of(
902 home: &GlobalId,
903 metadata: &BTreeMap<String, Value>,
904) -> Result<Vec<GlobalId>, String> {
905 let Some(value) = metadata.get(MetadataKey::MEMBERS_KEY) else {
906 return Ok(Vec::new());
907 };
908 let refuse = |why: String| {
909 format!(
910 "{home} records {key} as {value}, which {why}; a home's member list is a list of \
911 qualified project ids, at most one per source and none in the home's own",
912 key = MetadataKey::MEMBERS_KEY
913 )
914 };
915 let Value::Array(entries) = value else {
916 return Err(refuse("is not a list".to_owned()));
917 };
918 let mut members: Vec<GlobalId> = Vec::new();
919 for entry in entries {
920 let member = entry
921 .as_str()
922 .and_then(|entry| entry.parse::<GlobalId>().ok())
923 .ok_or_else(|| refuse(format!("holds {entry}, which is not a qualified id")))?;
924 if member.source == home.source || members.iter().any(|kept| kept.source == member.source) {
925 return Err(refuse(format!(
926 "names {member}, a second project in {}",
927 member.source
928 )));
929 }
930 members.push(member);
931 }
932 Ok(members)
933}
934
935/// One item a copy landed, and what settling its `onetaskgraph.copies` link needs.
936struct Linking {
937 /// The item, at its own source.
938 item: GlobalId,
939 /// Which of that source's interfaces holds it.
940 level: Level,
941 /// Which rule found where it landed, or `None` when this copy created it.
942 found: Option<Found>,
943 /// Where it landed.
944 destination: GlobalId,
945 /// What it held at `onetaskgraph.copies` when it was read, if anything.
946 held: Option<Value>,
947}
948
949/// One task a copy landed, and what the relation rule reads of it once the copy is complete.
950struct LandedTask {
951 /// Where it landed.
952 destination: GlobalId,
953 /// The source it was read from, which a bare `delivers` entry names a task of.
954 origin: SourceName,
955 /// Its `delivers`, as its source reported them.
956 delivers: Vec<TaskRef>,
957 /// The `delivers` the destination held there before this copy, qualified.
958 before: Vec<GlobalId>,
959 /// Its status category, as the copy wrote it.
960 category: StatusCategory,
961}
962
963/// A task a copy landed, with the tasks it delivers now and delivered before, qualified.
964struct Deliverer {
965 destination: GlobalId,
966 category: StatusCategory,
967 now: Vec<GlobalId>,
968 before: Vec<GlobalId>,
969}
970
971/// One item, read and resolved, on its way into the destination.
972struct Planned {
973 /// Where it came from.
974 source: GlobalId,
975 /// The item as its source reported it.
976 item: Item,
977 /// Its forward edges, as its source reported them.
978 edges: Vec<DependencyEdge>,
979 /// Where it is going.
980 target: Target,
981 /// What the destination held at that target, read once where the target was found.
982 ///
983 /// The read that decided an item exists is the read of what it holds, so the two are
984 /// one round trip rather than two against a hosted destination.
985 held: Option<Prior>,
986 /// The source it lands in: the copy's destination, or the source a route sent it to.
987 to: SourceName,
988 /// What the report says placed it there, for a copy into a source with routes.
989 placed: Option<Placement>,
990}
991
992/// One item read out of its source, before anything decides where it lands.
993struct Read {
994 /// Where it came from.
995 source: GlobalId,
996 /// The item as its source reported it.
997 item: Item,
998}
999
1000/// A task, a project or a document, so the copy path is written once.
1001#[derive(Clone)]
1002enum Item {
1003 /// A task.
1004 Task(Box<Task>),
1005 /// A project.
1006 Project(Box<Project>),
1007 /// A document.
1008 Document(Box<Document>),
1009}
1010
1011impl Item {
1012 fn id(&self) -> &NativeId {
1013 match self {
1014 Self::Task(task) => &task.id,
1015 Self::Project(project) => &project.id,
1016 Self::Document(document) => &document.id,
1017 }
1018 }
1019
1020 fn level(&self) -> Level {
1021 match self {
1022 Self::Task(_) => Level::Task,
1023 Self::Project(_) => Level::Project,
1024 Self::Document(_) => Level::Document,
1025 }
1026 }
1027}
1028
1029/// Which of a destination's three read-and-write interfaces one item belongs to.
1030///
1031/// Deliberately not [`ItemKind`]: that enum names what a *dependency endpoint* points at,
1032/// and the contract gives it no document variant because nothing may point at a document.
1033/// This one names which pair of methods reads and writes an item, which is a different
1034/// question with a third answer.
1035///
1036/// Ordered so it can key a map of what a destination holds, per interface: an id alone
1037/// does not identify a destination item, for the reason [`Undo::kind`] records.
1038#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1039enum Level {
1040 /// `get_task`, `write_task`, `delete_task`.
1041 Task,
1042 /// `get_project`, `write_project`, `delete_project`.
1043 Project,
1044 /// `get_document`, `write_document`, `delete_document`.
1045 Document,
1046}
1047
1048/// One record filed under a document's own project, and the location string its source
1049/// reports for it.
1050///
1051/// A reference is a **literal occurrence, in a document's content, of the exact location
1052/// string the source reports for a related record** — the `String` inside
1053/// [`Location::Path`] or [`Location::Url`]. Both ends of a rewrite come from the plugins'
1054/// own reported [`Location`]: nothing here composes an address out of a name, an id or a
1055/// root, because a source that reports a canonical absolute path and a source that reports
1056/// an issue link are the two things the contract lets this ask about.
1057#[derive(Clone)]
1058struct Referent {
1059 /// Its qualified id at the source.
1060 id: GlobalId,
1061 /// The origin it records in its own metadata, when it records one.
1062 origin: Option<GlobalId>,
1063 /// Which of the destination's three interfaces its counterpart would be read from.
1064 level: Level,
1065 /// The non-empty location string its source reports for it.
1066 location: String,
1067}
1068
1069impl Referent {
1070 /// The two keys a destination record's own recorded origin is matched against.
1071 ///
1072 /// **A destination record is this referent's counterpart when its
1073 /// [`GlobalId::ORIGIN_KEY`] equals either this referent's own qualified source id, or
1074 /// the origin this referent itself records.** In plainer terms: when the destination
1075 /// record was copied directly from this referent, or directly from the one predecessor
1076 /// this referent itself records.
1077 ///
1078 /// That reach is exactly one recorded hop of ancestry on each side, and nothing more:
1079 ///
1080 /// - **A single hop resolves.** The destination record came straight from the referent.
1081 /// - **A one-level fan-out resolves.** A record copied from one store into two, with
1082 /// the document arriving by one route and naming records that arrived by the other,
1083 /// so both sides trace to one common predecessor. That is what the second key buys,
1084 /// and it is the only thing it buys.
1085 /// - **A chain of two or more hops does not resolve**, on either side, and that is
1086 /// permanent rather than pending: only one origin is ever recorded and every hop
1087 /// overwrites it. [`carried`] removes the key from the incoming metadata outright and
1088 /// [`recorded`] writes the id at the *immediate* source on every path but the
1089 /// copy-back, so A → B → C leaves C keyed by B and A's id gone.
1090 ///
1091 /// The second key costs no read — the referent's metadata is already in hand. Chasing
1092 /// the chain further would need the intermediate stores configured and reachable, which
1093 /// would put a third party's availability inside a copy; a durable lineage id would
1094 /// identify only records written after it landed, so it would resolve nothing already
1095 /// on a destination.
1096 fn keys(&self) -> Vec<String> {
1097 let mut keys = vec![self.id.to_string()];
1098 if let Some(origin) = &self.origin {
1099 let recorded = origin.to_string();
1100 if recorded != keys[0] {
1101 keys.push(recorded);
1102 }
1103 }
1104 keys
1105 }
1106}
1107
1108/// What every whole-reference occurrence of one location string becomes.
1109///
1110/// Three variants rather than a rewrite beside a flag, because the two ways of leaving an
1111/// occurrence alone are what the two figures a copy reports are *about*: one is the design
1112/// working and the other says the destination or the source holds something a re-run will
1113/// never fix. A bool would let a reader of this type read them as the same outcome.
1114enum Resolution {
1115 /// The destination's own location string for the counterpart.
1116 Rewrite(String),
1117 /// The destination holds no counterpart, or holds one it reports no location for, so
1118 /// the occurrence is left byte-for-byte. Ordinary and expected under the bound this
1119 /// design works to.
1120 NoCounterpart,
1121 /// The correspondence could not be established **confidently** — more than one
1122 /// destination record matches the referent's two keys, or two referents report this one
1123 /// location string. The occurrence is left byte-for-byte, no record is chosen, and a
1124 /// re-run will never clear it.
1125 Ambiguous,
1126}
1127
1128/// Every destination record that records an origin, read **once per copy invocation**.
1129///
1130/// Several documents in one project is the ordinary case, and a walk per document would
1131/// multiply reads against a rate limiter for no gain — so this is built once, for the
1132/// levels the invocation's own documents really name, and every document and every
1133/// referent of that invocation is answered out of it. A copy whose documents hold no
1134/// candidate reference builds none at all.
1135///
1136/// **This is a stricter discipline than [`Engine::scan`], deliberately.** That lookup takes
1137/// the first hit and stops, and every consumer of the copy already depends on it doing so;
1138/// it chooses the copy's own target, where a caller named the item. This one edits the
1139/// content of somebody's document, where a wrong answer is silent corruption of prose a
1140/// person will act on — so where more than one record matches, it chooses none. The two
1141/// lookups answer different questions and are meant to disagree on a destination holding
1142/// duplicates.
1143#[derive(Default)]
1144struct Counterparts {
1145 /// The records at one interface recording one origin.
1146 by_origin: BTreeMap<(Level, String), Vec<Held>>,
1147}
1148
1149/// One record a destination walk found: where it is there, and the location string that
1150/// destination reports for it — `None` when it reports none.
1151type Held = (NativeId, Option<String>);
1152
1153impl Counterparts {
1154 /// Record one destination item, when it records an origin at all.
1155 fn note(
1156 &mut self,
1157 level: Level,
1158 id: &NativeId,
1159 location: Option<&Location>,
1160 metadata: &BTreeMap<String, Value>,
1161 ) {
1162 let Some(origin) = origin_of(metadata) else {
1163 return;
1164 };
1165 self.by_origin
1166 .entry((level, origin.to_string()))
1167 .or_default()
1168 .push((id.clone(), located(location)));
1169 }
1170
1171 /// What one referent's occurrences become, by the two-key rule.
1172 ///
1173 /// Where the correspondence cannot be established **confidently**, the text is left
1174 /// exactly as it is and no record is chosen — see the note on this type.
1175 fn resolve(&self, referent: &Referent) -> Resolution {
1176 let mut candidates: Vec<&Held> = Vec::new();
1177 for key in referent.keys() {
1178 for record in self
1179 .by_origin
1180 .get(&(referent.level, key))
1181 .into_iter()
1182 .flatten()
1183 {
1184 // One destination record matching both keys is one record, not two.
1185 if !candidates.iter().any(|held| held.0 == record.0) {
1186 candidates.push(record);
1187 }
1188 }
1189 }
1190 match candidates.as_slice() {
1191 [] => Resolution::NoCounterpart,
1192 // A counterpart the destination reports no location for names nowhere a reader
1193 // could go, so the source's own string is left standing rather than removed.
1194 [(_, location)] => location
1195 .clone()
1196 .map_or(Resolution::NoCounterpart, Resolution::Rewrite),
1197 _ => Resolution::Ambiguous,
1198 }
1199 }
1200}
1201
1202impl Engine {
1203 /// Copy every item a request names into one configured destination.
1204 ///
1205 /// This is the whole of the verb, and the command line drives exactly this: a copy a
1206 /// Rust caller makes and a copy typed at a shell are the same call, so the two cannot
1207 /// answer the same copy differently.
1208 ///
1209 /// # Errors
1210 ///
1211 /// Returns [`EngineError`] when the destination is not configured, cannot be built,
1212 /// cannot be written, or — for a document copy — declares it has no documents; when an
1213 /// id names nothing; when an origin names an item the
1214 /// destination no longer holds and `--recreate` was not given; and when the
1215 /// destination refuses the write — including a field or a metadata key it cannot
1216 /// carry, which it names rather than dropping.
1217 pub async fn copy(&self, request: &CopyRequest) -> Result<CopyReport, EngineError> {
1218 // Before anything is read: `--create` says there is nothing to look for, and the other
1219 // two say how to look.
1220 if request.create {
1221 for (given, flag) in [
1222 (request.match_by.is_some(), CopyLookup::MatchBy),
1223 (request.recreate, CopyLookup::Recreate),
1224 ] {
1225 if given {
1226 return Err(EngineError::CreateWith { flag });
1227 }
1228 }
1229 }
1230 let destination = self.writable(&request.destination)?;
1231 // Before anything is read, and from the declaration rather than from a failed
1232 // write: a destination that says it has no documents has nowhere to put one.
1233 if request.scope == CopyScope::Documents {
1234 documentary(destination)?;
1235 }
1236 // The sources this command names, each read once before and once after, so what it
1237 // spent is the difference between two of each one's own running totals.
1238 let mut metered = vec![destination];
1239 for routed in self.routes.reachable(&request.destination) {
1240 if let Some(source) = self.ready().find(|source| source.name() == &routed)
1241 && !metered.iter().any(|held| held.name() == source.name())
1242 {
1243 metered.push(source);
1244 }
1245 }
1246 for id in request.items.as_slice() {
1247 if let Some(source) = self.ready().find(|source| source.name() == &id.source)
1248 && !metered.iter().any(|held| held.name() == source.name())
1249 {
1250 metered.push(source);
1251 }
1252 }
1253 let before = readings(&metered).await;
1254 let mut journal = Journal::default();
1255 match self.copy_all(request, &mut journal).await {
1256 Ok((mut report, deliverers)) => {
1257 // After the whole copy is complete and outside the journal: what the relation
1258 // rule writes is the delivered tasks' own, and a failure there is reported
1259 // against that task rather than undoing a copy that landed.
1260 for deliverer in &deliverers {
1261 report.delivered.extend(
1262 self.deliver(
1263 &deliverer.destination,
1264 deliverer.category,
1265 &deliverer.now,
1266 &deliverer.before,
1267 )
1268 .await,
1269 );
1270 }
1271 report.spent = spent_between(&before, &readings(&metered).await);
1272 Ok(report)
1273 }
1274 Err(error) => Err(self.undo(journal, error).await),
1275 }
1276 }
1277
1278 /// The copy itself, with everything it writes recorded so a failure can be undone.
1279 ///
1280 /// The ids named together are **one** copied set, and that is what makes an edge
1281 /// between any two of them a real edge at the destination: a copy of two projects at
1282 /// once knows that a task in the first depends on a task in the second, and a task
1283 /// knows that the project it belongs to is being created beside it. Copying them one
1284 /// at a time could not, and wrote the far end as the id it had at its *source* — a
1285 /// dangling reference to somewhere the destination has never heard of.
1286 async fn copy_all(
1287 &self,
1288 request: &CopyRequest,
1289 journal: &mut Journal,
1290 ) -> Result<(CopyReport, Vec<Deliverer>), EngineError> {
1291 let mut running = Running::default();
1292 // The whole copied set, established before anything is written. For a project
1293 // copy that means reading every named project's membership first: the set is the
1294 // whole request rather than one project of it.
1295 let items = match &request.scope {
1296 CopyScope::Tasks | CopyScope::Documents => {
1297 let kind = if request.scope == CopyScope::Documents {
1298 Level::Document
1299 } else {
1300 Level::Task
1301 };
1302 running
1303 .resolvable
1304 .extend(request.items.as_slice().iter().cloned());
1305 let mut planned = Vec::new();
1306 for id in request.items.as_slice() {
1307 planned.push(self.plan(request, kind, id, &mut running).await?);
1308 }
1309 self.copy_items(request, planned, None, &mut running, journal)
1310 .await?
1311 }
1312 CopyScope::Projects { tasks } => {
1313 let mut projects = Vec::new();
1314 for id in request.items.as_slice() {
1315 let members = if *tasks {
1316 self.project_members(id).await?
1317 } else {
1318 Vec::new()
1319 };
1320 running.resolvable.push(id.clone());
1321 running.resolvable.extend(members.iter().cloned());
1322 projects.push((id.clone(), members));
1323 }
1324 self.copy_projects(request, &projects, &[], &mut running, journal)
1325 .await?
1326 }
1327 CopyScope::Members(named) => {
1328 let (projects, unrecorded) =
1329 self.named_members(request, named, &mut running).await?;
1330 self.copy_projects(request, &projects, &unrecorded, &mut running, journal)
1331 .await?
1332 }
1333 };
1334 let references = running.references;
1335 let delivers_rewritten = running.delivers_rewritten;
1336 // Every member's destination id is known once the first pass has landed it, so the
1337 // tasks each landed task delivers are settled here rather than after the repair.
1338 let deliverers: Vec<Deliverer> = running
1339 .landed
1340 .iter()
1341 .map(|task| Deliverer {
1342 destination: task.destination.clone(),
1343 category: task.category,
1344 now: targets(
1345 &resolved_entries(&mapped_delivers(
1346 &task.delivers,
1347 &task.origin,
1348 &task.destination.source,
1349 &running.resolvable,
1350 &running.counterparts,
1351 )),
1352 &task.destination.source,
1353 ),
1354 before: task.before.clone(),
1355 })
1356 .collect();
1357 let linking = std::mem::take(&mut running.linking);
1358 let homes = std::mem::take(&mut running.homes);
1359 self.repair(request, running, journal).await?;
1360 if !request.dry_run {
1361 self.record_members(homes, journal).await?;
1362 }
1363 let mut items = items;
1364 self.link(linking, &mut items, journal).await?;
1365 Ok((
1366 CopyReport {
1367 items,
1368 references_rewritten: references.rewritten,
1369 references_unresolved: references.unresolved,
1370 references_ambiguous: references.ambiguous,
1371 delivers_rewritten,
1372 // Filled in by `copy`, once the copy is complete.
1373 delivered: Vec::new(),
1374 // Filled in by `copy`, which is the one place both readings are taken.
1375 spent: None,
1376 },
1377 deliverers,
1378 ))
1379 }
1380
1381 /// Write every deferred item again, now that every destination id is known.
1382 ///
1383 /// This is the second half of the two passes an edge between two items of one copy
1384 /// needs: the far end's destination id does not exist until it has been created, so
1385 /// the item that points at it lands first without that edge and is completed here.
1386 /// It runs once for the whole request rather than once per project, because a far end
1387 /// may be in a project this copy has not reached yet.
1388 async fn repair(
1389 &self,
1390 request: &CopyRequest,
1391 running: Running,
1392 journal: &mut Journal,
1393 ) -> Result<(), EngineError> {
1394 if request.dry_run {
1395 return Ok(());
1396 }
1397 let Running {
1398 resolvable,
1399 counterparts,
1400 deferred,
1401 ..
1402 } = running;
1403 for entry in deferred {
1404 let destination = self.writable(&entry.item.to)?;
1405 let edges = mapped_edges(
1406 &entry.item.edges,
1407 &entry.item.source.source,
1408 &entry.item.to,
1409 &resolvable,
1410 &counterparts,
1411 );
1412 let delivers = delivers_of(&entry.item, &entry.item.to, &resolvable, &counterparts);
1413 self.write(
1414 destination,
1415 &entry.item,
1416 Some(entry.destination),
1417 entry.filed,
1418 &resolved(&edges),
1419 &resolved_entries(&delivers),
1420 entry.prior,
1421 journal,
1422 )
1423 .await?;
1424 }
1425 Ok(())
1426 }
1427
1428 /// Put the destination back the way this copy found it, then report why it failed.
1429 ///
1430 /// Undone in reverse, and an item this copy created is removed rather than restored —
1431 /// the entry recording what it looked like a moment after creation is not a state
1432 /// anybody asked for. When the destination cannot take one of them back, the refusal
1433 /// says so and names what is still there, because a user told "the copy failed" about
1434 /// a destination that is not as they left it will copy again over a tree nobody
1435 /// described.
1436 async fn undo(&self, journal: Journal, error: EngineError) -> EngineError {
1437 let created: Vec<(&SourceName, Level, &NativeId)> = journal
1438 .entries
1439 .iter()
1440 .filter_map(|entry| match entry {
1441 Undo::Created { at, kind, id } => Some((at, *kind, id)),
1442 Undo::Updated { .. } => None,
1443 })
1444 .collect();
1445 // The ids and the refusal are one value rather than two, because they are one
1446 // fact: an item is only left behind because the destination refused to take it
1447 // back, so the first refusal carries the first id and neither half can be
1448 // recorded without the other.
1449 let mut unrestored: Option<(LeftBehind, SourceError)> = None;
1450 // The links first, because they were written last: once the whole copy had landed.
1451 for entry in journal.links.iter().rev() {
1452 if let Err(problem) = self.unlink(entry).await {
1453 match &mut unrestored {
1454 Some((left_behind, _)) => left_behind.push(entry.item.clone()),
1455 None => unrestored = Some((LeftBehind::new(entry.item.clone()), problem)),
1456 }
1457 }
1458 }
1459 for entry in journal.entries.iter().rev() {
1460 let outcome = match self.writable(entry.at()) {
1461 Err(failed) => Err(SourceError::Refused {
1462 message: failed.to_string(),
1463 }),
1464 Ok(destination) => match entry {
1465 Undo::Created { kind, id, .. } => remove(destination, *kind, id).await,
1466 Undo::Updated { at, id, prior }
1467 if !created.contains(&(at, prior.item.level(), id)) =>
1468 {
1469 restore(destination, id, prior).await
1470 }
1471 Undo::Updated { .. } => Ok(()),
1472 },
1473 };
1474 if let Err(problem) = outcome {
1475 let id = GlobalId::new(entry.at().clone(), entry.id().clone());
1476 match &mut unrestored {
1477 Some((left_behind, _)) => left_behind.push(id),
1478 None => unrestored = Some((LeftBehind::new(id), problem)),
1479 }
1480 }
1481 }
1482 match unrestored {
1483 None => error,
1484 Some((left_behind, refusal)) => EngineError::CopyNotUndone {
1485 error: Box::new(error),
1486 left_behind,
1487 refusal,
1488 },
1489 }
1490 }
1491
1492 /// Record, on every item this copy landed, where it landed — once the whole copy has
1493 /// landed, so a copy that fails before this point has written nothing on any source.
1494 ///
1495 /// Each outcome is told what happened to its item's link. The items landed are exactly
1496 /// the outcomes that are not orphans, in the order they landed; a dry run records
1497 /// nothing, so this is never asked about one.
1498 async fn link(
1499 &self,
1500 linking: Vec<Linking>,
1501 items: &mut [CopyOutcome],
1502 journal: &mut Journal,
1503 ) -> Result<(), EngineError> {
1504 let mut landed = items
1505 .iter_mut()
1506 .filter(|outcome| !matches!(outcome.action, CopyAction::Orphaned { .. }));
1507 for entry in linking {
1508 let slot = landed
1509 .next()
1510 .filter(|outcome| outcome.source == entry.item)
1511 .and_then(|outcome| outcome.action.link_slot())
1512 .expect("every item a copy landed is reported, in the order it landed");
1513 *slot = Some(self.linked(entry, journal).await?);
1514 }
1515 Ok(())
1516 }
1517
1518 /// Write one landed item's `onetaskgraph.copies` entry for the destination, when it
1519 /// does not already say this, and say what was done.
1520 ///
1521 /// An item found by its own origin records nothing: that correspondence is already
1522 /// recorded, on the destination item. A source that cannot hold the entry — it has no
1523 /// write side, or refuses the narrow metadata write — leaves the copy as it landed and
1524 /// is reported so; any other failure fails the copy, which then undoes every write it
1525 /// made, this one's journal entry included.
1526 async fn linked(&self, entry: Linking, journal: &mut Journal) -> Result<CopyLink, EngineError> {
1527 // Keyed by the source the item really landed in, which a routed copy decides per item.
1528 let landed_in = entry.destination.source.clone();
1529 if entry.found == Some(Found::Origin) {
1530 return Ok(CopyLink::Unchanged);
1531 }
1532 let wanted = Value::String(entry.destination.to_string());
1533 // Only the entries that are links are kept. Anything else under the key — a value
1534 // that is not an object, or an entry a person spelled wrong — is not a link this or
1535 // any later copy can read, so it is replaced by what is, exactly as an entry naming
1536 // another item is rewritten.
1537 let mut links = well_formed(entry.held.as_ref());
1538 if links.get(landed_in.as_str()) == Some(&wanted) {
1539 return Ok(CopyLink::Unchanged);
1540 }
1541 let source = self.readable(&entry.item.source)?;
1542 if !source.source().writes().is_supported() {
1543 return Ok(CopyLink::Unrecorded);
1544 }
1545 links.insert(landed_in.to_string(), wanted);
1546 // Journalled before the write, for the reason a destination write is: a write that
1547 // stopped part way is only put back by what this journal holds.
1548 journal.links.push(Unlink {
1549 item: entry.item.clone(),
1550 level: entry.level,
1551 before: entry.held,
1552 });
1553 match set_link(
1554 source,
1555 entry.level,
1556 &entry.item.native,
1557 &Value::Object(links),
1558 )
1559 .await
1560 {
1561 Ok(true) => Ok(CopyLink::Recorded),
1562 // Nothing was written: the source no longer holds the item, or said it cannot
1563 // hold the key.
1564 Ok(false) | Err(SourceError::Refused { .. }) => {
1565 journal.links.pop();
1566 Ok(CopyLink::Unrecorded)
1567 }
1568 // §4.18 once let a plugin refuse any key in this namespace as malformed, and one
1569 // written then still may. Its answer says nothing about whether it wrote, so the
1570 // journal entry stays and a later failure puts the item back regardless.
1571 Err(SourceError::Malformed { .. }) => Ok(CopyLink::Unrecorded),
1572 Err(error) => Err(refused(source, error)),
1573 }
1574 }
1575
1576 /// Put one copied item's `onetaskgraph.copies` back to what it held before this copy.
1577 ///
1578 /// A key it held is written back through the same narrow write. A key it did not hold
1579 /// cannot be removed through that write, so the item is read as it now is and written
1580 /// back whole, without the key, through the source's own write interface — the same
1581 /// way an overwritten destination item is restored.
1582 async fn unlink(&self, entry: &Unlink) -> Result<(), SourceError> {
1583 let as_source_error = |error: EngineError| match error {
1584 EngineError::SourceRefused { error, .. } => error,
1585 other => SourceError::Refused {
1586 message: other.to_string(),
1587 },
1588 };
1589 let source = self.readable(&entry.item.source).map_err(as_source_error)?;
1590 if let Some(before) = &entry.before {
1591 return set_link(source, entry.level, &entry.item.native, before)
1592 .await
1593 .map(|_| ());
1594 }
1595 let Some(mut held) = self
1596 .prior(source, entry.level, &entry.item.native)
1597 .await
1598 .map_err(as_source_error)?
1599 else {
1600 return Ok(());
1601 };
1602 metadata_of(&mut held.item).remove(MetadataKey::COPIES_KEY);
1603 restore(source, &entry.item.native, &held).await
1604 }
1605
1606 /// The member project `home` names in `to`, created and recorded on the home when it
1607 /// names none — for a write that is not a copy, such as `task create`, routed away from
1608 /// the source its project is in. `None` when the home itself is not there.
1609 ///
1610 /// What it wrote is handed back, so a write that then fails takes it back through
1611 /// [`Engine::unfile`] and leaves no member nobody asked for.
1612 pub(super) async fn member_project(
1613 &self,
1614 home: &GlobalId,
1615 to: &SourceName,
1616 ) -> Result<Filed, EngineError> {
1617 let mut running = Running::default();
1618 let mut journal = Journal::default();
1619 let mut filed = self
1620 .member_in(false, home, to, &mut running, &mut journal)
1621 .await;
1622 if filed.is_ok() {
1623 let homes = std::mem::take(&mut running.homes);
1624 if let Err(error) = self.record_members(homes, &mut journal).await {
1625 filed = Err(error);
1626 }
1627 }
1628 match filed {
1629 Ok(project) => Ok(Filed { project, journal }),
1630 Err(error) => Err(self.undo(journal, error).await),
1631 }
1632 }
1633
1634 /// Take back what [`Engine::member_project`] wrote, because the write it filed for failed
1635 /// with `error`.
1636 pub(super) async fn unfile(&self, filed: Filed, error: EngineError) -> EngineError {
1637 self.undo(filed.journal, error).await
1638 }
1639
1640 /// The destination source, once it is established it exists and can be written.
1641 fn writable(&self, name: &SourceName) -> Result<&ResolvedSource, EngineError> {
1642 let name = self.known(name)?;
1643 if let Some(unavailable) = self.unavailable().find(|source| source.name() == &name) {
1644 return Err(EngineError::DestinationUnavailable {
1645 name: name.to_string(),
1646 error: unavailable.error().clone(),
1647 });
1648 }
1649 let source = self
1650 .ready()
1651 .find(|source| source.name() == &name)
1652 .ok_or(EngineError::NoSources)?;
1653 if !source.source().writes().is_supported() {
1654 return Err(EngineError::NotWritable {
1655 name: name.to_string(),
1656 kind: source.kind().to_owned(),
1657 });
1658 }
1659 Ok(source)
1660 }
1661
1662 /// Copy each project and what travels with it: every task in it, the members a member
1663 /// copy names, or nothing at all.
1664 ///
1665 /// Every item of every project is read and its target decided **before any of them is
1666 /// written, and each exactly once.** That is what lets a member copy refuse an edge it
1667 /// cannot resolve while the destination is still as it was found, and what stops a
1668 /// whole copy resolving one target twice — once to learn the ids the project's own
1669 /// edges name, and again to land it — which against a hosted destination is a read per
1670 /// item spent on an answer the command already had.
1671 async fn copy_projects(
1672 &self,
1673 request: &CopyRequest,
1674 projects: &[(GlobalId, Vec<GlobalId>)],
1675 unrecorded: &[GlobalId],
1676 running: &mut Running,
1677 journal: &mut Journal,
1678 ) -> Result<Vec<CopyOutcome>, EngineError> {
1679 let mut plans = Vec::new();
1680 for (id, members) in projects {
1681 if self.routes.routes(&request.destination) {
1682 // Every task is read before the project is aimed, because where its home goes
1683 // is decided by where they do.
1684 let project = self.read(Level::Project, id).await?;
1685 let mut reads = Vec::new();
1686 for member in members {
1687 reads.push(self.read(Level::Task, member).await?);
1688 }
1689 let home = self.home_placement(request, &project, &reads).await?;
1690 let project = self.aim(request, project, home).await?;
1691 let mut tasks = Vec::new();
1692 for read in reads {
1693 let placement = match &read.item {
1694 Item::Task(task) => {
1695 self.routes.place(&request.destination, &task.repositories)
1696 }
1697 Item::Project(_) | Item::Document(_) => {
1698 self.placed_at(request, &request.destination)
1699 }
1700 };
1701 tasks.push(self.aim(request, read, placement).await?);
1702 }
1703 plans.push((project, tasks));
1704 } else {
1705 let project = self.plan(request, Level::Project, id, running).await?;
1706 let mut tasks = Vec::new();
1707 for member in members {
1708 tasks.push(self.plan(request, Level::Task, member, running).await?);
1709 }
1710 plans.push((project, tasks));
1711 }
1712 }
1713 for (project, tasks) in &plans {
1714 for item in std::iter::once(project).chain(tasks) {
1715 unrecorded_far_end(item, unrecorded)?;
1716 }
1717 }
1718 let mut outcomes = Vec::new();
1719 for (project, tasks) in plans {
1720 outcomes.extend(
1721 self.copy_project(request, project, tasks, running, journal)
1722 .await?,
1723 );
1724 }
1725 Ok(outcomes)
1726 }
1727
1728 /// Where a routed project copy lands the project: its **home**.
1729 ///
1730 /// A home that already exists, in any source the copy can reach, stays where it is —
1731 /// found by the project's own link or origin. Otherwise the home follows its tasks only
1732 /// when every one copied routes to one source, and so does the project's own
1733 /// repositories when it names any; any other project's home is the copy's destination.
1734 /// With no task copied, the project's own repositories alone decide.
1735 async fn home_placement(
1736 &self,
1737 request: &CopyRequest,
1738 project: &Read,
1739 tasks: &[Read],
1740 ) -> Result<Placement, EngineError> {
1741 let Item::Project(held) = &project.item else {
1742 return Ok(self.placed_at(request, &request.destination));
1743 };
1744 let mut placements: Vec<Placement> = tasks
1745 .iter()
1746 .filter_map(|read| match &read.item {
1747 Item::Task(task) => {
1748 Some(self.routes.place(&request.destination, &task.repositories))
1749 }
1750 Item::Project(_) | Item::Document(_) => None,
1751 })
1752 .collect();
1753 if !held.repositories.is_empty() {
1754 placements.push(self.routes.place(&request.destination, &held.repositories));
1755 }
1756 let rule = match placements.first() {
1757 Some(first)
1758 if placements
1759 .iter()
1760 .all(|placement| placement.destination == first.destination) =>
1761 {
1762 first.clone()
1763 }
1764 _ => self.placed_at(request, &request.destination),
1765 };
1766 for elsewhere in self.routes.reachable(&request.destination) {
1767 if elsewhere == rule.destination {
1768 continue;
1769 }
1770 let there = self.writable(&elsewhere)?;
1771 let named = link_of(&held.metadata, &elsewhere)
1772 .or_else(|| origin_of(&held.metadata).filter(|origin| origin.source == elsewhere));
1773 let Some(named) = named else {
1774 continue;
1775 };
1776 let Some(found) = self.prior(there, Level::Project, &named.native).await? else {
1777 continue;
1778 };
1779 let names_back = origin_of(described(&found.item).1).as_ref() == Some(&project.source);
1780 if names_back || origin_of(&held.metadata).as_ref() == Some(&named) {
1781 return Ok(self.placed_at(request, &elsewhere));
1782 }
1783 }
1784 Ok(rule)
1785 }
1786
1787 /// Land one planned project, then the tasks planned beside it.
1788 async fn copy_project(
1789 &self,
1790 request: &CopyRequest,
1791 project: Planned,
1792 tasks: Vec<Planned>,
1793 running: &mut Running,
1794 journal: &mut Journal,
1795 ) -> Result<Vec<CopyOutcome>, EngineError> {
1796 let (carries_tasks, walks_orphans) = match request.scope {
1797 CopyScope::Projects { tasks } => (tasks, tasks),
1798 CopyScope::Members(_) => (true, false),
1799 CopyScope::Tasks | CopyScope::Documents => (false, false),
1800 };
1801 let id = project.source.clone();
1802 let members: Vec<GlobalId> = tasks.iter().map(|task| task.source.clone()).collect();
1803 // The project lands with every edge it can already resolve — its members' targets
1804 // are known from their plans — so a project that has not changed is not written at
1805 // all. What it *reports* is read the way it always has been: against the ids known
1806 // before its own members' were, unless every edge resolves and nothing differs.
1807 // Reading it any other way would move the word a repeat copy reports for a project
1808 // whose only difference is an edge, which is not this change's to move.
1809 let mut before_members = running.counterparts.clone();
1810 if let Target::Update { id: target, .. } = &project.target {
1811 before_members.insert(
1812 id.to_string(),
1813 GlobalId::new(project.to.clone(), target.clone()),
1814 );
1815 }
1816 for item in std::iter::once(&project).chain(&tasks) {
1817 if let Target::Update { id: target, .. } = &item.target {
1818 running.counterparts.insert(
1819 item.source.to_string(),
1820 GlobalId::new(item.to.clone(), target.clone()),
1821 );
1822 }
1823 }
1824 let home_source = project.to.clone();
1825 let unchanged = match &project.target {
1826 Target::Update { id: target, .. } => {
1827 let settled = mapped_edges(
1828 &project.edges,
1829 &id.source,
1830 &home_source,
1831 &running.resolvable,
1832 &running.counterparts,
1833 );
1834 let first = mapped_edges(
1835 &project.edges,
1836 &id.source,
1837 &home_source,
1838 &running.resolvable,
1839 &before_members,
1840 );
1841 let held = project.held.as_ref();
1842 let unchanged_with = |edges: &[Option<DependencyEdge>]| {
1843 !changes(
1844 held,
1845 &project,
1846 target,
1847 &None,
1848 &resolved(edges),
1849 &[],
1850 &home_source,
1851 )
1852 };
1853 Some(
1854 (!settled.iter().any(Option::is_none) && unchanged_with(&settled))
1855 || unchanged_with(&first),
1856 )
1857 }
1858 Target::Create => None,
1859 };
1860 // A project this copy creates holds nothing it did not file itself, so nothing filed
1861 // under it can be an orphan and the destination is not walked for one.
1862 let created = matches!(project.target, Target::Create);
1863 let mut outcomes = self
1864 .copy_items(request, vec![project], None, running, journal)
1865 .await?;
1866 if let (Some(unchanged), Some(landed), Some(via)) = (
1867 unchanged,
1868 outcomes[0].destination().cloned(),
1869 outcomes[0].action.found_by(),
1870 ) {
1871 let link = outcomes[0].action.link();
1872 outcomes[0].action = if unchanged {
1873 CopyAction::Unchanged {
1874 destination: landed,
1875 via,
1876 link,
1877 }
1878 } else {
1879 CopyAction::Updated {
1880 destination: landed,
1881 via,
1882 link,
1883 }
1884 };
1885 }
1886 if !carries_tasks {
1887 return Ok(outcomes);
1888 }
1889 // `None` when a dry run would have created the project: nothing was written, so
1890 // there is no destination project id to file the tasks under. Every task is still
1891 // read and still reported, because that is what a dry run is for.
1892 let filed = outcomes.first().and_then(CopyOutcome::destination).cloned();
1893 let filing = match &filed {
1894 None => None,
1895 Some(home) => {
1896 let mut filing = Filing::from([(home.source.clone(), home.native.clone())]);
1897 // A task routed to another source than its home's is filed under the home's
1898 // member project there, found or created before any of those tasks lands.
1899 let mut elsewhere: Vec<SourceName> = Vec::new();
1900 for task in &tasks {
1901 if task.to != home.source && !elsewhere.contains(&task.to) {
1902 elsewhere.push(task.to.clone());
1903 }
1904 }
1905 for to in elsewhere {
1906 if let Some(member) = self
1907 .member_in(request.dry_run, home, &to, running, journal)
1908 .await?
1909 {
1910 filing.insert(to, member);
1911 }
1912 }
1913 Some(filing)
1914 }
1915 };
1916 outcomes.extend(
1917 self.copy_items(request, tasks, filing.as_ref(), running, journal)
1918 .await?,
1919 );
1920 // A member copy was told which members it carries, so it cannot tell a member it
1921 // left out from one the source no longer holds, and does not walk for either.
1922 if walks_orphans
1923 && !created
1924 && let Some(filed) = filed
1925 {
1926 let destination = self.writable(&filed.source)?;
1927 outcomes.extend(
1928 self.orphans(destination, &id, &filed.native, &members)
1929 .await?,
1930 );
1931 // A routed plan's tasks are filed under its members too, so each member is walked
1932 // in its own source for what the source plan no longer holds.
1933 if self.routes.routes(&request.destination) {
1934 for member in self.members_named(&filed, running).await? {
1935 let there = self.writable(&member.source)?;
1936 outcomes.extend(self.orphans(there, &id, &member.native, &members).await?);
1937 }
1938 }
1939 }
1940 Ok(outcomes)
1941 }
1942
1943 /// The member projects `home` names: the list this copy already holds for it, grown
1944 /// members included, or else the one the home itself records.
1945 async fn members_named(
1946 &self,
1947 home: &GlobalId,
1948 running: &Running,
1949 ) -> Result<Vec<GlobalId>, EngineError> {
1950 if let Some(held) = running.homes.get(&home.to_string()) {
1951 return Ok(held.members.clone());
1952 }
1953 let at = self.writable(&home.source)?;
1954 let Some(held) = at
1955 .source()
1956 .get_project(&home.native)
1957 .await
1958 .map_err(|error| refused(at, error))?
1959 else {
1960 return Ok(Vec::new());
1961 };
1962 members_of(home, &held.metadata).map_err(|message| EngineError::SourceRefused {
1963 name: home.source.to_string(),
1964 error: SourceError::Malformed { message },
1965 })
1966 }
1967
1968 /// The members a member copy names, per project and in the order they were named, and
1969 /// every member it does not name that records no destination id.
1970 ///
1971 /// Settled before anything is read at the destination. A named id that is a member of
1972 /// none of the projects is refused here, naming it. An unnamed member whose recorded
1973 /// origin names the destination joins the copied set with that id already known, so an
1974 /// edge to it resolves exactly as an edge to a copied item does, and costs no read. An
1975 /// unnamed member recording none is returned, so an edge to it is refused before
1976 /// anything is written — see [`unrecorded_far_end`].
1977 async fn named_members(
1978 &self,
1979 request: &CopyRequest,
1980 named: &CopyItems,
1981 running: &mut Running,
1982 ) -> Result<(Vec<(GlobalId, Vec<GlobalId>)>, Vec<GlobalId>), EngineError> {
1983 let projects = request.items.as_slice();
1984 // A routed copy can land a member in any source it reaches, so an origin naming any
1985 // of them is a destination id already known.
1986 let reachable = self.routes.reachable(&request.destination);
1987 let mut carried = Vec::new();
1988 let mut unrecorded = Vec::new();
1989 for project in projects {
1990 let held = self.project_member_tasks(project).await?;
1991 let mut members: Vec<GlobalId> = Vec::new();
1992 for id in named.as_slice() {
1993 if held.iter().any(|task| &task.id == id) && !members.contains(id) {
1994 members.push(id.clone());
1995 }
1996 }
1997 for task in held {
1998 if members.contains(&task.id) {
1999 continue;
2000 }
2001 // A routed copy also reads the member's own link, because a plan authored
2002 // where it is copied from records where each task landed rather than an
2003 // origin, and its tasks may have landed in any source the copy reaches.
2004 let linked = || {
2005 reachable
2006 .iter()
2007 .find_map(|there| link_of(&task.item.metadata, there))
2008 .filter(|_| self.routes.routes(&request.destination))
2009 };
2010 match origin_of(&task.item.metadata)
2011 .filter(|origin| reachable.contains(&origin.source))
2012 .or_else(linked)
2013 {
2014 Some(origin) => {
2015 running.counterparts.insert(task.id.to_string(), origin);
2016 running.resolvable.push(task.id);
2017 }
2018 None => unrecorded.push(task.id),
2019 }
2020 }
2021 running.resolvable.push(project.clone());
2022 running.resolvable.extend(members.iter().cloned());
2023 carried.push((project.clone(), members));
2024 }
2025 if let Some(stray) = named
2026 .as_slice()
2027 .iter()
2028 .find(|id| !carried.iter().any(|(_, members)| members.contains(id)))
2029 {
2030 return Err(EngineError::NotAMember {
2031 id: stray.clone(),
2032 projects: projects.to_vec(),
2033 });
2034 }
2035 Ok((carried, unrecorded))
2036 }
2037
2038 /// Every task the source holds in `project`, by qualified id.
2039 async fn project_members(&self, project: &GlobalId) -> Result<Vec<GlobalId>, EngineError> {
2040 Ok(self
2041 .project_member_tasks(project)
2042 .await?
2043 .into_iter()
2044 .map(|task| task.id)
2045 .collect())
2046 }
2047
2048 /// Every task the source holds in `project`, as the source reported it.
2049 ///
2050 /// The ids alone are what a copy files under a project; the whole task is what a
2051 /// document's references need, because the location a reference names is a field of it.
2052 async fn project_member_tasks(
2053 &self,
2054 project: &GlobalId,
2055 ) -> Result<Vec<Qualified<Task>>, EngineError> {
2056 let mut request = TaskRequest {
2057 sources: vec![project.source.clone()],
2058 filters: Filters::default(),
2059 project: ProjectSelector::Qualified(project.clone()),
2060 priorities: Vec::new(),
2061 commented_since: None,
2062 metadata: Vec::new(),
2063 origin: None,
2064 include_members: false,
2065 paging: Paging {
2066 limit: PROJECT_PAGE,
2067 token: None,
2068 },
2069 };
2070 let mut members = Vec::new();
2071 // Pages by this engine's own token rather than by a source cursor, and the
2072 // asymmetry with the three walks below is deliberate. A source answering two
2073 // cursors with each other advances on every page, so `unrepeated` under the list
2074 // verb never fires; the cycle shows only as a token handed back unchanged, which
2075 // is what this loop pages by. Point it at the source and a project copy spins.
2076 //
2077 // No page bound here for the same reason: `Engine::tasks` merges under the budget
2078 // it was asked, so nothing longer than `PROJECT_PAGE` can arrive. `fits` in
2079 // `fetch::walk` refuses the page a source can really overrun, its own, while
2080 // these very members are read.
2081 let misbehaved = |error| EngineError::SourceRefused {
2082 name: project.source.to_string(),
2083 error,
2084 };
2085 loop {
2086 let asked = request.paging.token.clone();
2087 let response = self.tasks(&request).await?;
2088 if let Some(failure) = response.errors.first() {
2089 return Err(EngineError::SourceRefused {
2090 name: failure.source.to_string(),
2091 error: failure.error.clone(),
2092 });
2093 }
2094 unrepeated(
2095 response.next.as_ref(),
2096 asked.as_ref(),
2097 "the tasks of a project were being read for a copy",
2098 )
2099 .map_err(misbehaved)?;
2100 members.extend(response.items);
2101 match response.next {
2102 Some(token) => request.paging.token = Some(token),
2103 None => return Ok(members),
2104 }
2105 }
2106 }
2107
2108 /// Every document the source holds in `project`, as the source reported it.
2109 ///
2110 /// Paged by this engine's own token for the reason the member walk above is, and the
2111 /// note there says why.
2112 async fn project_documents(
2113 &self,
2114 project: &GlobalId,
2115 ) -> Result<Vec<Qualified<Document>>, EngineError> {
2116 let mut request = DocumentRequest {
2117 sources: vec![project.source.clone()],
2118 filters: DocumentFilters::default(),
2119 project: ProjectSelector::Qualified(project.clone()),
2120 paging: Paging {
2121 limit: PROJECT_PAGE,
2122 token: None,
2123 },
2124 };
2125 let mut held = Vec::new();
2126 let misbehaved = |error| EngineError::SourceRefused {
2127 name: project.source.to_string(),
2128 error,
2129 };
2130 loop {
2131 let asked = request.paging.token.clone();
2132 let response = self.documents(&request).await?;
2133 if let Some(failure) = response.errors.first() {
2134 return Err(EngineError::SourceRefused {
2135 name: failure.source.to_string(),
2136 error: failure.error.clone(),
2137 });
2138 }
2139 unrepeated(
2140 response.next.as_ref(),
2141 asked.as_ref(),
2142 "the documents of a project were being read for a copy",
2143 )
2144 .map_err(misbehaved)?;
2145 held.extend(response.items);
2146 match response.next {
2147 Some(token) => request.paging.token = Some(token),
2148 None => return Ok(held),
2149 }
2150 }
2151 }
2152
2153 /// Point every reference the documents of this copy hold at the destination's own
2154 /// records, and say how many it could not.
2155 ///
2156 /// A document copied out of a local Markdown store used to arrive naming absolute paths
2157 /// under one checkout on one machine, dead for the only reader the copy exists for,
2158 /// while the destination held its own record for every one of them the whole time. This
2159 /// is what closes that, and it is deliberately **not** a Markdown-link parser: the
2160 /// artifact that motivated it holds bare absolute paths inside backticks in a table
2161 /// cell, which `[text](target)` matching would have left exactly as it found them.
2162 ///
2163 /// The correspondence is re-established from what the *destination* records at
2164 /// [`GlobalId::ORIGIN_KEY`], not from the mapping this copy holds. That mapping is not
2165 /// available when it is needed: `project copy` and `document copy` are separate verbs,
2166 /// one invocation carries one [`CopyScope`], and a project copy carries no documents —
2167 /// so by the time the document is copied, the tasks were written by a process that has
2168 /// exited. Reading the destination is also what makes a document copied on its own
2169 /// work, which a same-run mapping never could.
2170 ///
2171 // llmlint: ignore-block[contracts_have_one_source_or_a_drift_gate] This comment is required to state the rule; it is held by the journeys `a_rendering_whose_references_a_copy_rewrites_records_the_digest_of_what_it_was_given`, `a_copy_carries_provenance_it_cannot_vouch_for_verbatim` and `a_rendering_whose_references_a_copy_leaves_alone_carries_its_provenance_unchanged` in `crates/onetaskgraph/tests/e2e/rendered.rs`, and the entry's fields are `TemplateProvenance`'s own.
2172 /// Tasks and projects are not touched. A document's content is rewritten, and so is
2173 /// one thing beside it: when this substitutes at least one reference into a rendering
2174 /// that still hashes to the `body_digest` its [`TemplateProvenance`] records, the copy
2175 /// records as `body_digest` the digest of the rendering as this rewrote its references,
2176 /// carrying the entry's other three fields; any other entry, or none, is carried
2177 /// verbatim. The answers that rendering was made from, and the `answers_digest` naming
2178 /// them, still hold the pre-copy locations, so the copy is not a fresh rendering of them
2179 /// and a regenerate from them reproduces the pre-copy content.
2180 // llmlint: ignore-end[contracts_have_one_source_or_a_drift_gate]
2181 async fn rewrite_references(
2182 &self,
2183 planned: &mut [Planned],
2184 counts: &mut Counted,
2185 ) -> Result<(), EngineError> {
2186 // Read at the source, once per project rather than once per document: several
2187 // documents of one project is the ordinary case.
2188 let mut by_project: BTreeMap<String, Vec<Referent>> = BTreeMap::new();
2189 let mut named: Vec<Vec<Referent>> = Vec::new();
2190 for item in planned.iter() {
2191 named.push(self.named_referents(item, &mut by_project).await?);
2192 }
2193 // The destination is walked only for a copy that really names something, and only
2194 // for the interfaces those referents are read from — in each source a document of
2195 // this call lands in, which a routed copy decides per document.
2196 let mut levels: BTreeMap<SourceName, Vec<Level>> = BTreeMap::new();
2197 for (item, referents) in planned.iter().zip(&named) {
2198 for referent in referents {
2199 let wanted = levels.entry(item.to.clone()).or_default();
2200 if !wanted.contains(&referent.level) {
2201 wanted.push(referent.level);
2202 }
2203 }
2204 }
2205 if levels.is_empty() {
2206 return Ok(());
2207 }
2208 let mut walked: BTreeMap<SourceName, Counterparts> = BTreeMap::new();
2209 for (to, levels) in levels {
2210 let destination = self.writable(&to)?;
2211 walked.insert(to, self.counterparts(destination, &levels).await?);
2212 }
2213 for (item, referents) in planned.iter_mut().zip(named) {
2214 let Some(counterparts) = walked.get(&item.to) else {
2215 continue;
2216 };
2217 let Item::Document(document) = &mut item.item else {
2218 continue;
2219 };
2220 let Some(content) = &document.content else {
2221 continue;
2222 };
2223 let (rewritten, made) = substitute(content, &table_for(&referents, counterparts));
2224 if made.rewritten > 0
2225 && let Some(provenance) = restamped(&document.metadata, content, &rewritten)
2226 {
2227 document
2228 .metadata
2229 .insert(TemplateProvenance::KEY.to_owned(), provenance.to_value());
2230 }
2231 document.content = Some(rewritten);
2232 counts.add(made);
2233 }
2234 Ok(())
2235 }
2236
2237 /// The records of one document's own project whose location string its content really
2238 /// holds, as a whole reference.
2239 ///
2240 /// A document with no content, or with no project at the source, names nothing: the
2241 /// referent set is the document's own project, its tasks and its other documents, and
2242 /// there is no such set without a project.
2243 async fn named_referents(
2244 &self,
2245 item: &Planned,
2246 by_project: &mut BTreeMap<String, Vec<Referent>>,
2247 ) -> Result<Vec<Referent>, EngineError> {
2248 let Item::Document(document) = &item.item else {
2249 return Ok(Vec::new());
2250 };
2251 let (Some(content), Some(project)) =
2252 (document.content.as_deref(), document.project.as_ref())
2253 else {
2254 return Ok(Vec::new());
2255 };
2256 if content.is_empty() {
2257 return Ok(Vec::new());
2258 }
2259 let project = GlobalId::new(item.source.source.clone(), project.clone());
2260 let key = project.to_string();
2261 if !by_project.contains_key(&key) {
2262 let read = self.referents(&project).await?;
2263 by_project.insert(key.clone(), read);
2264 }
2265 Ok(by_project[&key]
2266 .iter()
2267 // A document does not name itself: the referent set is every *other* record
2268 // filed under the project. Told by the interface as well as the id, because an
2269 // id alone does not identify a record — a folder of Markdown filing `A.md`
2270 // under both `tasks/` and `documents/` is the ordinary case rather than the
2271 // contrived one, and excluding by id alone would drop that task from the set.
2272 .filter(|referent| referent.level != Level::Document || referent.id != item.source)
2273 .filter(|referent| holds(content, &referent.location))
2274 .cloned()
2275 .collect())
2276 }
2277
2278 /// Every record filed under one project at its own source, with the location string
2279 /// that source reports for it.
2280 ///
2281 /// The project record itself, every task filed under it, and every document filed under
2282 /// it. All three are reads this engine already knows how to make.
2283 async fn referents(&self, project: &GlobalId) -> Result<Vec<Referent>, EngineError> {
2284 let source = self.readable(&project.source)?;
2285 let mut referents = Vec::new();
2286 if let Some(held) = source
2287 .source()
2288 .get_project(&project.native)
2289 .await
2290 .map_err(|error| refused(source, error))?
2291 {
2292 note(
2293 &mut referents,
2294 project.clone(),
2295 Level::Project,
2296 held.location.as_ref(),
2297 &held.metadata,
2298 );
2299 }
2300 for task in self.project_member_tasks(project).await? {
2301 note(
2302 &mut referents,
2303 task.id,
2304 Level::Task,
2305 task.item.location.as_ref(),
2306 &task.item.metadata,
2307 );
2308 }
2309 for document in self.project_documents(project).await? {
2310 note(
2311 &mut referents,
2312 document.id,
2313 Level::Document,
2314 document.item.location.as_ref(),
2315 &document.item.metadata,
2316 );
2317 }
2318 Ok(referents)
2319 }
2320
2321 /// Walk the destination once for every record it holds at the levels named, one page
2322 /// at a time.
2323 ///
2324 /// One page is *read* at a time, and what is kept from each is three fields of each
2325 /// record — its id, the origin it records and the location the destination reports —
2326 /// never the page. That is more than [`Engine::scan`] keeps, and deliberately: the whole
2327 /// point of this walk is that one pass answers every document and every referent of the
2328 /// invocation, so what it learns has to outlive the page it learned it from. Nothing is
2329 /// written down and the index is dropped with the call.
2330 ///
2331 /// The other difference from `scan` is the *answer*: this one keeps every match, so a
2332 /// destination holding two records for one work item is reported as ambiguous rather
2333 /// than resolved to the first.
2334 async fn counterparts(
2335 &self,
2336 destination: &ResolvedSource,
2337 levels: &[Level],
2338 ) -> Result<Counterparts, EngineError> {
2339 let mut found = Counterparts::default();
2340 for level in levels {
2341 // Every cursor this level has already been sent. `unrepeated` below catches a
2342 // source that hands back the cursor it was just given, and its own note says
2343 // why it catches no more than that: a source cycling through two cursors
2344 // advances on every page, and seeing it needs memory the walks that share that
2345 // helper do not keep. This walk does keep memory — it is building an index that
2346 // outlives each page — so here the memory exists and the cycle is caught. The
2347 // walks one level up catch the same defect as a page token handed back
2348 // unchanged; this one pages by the source's own cursor and has no level above
2349 // it, so nothing else would.
2350 let mut asked_before: BTreeSet<String> = BTreeSet::new();
2351 let mut cursor: Option<Cursor> = None;
2352 loop {
2353 if let Some(next) = &cursor
2354 && !asked_before.insert(next.0.clone())
2355 {
2356 return Err(refused(
2357 destination,
2358 SourceError::Malformed {
2359 message: "the source returned a cursor it had already been \
2360 given while the destination was being walked for the \
2361 records a document's references name, so the walk \
2362 would never end"
2363 .to_owned(),
2364 },
2365 ));
2366 }
2367 let asked = cursor.clone();
2368 let request = request_for(destination, cursor);
2369 let next = match level {
2370 Level::Task => {
2371 let page = destination
2372 .source()
2373 .query_tasks(&TaskQuery::default(), &request)
2374 .await
2375 .map_err(|error| refused(destination, error))?;
2376 fits(page.items.len(), request.limit)
2377 .map_err(|error| refused(destination, error))?;
2378 for task in &page.items {
2379 found.note(*level, &task.id, task.location.as_ref(), &task.metadata);
2380 }
2381 page.next
2382 }
2383 Level::Project => {
2384 let page = destination
2385 .source()
2386 .query_projects(&ProjectQuery::default(), &request)
2387 .await
2388 .map_err(|error| refused(destination, error))?;
2389 fits(page.items.len(), request.limit)
2390 .map_err(|error| refused(destination, error))?;
2391 for project in &page.items {
2392 found.note(
2393 *level,
2394 &project.id,
2395 project.location.as_ref(),
2396 &project.metadata,
2397 );
2398 }
2399 page.next
2400 }
2401 Level::Document => {
2402 let page = destination
2403 .source()
2404 .query_documents(&DocumentQuery::default(), &request)
2405 .await
2406 .map_err(|error| refused(destination, error))?;
2407 fits(page.items.len(), request.limit)
2408 .map_err(|error| refused(destination, error))?;
2409 for document in &page.items {
2410 found.note(
2411 *level,
2412 &document.id,
2413 document.location.as_ref(),
2414 &document.metadata,
2415 );
2416 }
2417 page.next
2418 }
2419 };
2420 unrepeated(
2421 next.as_ref(),
2422 asked.as_ref(),
2423 "the destination was being walked for the records a document's \
2424 references name",
2425 )
2426 .map_err(|error| refused(destination, error))?;
2427 match next {
2428 Some(next) => cursor = Some(next),
2429 None => break,
2430 }
2431 }
2432 }
2433 Ok(found)
2434 }
2435
2436 /// Destination tasks filed under the copied project whose origin the source no longer
2437 /// holds.
2438 ///
2439 /// A copy never deletes, so each is left exactly as it is and reported.
2440 async fn orphans(
2441 &self,
2442 destination: &ResolvedSource,
2443 project: &GlobalId,
2444 at_destination: &NativeId,
2445 copied: &[GlobalId],
2446 ) -> Result<Vec<CopyOutcome>, EngineError> {
2447 let mut orphans = Vec::new();
2448 let mut cursor: Option<Cursor> = None;
2449 loop {
2450 let asked = cursor.clone();
2451 let request = request_for(destination, cursor);
2452 let page: Page<Task> = destination
2453 .source()
2454 .query_tasks(&TaskQuery::default(), &request)
2455 .await
2456 .map_err(|error| refused(destination, error))?;
2457 fits(page.items.len(), request.limit).map_err(|error| refused(destination, error))?;
2458 for task in &page.items {
2459 if task.project.as_ref() != Some(at_destination) {
2460 continue;
2461 }
2462 let Some(origin) = origin_of(&task.metadata) else {
2463 continue;
2464 };
2465 if origin.source != project.source || copied.contains(&origin) {
2466 continue;
2467 }
2468 orphans.push(CopyOutcome {
2469 source: origin,
2470 action: CopyAction::Orphaned {
2471 destination: GlobalId::new(destination.name().clone(), task.id.clone()),
2472 },
2473 placed: None,
2474 });
2475 }
2476 unrepeated(
2477 page.next.as_ref(),
2478 asked.as_ref(),
2479 "the destination was being read for items the copy left behind",
2480 )
2481 .map_err(|error| refused(destination, error))?;
2482 match page.next {
2483 Some(next) => cursor = Some(next),
2484 None => return Ok(orphans),
2485 }
2486 }
2487 }
2488
2489 /// Resolve and write every item planned, holding back the ones whose edges are not
2490 /// resolvable yet.
2491 ///
2492 /// An edge between two items of one copy can point at a member whose destination id
2493 /// does not exist until it has been created, so the item that points at it lands
2494 /// without that edge and is handed to `deferred`. [`Engine::repair`] finishes it once
2495 /// the *whole* request has landed — not once this call has, because the far end may
2496 /// be in another project of the same command.
2497 async fn copy_items(
2498 &self,
2499 request: &CopyRequest,
2500 mut planned: Vec<Planned>,
2501 project: Option<&Filing>,
2502 running: &mut Running,
2503 journal: &mut Journal,
2504 ) -> Result<Vec<CopyOutcome>, EngineError> {
2505 // Only a document's own content names other records, and only once every document
2506 // of this call has been read: the destination is walked once for all of them, and
2507 // the content the rest of this call lands is the rewritten one — which is what
2508 // makes a repeat copy of an already-rewritten document report `unchanged`.
2509 if planned
2510 .iter()
2511 .any(|item| item.item.level() == Level::Document)
2512 {
2513 self.rewrite_references(&mut planned, &mut running.references)
2514 .await?;
2515 }
2516
2517 for item in &planned {
2518 if let Target::Update { id, .. } = &item.target {
2519 running.counterparts.insert(
2520 item.source.to_string(),
2521 GlobalId::new(item.to.clone(), id.clone()),
2522 );
2523 }
2524 if let Item::Task(task) = &item.item {
2525 running.delivers_rewritten +=
2526 members_named(&task.delivers, &item.source.source, &running.resolvable);
2527 }
2528 }
2529
2530 // Resolved once per item, and used by both passes: the repair pass writes the
2531 // same item again, and re-deriving this there could file it somewhere else.
2532 let mut filed = Vec::new();
2533 for item in &planned {
2534 filed.push(match (project, &item.item) {
2535 (_, Item::Project(_)) => None,
2536 (Some(filing), _) => filing.get(&item.to).cloned(),
2537 (None, _) => self.filed(request, item, None, running, journal).await?,
2538 });
2539 }
2540
2541 let mut outcomes = Vec::new();
2542 let mut unresolved = Vec::new();
2543 let mut priors = Vec::new();
2544 for (index, item) in planned.iter().enumerate() {
2545 let destination = self.writable(&item.to)?;
2546 let edges = mapped_edges(
2547 &item.edges,
2548 &item.source.source,
2549 &item.to,
2550 &running.resolvable,
2551 &running.counterparts,
2552 );
2553 let delivers = delivers_of(item, &item.to, &running.resolvable, &running.counterparts);
2554 if edges.iter().any(Option::is_none) || delivers.iter().any(Option::is_none) {
2555 unresolved.push(index);
2556 }
2557 let resolvable_delivers = resolved_entries(&delivers);
2558 let (outcome, prior) = self
2559 .land(
2560 destination,
2561 request,
2562 item,
2563 filed[index].clone(),
2564 Pointing {
2565 edges: &edges,
2566 delivers: &resolvable_delivers,
2567 },
2568 journal,
2569 )
2570 .await?;
2571 if let Some(id) = outcome.destination() {
2572 running
2573 .counterparts
2574 .insert(item.source.to_string(), id.clone());
2575 if !request.dry_run {
2576 running.linking.push(Linking {
2577 item: item.source.clone(),
2578 level: item.item.level(),
2579 found: item.target.found(),
2580 destination: id.clone(),
2581 held: described(&item.item)
2582 .1
2583 .get(MetadataKey::COPIES_KEY)
2584 .cloned(),
2585 });
2586 }
2587 }
2588 if !request.dry_run
2589 && let (Item::Task(task), Some(landed)) = (&item.item, outcome.destination())
2590 {
2591 running.landed.push(LandedTask {
2592 destination: landed.clone(),
2593 origin: item.source.source.clone(),
2594 delivers: task.delivers.clone(),
2595 before: match prior.as_ref().map(|prior| &prior.item) {
2596 Some(Item::Task(held)) => targets(&held.delivers, destination.name()),
2597 _ => Vec::new(),
2598 },
2599 category: task.status.category,
2600 });
2601 }
2602 outcomes.push(outcome);
2603 priors.push(prior);
2604 }
2605
2606 if !request.dry_run {
2607 for (index, item) in planned.into_iter().enumerate() {
2608 if !unresolved.contains(&index) {
2609 continue;
2610 }
2611 // Every item a copy that is not a dry run lands has a destination id: the
2612 // one outcome without one is a dry run that would have created, and this
2613 // block does not run for a dry run.
2614 let id = outcomes[index]
2615 .destination()
2616 .expect("a copy that writes lands every item it planned")
2617 .clone();
2618 running.deferred.push(Deferred {
2619 item,
2620 filed: filed[index].clone(),
2621 destination: id.native,
2622 prior: priors[index].clone(),
2623 });
2624 }
2625 }
2626 Ok(outcomes)
2627 }
2628
2629 /// Read one item, place it, read its forward edges, and decide where it is going.
2630 ///
2631 /// A task is placed by its own repositories; a document goes with its project's home.
2632 async fn plan(
2633 &self,
2634 request: &CopyRequest,
2635 kind: Level,
2636 id: &GlobalId,
2637 running: &mut Running,
2638 ) -> Result<Planned, EngineError> {
2639 let read = self.read(kind, id).await?;
2640 let placement = match &read.item {
2641 Item::Task(task) => self.routes.place(&request.destination, &task.repositories),
2642 Item::Document(document) => {
2643 self.document_placement(request, &read.source, document, running)
2644 .await?
2645 }
2646 Item::Project(project) => self
2647 .routes
2648 .place(&request.destination, &project.repositories),
2649 };
2650 self.aim(request, read, placement).await
2651 }
2652
2653 /// Read one item out of its own source.
2654 async fn read(&self, kind: Level, id: &GlobalId) -> Result<Read, EngineError> {
2655 let source = self.readable(&id.source)?;
2656 if kind == Level::Document {
2657 documentary(source)?;
2658 }
2659 let item = match kind {
2660 Level::Task => source
2661 .source()
2662 .get_task(&id.native)
2663 .await
2664 .map_err(|error| refused(source, error))?
2665 .map(|task| Item::Task(Box::new(task))),
2666 Level::Project => source
2667 .source()
2668 .get_project(&id.native)
2669 .await
2670 .map_err(|error| refused(source, error))?
2671 .map(|project| Item::Project(Box::new(project))),
2672 Level::Document => source
2673 .source()
2674 .get_document(&id.native)
2675 .await
2676 .map_err(|error| refused(source, error))?
2677 .map(|document| Item::Document(Box::new(document))),
2678 }
2679 .ok_or_else(|| EngineError::NoSuchItem { id: id.to_string() })?;
2680 Ok(Read {
2681 source: id.clone(),
2682 item,
2683 })
2684 }
2685
2686 /// Decide where one read item is going, once it is known which source it lands in.
2687 async fn aim(
2688 &self,
2689 request: &CopyRequest,
2690 read: Read,
2691 placement: Placement,
2692 ) -> Result<Planned, EngineError> {
2693 let Read { source: id, item } = read;
2694 let destination = self.writable(&placement.destination)?;
2695 if item.level() == Level::Document {
2696 documentary(destination)?;
2697 }
2698 // Before the destination is read, and so before it is written: a destination that
2699 // holds no priority has nowhere to put one, and every item of a copy is planned before
2700 // any of them lands. A task carrying `none` passes and writes exactly as it always did.
2701 if let Item::Task(task) = &item {
2702 holds_priority(destination, &id.to_string(), task.priority)?;
2703 }
2704 let routed = self.routes.routes(&request.destination);
2705 // A task or a document whose counterpart already sits somewhere a route could have
2706 // sent it, other than where it routes now, was moved by a repository changed after
2707 // the copy. That is a person's decision rather than a silent move, so it is refused
2708 // before anything is written. A project's home is exempt: it stays where it landed.
2709 if routed && item.level() != Level::Project {
2710 self.misrouted(request, &id, &item, &placement.destination)
2711 .await?;
2712 }
2713 let source = self.readable(&id.source)?;
2714 let edges = forward_edges(source, &id.native, item.level()).await?;
2715 let (target, held) = self.target(destination, request, &id, &item).await?;
2716 Ok(Planned {
2717 source: id,
2718 item,
2719 edges,
2720 target,
2721 held,
2722 to: placement.destination.clone(),
2723 placed: routed.then_some(placement),
2724 })
2725 }
2726
2727 /// Refuse an item whose existing counterpart, by its link or its origin, sits in a source
2728 /// its copy could reach other than `to`, the one it routes to now.
2729 async fn misrouted(
2730 &self,
2731 request: &CopyRequest,
2732 id: &GlobalId,
2733 item: &Item,
2734 to: &SourceName,
2735 ) -> Result<(), EngineError> {
2736 let metadata = described(item).1;
2737 for elsewhere in self.routes.reachable(&request.destination) {
2738 if &elsewhere == to {
2739 continue;
2740 }
2741 let there = self.writable(&elsewhere)?;
2742 let linked = match link_of(metadata, &elsewhere) {
2743 Some(link) => self
2744 .prior(there, item.level(), &link.native)
2745 .await?
2746 .filter(|held| origin_of(described(&held.item).1).as_ref() == Some(id))
2747 .map(|_| link),
2748 None => None,
2749 };
2750 let originated = match origin_of(metadata).filter(|origin| origin.source == elsewhere) {
2751 Some(origin) => self
2752 .prior(there, item.level(), &origin.native)
2753 .await?
2754 .map(|_| origin),
2755 None => None,
2756 };
2757 if let Some(counterpart) = linked.or(originated) {
2758 return Err(EngineError::Misrouted {
2759 item: id.clone(),
2760 counterpart,
2761 route: to.clone(),
2762 });
2763 }
2764 }
2765 Ok(())
2766 }
2767
2768 /// Where a document lands: with its project's home, wherever that is; by its own
2769 /// repositories when its project has none yet, or it is in no project.
2770 async fn document_placement(
2771 &self,
2772 request: &CopyRequest,
2773 id: &GlobalId,
2774 document: &Document,
2775 running: &mut Running,
2776 ) -> Result<Placement, EngineError> {
2777 if self.routes.routes(&request.destination)
2778 && let Some(project) = &document.project
2779 {
2780 let qualified = GlobalId::new(id.source.clone(), project.clone());
2781 if let Some(home) = self.find_home(request, &qualified, running).await? {
2782 return Ok(self.placed_at(request, &home.source));
2783 }
2784 }
2785 Ok(self
2786 .routes
2787 .place(&request.destination, &document.repositories))
2788 }
2789
2790 /// What the report says placed an item in `at`: the first route entry sending there, or
2791 /// none for the copy's own destination.
2792 fn placed_at(&self, request: &CopyRequest, at: &SourceName) -> Placement {
2793 Placement {
2794 destination: at.clone(),
2795 route: self
2796 .routes
2797 .of(&request.destination)
2798 .iter()
2799 .position(|route| route.to() == at)
2800 .map(|index| u32::try_from(index).unwrap_or(u32::MAX)),
2801 }
2802 }
2803
2804 /// Which destination item this one corresponds to, by the link the item records, the
2805 /// two origin rules and the caller's escape, and what the destination holds there.
2806 async fn target(
2807 &self,
2808 destination: &ResolvedSource,
2809 request: &CopyRequest,
2810 id: &GlobalId,
2811 item: &Item,
2812 ) -> Result<(Target, Option<Prior>), EngineError> {
2813 let (title, metadata) = described(item);
2814 // A caller asserting there is nothing to find is believed, and nothing is looked for —
2815 // unless the item itself says otherwise, by naming a counterpart at the destination.
2816 if request.create {
2817 let carrier = link_of(metadata, destination.name()).or_else(|| {
2818 origin_of(metadata).filter(|origin| &origin.source == destination.name())
2819 });
2820 if let Some(carrier) = carrier {
2821 return Err(EngineError::CreateCarried {
2822 item: id.clone(),
2823 carrier,
2824 });
2825 }
2826 return Ok((Target::Create, None));
2827 }
2828 // The link first, and it is believed only when the item it names still names this
2829 // one back: a person who re-pointed that item's origin has said it is not this
2830 // one's counterpart any more, so the link is ignored and rewritten to whatever the
2831 // rules below find.
2832 if let Some(link) = link_of(metadata, destination.name()) {
2833 match self.prior(destination, item.level(), &link.native).await? {
2834 Some(held) if origin_of(described(&held.item).1).as_ref() == Some(id) => {
2835 return Ok((
2836 Target::Update {
2837 id: link.native,
2838 found: Found::Link,
2839 },
2840 Some(held),
2841 ));
2842 }
2843 Some(_) => {}
2844 None if request.recreate => {}
2845 None => {
2846 return Err(EngineError::StaleLink {
2847 item: id.to_string(),
2848 link: link.to_string(),
2849 });
2850 }
2851 }
2852 }
2853 if let Some(origin) = origin_of(metadata)
2854 && &origin.source == destination.name()
2855 {
2856 // The read that says the origin still names something is the read of what it
2857 // holds, so rule 1 costs one round trip rather than two.
2858 if let Some(held) = self
2859 .prior(destination, item.level(), &origin.native)
2860 .await?
2861 {
2862 return Ok((
2863 Target::Update {
2864 id: origin.native,
2865 found: Found::Origin,
2866 },
2867 Some(held),
2868 ));
2869 }
2870 if !request.recreate {
2871 return Err(EngineError::StaleOrigin {
2872 item: id.to_string(),
2873 origin: origin.to_string(),
2874 });
2875 }
2876 }
2877 if let Some(found) = self
2878 .scan(destination, item.level(), &Wanted::Origin(id.to_string()))
2879 .await?
2880 {
2881 let held = self.prior(destination, item.level(), &found).await?;
2882 return Ok((
2883 Target::Update {
2884 id: found,
2885 found: Found::Scan,
2886 },
2887 held,
2888 ));
2889 }
2890 let wanted = match &request.match_by {
2891 Some(MatchBy::Title) => Some(Wanted::Title(title.to_owned())),
2892 Some(MatchBy::Metadata(key)) => metadata
2893 .get(key)
2894 .map(|value| Wanted::Metadata(key.clone(), value.clone())),
2895 None => None,
2896 };
2897 if let Some(wanted) = wanted
2898 && let Some(found) = self.scan(destination, item.level(), &wanted).await?
2899 {
2900 let held = self.prior(destination, item.level(), &found).await?;
2901 return Ok((
2902 Target::Update {
2903 id: found,
2904 found: Found::Match,
2905 },
2906 held,
2907 ));
2908 }
2909 Ok((Target::Create, None))
2910 }
2911
2912 /// Walk the destination one page at a time, looking for `wanted`.
2913 ///
2914 /// One page is held at a time and nothing is written down, which is the same bound
2915 /// every other compensation in this engine works under.
2916 async fn scan(
2917 &self,
2918 destination: &ResolvedSource,
2919 kind: Level,
2920 wanted: &Wanted,
2921 ) -> Result<Option<NativeId>, EngineError> {
2922 let mut cursor: Option<Cursor> = None;
2923 let tasks = wanted.task_query(&destination.source().capabilities());
2924 loop {
2925 let asked = cursor.clone();
2926 let request = request_for(destination, cursor);
2927 let next = match kind {
2928 Level::Task => {
2929 let page = destination
2930 .source()
2931 .query_tasks(&tasks, &request)
2932 .await
2933 .map_err(|error| refused(destination, error))?;
2934 fits(page.items.len(), request.limit)
2935 .map_err(|error| refused(destination, error))?;
2936 for task in &page.items {
2937 if wanted.found(&task.title, &task.metadata) {
2938 return Ok(Some(task.id.clone()));
2939 }
2940 }
2941 page.next
2942 }
2943 Level::Project => {
2944 let page = destination
2945 .source()
2946 .query_projects(&ProjectQuery::default(), &request)
2947 .await
2948 .map_err(|error| refused(destination, error))?;
2949 fits(page.items.len(), request.limit)
2950 .map_err(|error| refused(destination, error))?;
2951 for project in &page.items {
2952 if wanted.found(&project.title, &project.metadata) {
2953 return Ok(Some(project.id.clone()));
2954 }
2955 }
2956 page.next
2957 }
2958 Level::Document => {
2959 let page = destination
2960 .source()
2961 .query_documents(&DocumentQuery::default(), &request)
2962 .await
2963 .map_err(|error| refused(destination, error))?;
2964 fits(page.items.len(), request.limit)
2965 .map_err(|error| refused(destination, error))?;
2966 for document in &page.items {
2967 if wanted.found(&document.title, &document.metadata) {
2968 return Ok(Some(document.id.clone()));
2969 }
2970 }
2971 page.next
2972 }
2973 };
2974 unrepeated(
2975 next.as_ref(),
2976 asked.as_ref(),
2977 "the destination was being scanned for the item to update",
2978 )
2979 .map_err(|error| refused(destination, error))?;
2980 match next {
2981 Some(next) => cursor = Some(next),
2982 None => return Ok(None),
2983 }
2984 }
2985 }
2986
2987 /// Write one planned item, or say what a dry run would have done.
2988 ///
2989 /// Answers with what the destination held there beforehand as well, which is what
2990 /// makes an item written twice restorable to what it was rather than to what this
2991 /// copy's first pass left.
2992 async fn land(
2993 &self,
2994 destination: &ResolvedSource,
2995 request: &CopyRequest,
2996 item: &Planned,
2997 project: Option<NativeId>,
2998 pointing: Pointing<'_>,
2999 journal: &mut Journal,
3000 ) -> Result<(CopyOutcome, Option<Prior>), EngineError> {
3001 let Pointing { edges, delivers } = pointing;
3002 let (target, found) = match &item.target {
3003 Target::Update { id, found } => (Some(id.clone()), Some(*found)),
3004 Target::Create => (None, None),
3005 };
3006 // Every link is settled once the whole copy has landed, by `Engine::link`, so none
3007 // is known here.
3008 let reached = |destination: Option<GlobalId>, changed: bool| match (found, destination) {
3009 (Some(via), Some(destination)) if changed => CopyAction::Updated {
3010 destination,
3011 via: via.via(),
3012 link: None,
3013 },
3014 (Some(via), Some(destination)) => CopyAction::Unchanged {
3015 destination,
3016 via: via.via(),
3017 link: None,
3018 },
3019 (_, destination) => CopyAction::Created {
3020 destination,
3021 via: NoCounterpart::Created,
3022 link: None,
3023 },
3024 };
3025 // The one read of the destination item, made where its target was found, used to
3026 // decide whether the write changes anything and — if the copy cannot finish — to
3027 // put that item back.
3028 let prior = item.held.clone();
3029 let edges = resolved(edges);
3030 let qualified = |native: NativeId| GlobalId::new(destination.name().clone(), native);
3031 if let Some(id) = &target
3032 && !changes(
3033 prior.as_ref(),
3034 item,
3035 id,
3036 &project,
3037 &edges,
3038 delivers,
3039 destination.name(),
3040 )
3041 {
3042 return Ok((
3043 CopyOutcome {
3044 source: item.source.clone(),
3045 action: reached(Some(qualified(id.clone())), false),
3046 placed: item.placed.clone(),
3047 },
3048 prior,
3049 ));
3050 }
3051 if request.dry_run {
3052 return Ok((
3053 CopyOutcome {
3054 source: item.source.clone(),
3055 // Null only for a create here: nothing was created, so there is no id to
3056 // report.
3057 action: reached(target.map(qualified), true),
3058 placed: item.placed.clone(),
3059 },
3060 prior,
3061 ));
3062 }
3063 let written = qualified(
3064 self.write(
3065 destination,
3066 item,
3067 target,
3068 project,
3069 &edges,
3070 delivers,
3071 prior.clone(),
3072 journal,
3073 )
3074 .await?,
3075 );
3076 Ok((
3077 CopyOutcome {
3078 source: item.source.clone(),
3079 action: reached(Some(written), true),
3080 placed: item.placed.clone(),
3081 },
3082 prior,
3083 ))
3084 }
3085
3086 /// Which destination project this item is filed under, when it is filed at all.
3087 ///
3088 /// A task copied as part of a project copy is filed under that project's counterpart,
3089 /// which the copy has just established. A task copied on its own has to find it.
3090 async fn filed(
3091 &self,
3092 request: &CopyRequest,
3093 item: &Planned,
3094 project: Option<NativeId>,
3095 running: &mut Running,
3096 journal: &mut Journal,
3097 ) -> Result<Option<NativeId>, EngineError> {
3098 match (&item.item, project) {
3099 (Item::Task(task), None) => {
3100 self.counterpart(request, item, task.project.as_ref(), running, journal)
3101 .await
3102 }
3103 (Item::Document(document), None) => {
3104 self.counterpart(request, item, document.project.as_ref(), running, journal)
3105 .await
3106 }
3107 (Item::Task(_) | Item::Document(_), filed) => Ok(filed),
3108 (Item::Project(_), _) => Ok(None),
3109 }
3110 }
3111
3112 /// The destination project this task's own project corresponds to, when there is one.
3113 ///
3114 /// A task copied on its own keeps its source's project id when the destination holds
3115 /// no counterpart: the field is opaque to this engine, and dropping it would lose
3116 /// what the source said.
3117 ///
3118 /// Looked for once per source project and landing source per command. A project this
3119 /// command itself copied is already known, and one an earlier task of this command was
3120 /// filed under was already looked for; walking the destination again for either would
3121 /// spend a scan per task on an answer the command holds. A project whose own link names
3122 /// its counterpart there is found by that link — see [`Engine::linked_project`] — and
3123 /// the destination is walked only when it does not.
3124 ///
3125 /// A routed item whose project's home landed in another source than it does is filed
3126 /// under that home's member project in its own source — see [`Engine::member_in`].
3127 async fn counterpart(
3128 &self,
3129 request: &CopyRequest,
3130 item: &Planned,
3131 project: Option<&NativeId>,
3132 running: &mut Running,
3133 journal: &mut Journal,
3134 ) -> Result<Option<NativeId>, EngineError> {
3135 let Some(project) = project else {
3136 return Ok(None);
3137 };
3138 let qualified = GlobalId::new(item.source.source.clone(), project.clone());
3139 let key = (qualified.to_string(), item.to.clone());
3140 if let Some(looked) = running.filings.get(&key) {
3141 return Ok(looked.clone());
3142 }
3143 let filed = match self.find_home(request, &qualified, running).await? {
3144 Some(home) if home.source == item.to => Some(home.native),
3145 Some(home) => {
3146 self.member_in(request.dry_run, &home, &item.to, running, journal)
3147 .await?
3148 }
3149 None => Some(project.clone()),
3150 };
3151 running.filings.insert(key, filed.clone());
3152 Ok(filed)
3153 }
3154
3155 /// The counterpart a source project has in some source this copy can reach — its home —
3156 /// when it has one: the one this command landed, the one its own link names, or the one a
3157 /// walk of each reachable source finds recording it as its origin.
3158 async fn find_home(
3159 &self,
3160 request: &CopyRequest,
3161 project: &GlobalId,
3162 running: &Running,
3163 ) -> Result<Option<GlobalId>, EngineError> {
3164 if let Some(landed) = running.counterparts.get(&project.to_string()) {
3165 return Ok(Some(landed.clone()));
3166 }
3167 if let Some(linked) = self.linked_project(request, project).await? {
3168 return Ok(Some(linked));
3169 }
3170 for reachable in self.routes.reachable(&request.destination) {
3171 let there = self.writable(&reachable)?;
3172 if let Some(found) = self
3173 .scan(there, Level::Project, &Wanted::Origin(project.to_string()))
3174 .await?
3175 {
3176 return Ok(Some(GlobalId::new(reachable, found)));
3177 }
3178 }
3179 Ok(None)
3180 }
3181
3182 /// The member project `home` names in `to`, creating it when it names none there.
3183 ///
3184 /// Found through the home's `onetaskgraph.members`, read once per command. A member it
3185 /// names that `to` no longer holds is replaced rather than refused: the member exists
3186 /// only to file the home's routed tasks, and nothing a person wrote lives on it. A dry
3187 /// run creates nothing, so a member it would create files its tasks under nothing.
3188 async fn member_in(
3189 &self,
3190 dry_run: bool,
3191 home: &GlobalId,
3192 to: &SourceName,
3193 running: &mut Running,
3194 journal: &mut Journal,
3195 ) -> Result<Option<NativeId>, EngineError> {
3196 let key = home.to_string();
3197 if !running.homes.contains_key(&key) {
3198 let at = self.writable(&home.source)?;
3199 let Some(held) = at
3200 .source()
3201 .get_project(&home.native)
3202 .await
3203 .map_err(|error| refused(at, error))?
3204 else {
3205 return Ok(None);
3206 };
3207 running
3208 .homes
3209 .insert(key.clone(), Home::of(home.clone(), &held)?);
3210 }
3211 let named = running.homes[&key]
3212 .members
3213 .iter()
3214 .find(|member| &member.source == to)
3215 .cloned();
3216 let there = self.writable(to)?;
3217 if let Some(member) = named
3218 && there
3219 .source()
3220 .get_project(&member.native)
3221 .await
3222 .map_err(|error| refused(there, error))?
3223 .is_some()
3224 {
3225 return Ok(Some(member.native));
3226 }
3227 if dry_run {
3228 return Ok(None);
3229 }
3230 let held = &running.homes[&key];
3231 let member = Project {
3232 id: home.native.clone(),
3233 title: held.title.clone(),
3234 content: None,
3235 status: held.status.clone(),
3236 labels: Vec::new(),
3237 url: None,
3238 location: None,
3239 created_at: None,
3240 updated_at: None,
3241 metadata: BTreeMap::from([(
3242 MetadataKey::MEMBER_OF_KEY.to_owned(),
3243 Value::String(home.to_string()),
3244 )]),
3245 repositories: Vec::new(),
3246 };
3247 let created = there
3248 .source()
3249 .write_project(&ItemWrite {
3250 target: None,
3251 item: member,
3252 depends_on: Vec::new(),
3253 })
3254 .await
3255 .map_err(|error| refused(there, error))?;
3256 journal.record(Undo::Created {
3257 at: to.clone(),
3258 kind: Level::Project,
3259 id: created.clone(),
3260 });
3261 let entry = running
3262 .homes
3263 .get_mut(&key)
3264 .expect("the home was recorded above");
3265 entry.members.retain(|member| &member.source != to);
3266 entry
3267 .members
3268 .push(GlobalId::new(to.clone(), created.clone()));
3269 entry.grew = true;
3270 Ok(Some(created))
3271 }
3272
3273 /// Write each home's grown `onetaskgraph.members` through its own project write, once the
3274 /// whole copy has landed.
3275 ///
3276 /// The home is read as it now stands and written back with that one entry changed, and
3277 /// the read is what the journal restores when the copy cannot finish — unless the copy
3278 /// already holds an earlier entry for it, which is what it was before this copy began.
3279 async fn record_members(
3280 &self,
3281 homes: BTreeMap<String, Home>,
3282 journal: &mut Journal,
3283 ) -> Result<(), EngineError> {
3284 for home in homes.into_values().filter(|home| home.grew) {
3285 let at = self.writable(&home.id.source)?;
3286 let Some(prior) = self.prior(at, Level::Project, &home.id.native).await? else {
3287 continue;
3288 };
3289 let Item::Project(mut project) = prior.item.clone() else {
3290 continue;
3291 };
3292 project.metadata.insert(
3293 MetadataKey::MEMBERS_KEY.to_owned(),
3294 Value::Array(
3295 home.members
3296 .iter()
3297 .map(|member| Value::String(member.to_string()))
3298 .collect(),
3299 ),
3300 );
3301 let edges = prior.edges.clone();
3302 journal.record(Undo::Updated {
3303 at: home.id.source.clone(),
3304 id: home.id.native.clone(),
3305 prior,
3306 });
3307 at.source()
3308 .write_project(&ItemWrite {
3309 target: Some(home.id.native.clone()),
3310 item: *project,
3311 depends_on: edges,
3312 })
3313 .await
3314 .map_err(|error| refused(at, error))?;
3315 }
3316 Ok(())
3317 }
3318
3319 /// The home project a source project's own `onetaskgraph.copies` names in a source this
3320 /// copy can reach, when the project it names there still records that source project as
3321 /// its origin.
3322 ///
3323 /// The filing lookup's half of following a link: one read of the project at its source
3324 /// and one of its counterpart by id, in place of a walk of the destination's projects.
3325 /// A link that names nothing there, or names a project somebody re-pointed, is not a
3326 /// refusal here as it is for a copy's own target — this only decides where an item is
3327 /// filed — so the walk answers instead, exactly as it did before there were links.
3328 async fn linked_project(
3329 &self,
3330 request: &CopyRequest,
3331 project: &GlobalId,
3332 ) -> Result<Option<GlobalId>, EngineError> {
3333 let source = self.readable(&project.source)?;
3334 if !source.source().capabilities().projects.is_native() {
3335 return Ok(None);
3336 }
3337 let Some(held) = source
3338 .source()
3339 .get_project(&project.native)
3340 .await
3341 .map_err(|error| refused(source, error))?
3342 else {
3343 return Ok(None);
3344 };
3345 for reachable in self.routes.reachable(&request.destination) {
3346 let Some(link) = link_of(&held.metadata, &reachable) else {
3347 continue;
3348 };
3349 let there = self.writable(&reachable)?;
3350 let named = there
3351 .source()
3352 .get_project(&link.native)
3353 .await
3354 .map_err(|error| refused(there, error))?;
3355 if named.is_some_and(|there| origin_of(&there.metadata).as_ref() == Some(project)) {
3356 return Ok(Some(link));
3357 }
3358 }
3359 Ok(None)
3360 }
3361
3362 /// What the destination holds at one id, item and forward edges together.
3363 ///
3364 /// One read for both purposes it serves — deciding whether a write changes anything,
3365 /// and putting the item back if the copy cannot finish — because a second read of the
3366 /// same item is a second round trip against a hosted destination for nothing.
3367 async fn prior(
3368 &self,
3369 destination: &ResolvedSource,
3370 kind: Level,
3371 id: &NativeId,
3372 ) -> Result<Option<Prior>, EngineError> {
3373 let held = match kind {
3374 Level::Task => destination
3375 .source()
3376 .get_task(id)
3377 .await
3378 .map_err(|error| refused(destination, error))?
3379 .map(|task| Item::Task(Box::new(task))),
3380 Level::Project => destination
3381 .source()
3382 .get_project(id)
3383 .await
3384 .map_err(|error| refused(destination, error))?
3385 .map(|project| Item::Project(Box::new(project))),
3386 Level::Document => destination
3387 .source()
3388 .get_document(id)
3389 .await
3390 .map_err(|error| refused(destination, error))?
3391 .map(|document| Item::Document(Box::new(document))),
3392 };
3393 let Some(item) = held else {
3394 return Ok(None);
3395 };
3396 let edges = forward_edges(destination, id, kind).await?;
3397 Ok(Some(Prior { item, edges }))
3398 }
3399
3400 /// Hand one item to the destination's own write interface, recording how to take it
3401 /// back.
3402 // llmlint: ignore[suppressions_justified] A write is the item, where it is going, what
3403 // it is filed under, its edges, what was there before and the journal that records how
3404 // to put it back. Each is a distinct decision made by a different part of the copy, and
3405 // grouping them would only move the argument list to a constructor.
3406 #[allow(clippy::too_many_arguments)]
3407 async fn write(
3408 &self,
3409 destination: &ResolvedSource,
3410 item: &Planned,
3411 target: Option<NativeId>,
3412 project: Option<NativeId>,
3413 edges: &[DependencyEdge],
3414 delivers: &[TaskRef],
3415 prior: Option<Prior>,
3416 journal: &mut Journal,
3417 ) -> Result<NativeId, EngineError> {
3418 let created_kind = item.item.level();
3419 let suggested = target
3420 .clone()
3421 .unwrap_or_else(|| created_id(&item.item, project.as_ref()));
3422 // Settled before the journal takes `prior`, and from that same read: what the
3423 // destination holds at the origin key is what a copy-back leaves there, and what it
3424 // holds as `delivered_by` is what the item keeps.
3425 let origin = recorded(item, prior.as_ref());
3426 let landing = outgoing(item, suggested, project, &origin, delivers, prior.as_ref());
3427 // Recorded *before* the write rather than after it. A destination's own write is
3428 // several calls — `docs/plugin-protocol.md` §4.9 — and one of them failing leaves
3429 // the ones before it applied. No source can put those back, because only this
3430 // journal holds what was there; recorded after a successful write, an update that
3431 // stopped part way was the one way a copy could end and leave the destination
3432 // altered. A restore of an item the write never reached rewrites what is already
3433 // there, which costs one mutation and is what "either complete or it never
3434 // happened" is worth.
3435 if let (Some(id), Some(prior)) = (target.clone(), prior) {
3436 journal.record(Undo::Updated {
3437 at: destination.name().clone(),
3438 id,
3439 prior,
3440 });
3441 }
3442 let landed = match landing {
3443 Item::Task(task) => destination
3444 .source()
3445 .write_task(&ItemWrite {
3446 target: target.clone(),
3447 item: *task,
3448 depends_on: edges.to_vec(),
3449 })
3450 .await
3451 .map_err(|error| refused(destination, error))?,
3452 Item::Project(project) => destination
3453 .source()
3454 .write_project(&ItemWrite {
3455 target: target.clone(),
3456 item: *project,
3457 depends_on: edges.to_vec(),
3458 })
3459 .await
3460 .map_err(|error| refused(destination, error))?,
3461 // No edges, and that is the contract: a document takes part in no dependency
3462 // graph, so there is nothing here for `depends_on` to carry.
3463 Item::Document(document) => destination
3464 .source()
3465 .write_document(&ItemWrite {
3466 target: target.clone(),
3467 item: *document,
3468 depends_on: Vec::new(),
3469 })
3470 .await
3471 .map_err(|error| refused(destination, error))?,
3472 };
3473 // A created item can only be journalled here: its id is what the write answers
3474 // with. A create that fails leaves nothing behind — §4.9 makes taking the item
3475 // back the source's own duty, because a write that refused must not leave an item
3476 // nobody asked for.
3477 if target.is_none() {
3478 journal.record(Undo::Created {
3479 at: destination.name().clone(),
3480 kind: created_kind,
3481 id: landed.clone(),
3482 });
3483 }
3484 Ok(landed)
3485 }
3486
3487 /// A configured source that built, for reading an item out of.
3488 fn readable(&self, name: &SourceName) -> Result<&ResolvedSource, EngineError> {
3489 let name = self.known(name)?;
3490 if let Some(unavailable) = self.unavailable().find(|source| source.name() == &name) {
3491 return Err(EngineError::SourceRefused {
3492 name: name.to_string(),
3493 error: unavailable.error().clone(),
3494 });
3495 }
3496 self.ready()
3497 .find(|source| source.name() == &name)
3498 .ok_or(EngineError::NoSources)
3499 }
3500}
3501
3502/// How many tasks of a project are read at once while walking it.
3503const PROJECT_PAGE: std::num::NonZeroU32 = std::num::NonZeroU32::new(50).expect("50 is not zero");
3504
3505/// Each source's own running totals, in the order the sources were given.
3506///
3507/// A reading a source could not take is read as that source not metering: what a command
3508/// spent is a report about the work, and a failed reading must not become a failure of the
3509/// work itself. That holds when only one of a source's two readings failed as well, since
3510/// a difference needs both ends.
3511pub(super) async fn readings(sources: &[&ResolvedSource]) -> Vec<Option<Metering>> {
3512 let mut read = Vec::with_capacity(sources.len());
3513 for source in sources {
3514 read.push(source.source().metering().await.ok().flatten());
3515 }
3516 read
3517}
3518
3519/// What the sources spent between two readings of them, or `None` when none of them meters.
3520///
3521/// A source that does not meter contributes nothing and does not make the total zero: a
3522/// command whose sources all declined reports that it cannot say, not that it spent nothing.
3523/// A budget is keyed by its name and unit together, so two sources naming one budget add up
3524/// and two units of one budget stay apart.
3525///
3526/// A source's two readings are a plugin's word, so they are held to [`Metering`]'s contract
3527/// before either is believed, and a pair that breaks it is that source not metering — see
3528/// [`difference`].
3529pub(super) fn spent_between(
3530 before: &[Option<Metering>],
3531 after: &[Option<Metering>],
3532) -> Option<Spent> {
3533 let mut metered = false;
3534 let mut requests = 0_u64;
3535 let mut budgets: BTreeMap<(String, String), (u64, u64)> = BTreeMap::new();
3536 for (before, after) in before.iter().zip(after) {
3537 let (Some(before), Some(after)) = (before, after) else {
3538 continue;
3539 };
3540 let Some((sent, spent)) = difference(before, after) else {
3541 continue;
3542 };
3543 metered = true;
3544 requests = requests.saturating_add(sent);
3545 for (key, measured, modelled) in spent {
3546 let total = budgets.entry(key).or_default();
3547 total.0 = total.0.saturating_add(measured).saturating_add(modelled);
3548 total.1 = total.1.saturating_add(modelled);
3549 }
3550 }
3551 metered.then(|| Spent {
3552 requests,
3553 budgets: budgets
3554 .into_iter()
3555 .map(|((budget, unit), (amount, modelled))| BudgetSpent {
3556 budget,
3557 unit,
3558 amount,
3559 lower_bound: modelled > 0,
3560 })
3561 .collect(),
3562 })
3563}
3564
3565/// One budget's name and unit, and the measured and modelled amounts spent against it.
3566type BudgetDifference = ((String, String), u64, u64);
3567
3568/// What one source sent and spent between two of its readings, or `None` when the pair
3569/// cannot be a running total's.
3570///
3571/// A running total never falls, never drops a budget it has named, and names every budget
3572/// it keeps, once; a pair that breaks any of that is a source that reset its figures or
3573/// reported something else, and a difference taken over it would be a number that measures
3574/// nothing. Such a source is reported as not metering rather than as having spent a clamped
3575/// zero.
3576fn difference(before: &Metering, after: &Metering) -> Option<(u64, Vec<BudgetDifference>)> {
3577 let sent = after.requests.checked_sub(before.requests)?;
3578 for reading in [before, after] {
3579 let mut named = BTreeSet::new();
3580 for budget in &reading.budgets {
3581 if budget.budget.is_empty()
3582 || budget.unit.is_empty()
3583 || !named.insert((&budget.budget, &budget.unit))
3584 {
3585 return None;
3586 }
3587 }
3588 }
3589 let held = |from: &Metering, budget: &onetaskgraph_plugin_api::Metered| {
3590 from.budgets
3591 .iter()
3592 .find(|held| held.budget == budget.budget && held.unit == budget.unit)
3593 .cloned()
3594 };
3595 if before
3596 .budgets
3597 .iter()
3598 .any(|budget| held(after, budget).is_none())
3599 {
3600 return None;
3601 }
3602 let mut spent = Vec::with_capacity(after.budgets.len());
3603 for budget in &after.budgets {
3604 let earlier = held(before, budget);
3605 let measured = budget
3606 .measured
3607 .checked_sub(earlier.as_ref().map_or(0, |held| held.measured))?;
3608 let modelled = budget
3609 .modelled
3610 .checked_sub(earlier.as_ref().map_or(0, |held| held.modelled))?;
3611 spent.push((
3612 (budget.budget.clone(), budget.unit.clone()),
3613 measured,
3614 modelled,
3615 ));
3616 }
3617 Some((sent, spent))
3618}
3619
3620/// One page request against `source`, at the largest page it will serve.
3621fn request_for(source: &ResolvedSource, cursor: Option<Cursor>) -> PageRequest {
3622 PageRequest {
3623 cursor,
3624 limit: source.source().capabilities().max_page_size.max(1),
3625 }
3626}
3627
3628/// Whether writing this item would change what the destination already holds.
3629///
3630/// A free function over the state already read rather than a method that reads it again:
3631/// the same answer is wanted where the item is landed and where a repeat copy of a project
3632/// decides whether it settled, and a second read there is a second round trip for nothing.
3633fn changes(
3634 held: Option<&Prior>,
3635 item: &Planned,
3636 target: &NativeId,
3637 project: &Option<NativeId>,
3638 edges: &[DependencyEdge],
3639 delivers: &[TaskRef],
3640 destination: &SourceName,
3641) -> bool {
3642 let Some(held) = held else {
3643 return true;
3644 };
3645 let outgoing = outgoing(
3646 item,
3647 target.clone(),
3648 project.clone(),
3649 &recorded(item, Some(held)),
3650 delivers,
3651 Some(held),
3652 );
3653 !same(&held.item, &outgoing, destination) || !same_edges(&held.edges, edges)
3654}
3655
3656/// Remove one item this copy created, through the destination's own write interface.
3657async fn remove(
3658 destination: &ResolvedSource,
3659 kind: Level,
3660 id: &NativeId,
3661) -> Result<(), SourceError> {
3662 match kind {
3663 Level::Task => destination.source().delete_task(id).await,
3664 Level::Project => destination.source().delete_project(id).await,
3665 Level::Document => destination.source().delete_document(id).await,
3666 }
3667}
3668
3669/// Write one item back exactly as the destination held it before this copy.
3670async fn restore(
3671 destination: &ResolvedSource,
3672 id: &NativeId,
3673 prior: &Prior,
3674) -> Result<(), SourceError> {
3675 match &prior.item {
3676 Item::Task(task) => destination
3677 .source()
3678 .write_task(&ItemWrite {
3679 target: Some(id.clone()),
3680 item: (**task).clone(),
3681 depends_on: prior.edges.clone(),
3682 })
3683 .await
3684 .map(|_| ()),
3685 Item::Project(project) => destination
3686 .source()
3687 .write_project(&ItemWrite {
3688 target: Some(id.clone()),
3689 item: (**project).clone(),
3690 depends_on: prior.edges.clone(),
3691 })
3692 .await
3693 .map(|_| ()),
3694 Item::Document(document) => destination
3695 .source()
3696 .write_document(&ItemWrite {
3697 target: Some(id.clone()),
3698 item: (**document).clone(),
3699 depends_on: Vec::new(),
3700 })
3701 .await
3702 .map(|_| ()),
3703 }
3704}
3705
3706/// Hold `value` at `onetaskgraph.copies` on one item, through its source's narrow metadata
3707/// write — `false` when the source holds no such item.
3708async fn set_link(
3709 source: &ResolvedSource,
3710 level: Level,
3711 id: &NativeId,
3712 value: &Value,
3713) -> Result<bool, SourceError> {
3714 let key = MetadataKey::copies();
3715 Ok(match level {
3716 Level::Task => source
3717 .source()
3718 .set_task_metadata(id, &key, value)
3719 .await?
3720 .is_some(),
3721 Level::Project => source
3722 .source()
3723 .set_project_metadata(id, &key, value)
3724 .await?
3725 .is_some(),
3726 Level::Document => source
3727 .source()
3728 .set_document_metadata(id, &key, value)
3729 .await?
3730 .is_some(),
3731 })
3732}
3733
3734/// The metadata of either kind of item, to edit.
3735fn metadata_of(item: &mut Item) -> &mut BTreeMap<String, Value> {
3736 match item {
3737 Item::Task(task) => &mut task.metadata,
3738 Item::Project(project) => &mut project.metadata,
3739 Item::Document(document) => &mut document.metadata,
3740 }
3741}
3742
3743/// Refuse a document copy addressed to a source that declares it has none.
3744///
3745/// Read off the declaration rather than by asking, which is what "not asked" means: the
3746/// engine learned at the handshake that this source holds no documents, so it refuses
3747/// naming the source and its plugin instead of sending a read that would be refused there.
3748/// Applied at both ends of a copy — a source with no documents holds nothing to copy out,
3749/// and a destination with none has nowhere to put one.
3750fn documentary(source: &ResolvedSource) -> Result<(), EngineError> {
3751 if source.source().capabilities().documents.is_native() {
3752 return Ok(());
3753 }
3754 Err(EngineError::NoDocuments {
3755 name: source.name().to_string(),
3756 kind: source.kind().to_owned(),
3757 })
3758}
3759
3760/// One source failing while a copy was mid-flight.
3761fn refused(source: &ResolvedSource, error: SourceError) -> EngineError {
3762 EngineError::SourceRefused {
3763 name: source.name().to_string(),
3764 error,
3765 }
3766}
3767
3768/// Every forward edge at one item, walked to exhaustion one page at a time.
3769async fn forward_edges(
3770 source: &ResolvedSource,
3771 id: &NativeId,
3772 kind: Level,
3773) -> Result<Vec<DependencyEdge>, EngineError> {
3774 // A document has no edges to walk, and asking for them would mean asking a source for
3775 // a graph the contract says nothing may point into.
3776 if kind == Level::Document {
3777 return Ok(Vec::new());
3778 }
3779 let mut edges = Vec::new();
3780 let mut cursor: Option<Cursor> = None;
3781 loop {
3782 let asked = cursor.clone();
3783 let request = request_for(source, cursor);
3784 let page = match kind {
3785 Level::Task | Level::Document => {
3786 source
3787 .source()
3788 .task_dependencies(id, Direction::DependsOn, &request)
3789 .await
3790 }
3791 Level::Project => {
3792 source
3793 .source()
3794 .project_dependencies(id, Direction::DependsOn, &request)
3795 .await
3796 }
3797 }
3798 .map_err(|error| refused(source, error))?;
3799 fits(page.items.len(), request.limit).map_err(|error| refused(source, error))?;
3800 edges.extend(page.items);
3801 unrepeated(
3802 page.next.as_ref(),
3803 asked.as_ref(),
3804 "an item's dependencies were being read for a copy",
3805 )
3806 .map_err(|error| refused(source, error))?;
3807 match page.next {
3808 Some(next) => cursor = Some(next),
3809 None => return Ok(edges),
3810 }
3811 }
3812}
3813
3814/// The location string one record reports, when it reports a usable one.
3815///
3816/// Either variant's own `String`, and `None` for a record the source gave no location for
3817/// or gave an empty string for: there is nothing to look for in a document's content and
3818/// nothing to point a reader at.
3819fn located(location: Option<&Location>) -> Option<String> {
3820 let (Location::Path(held) | Location::Url(held)) = location?;
3821 (!held.is_empty()).then(|| held.clone())
3822}
3823
3824/// Record one candidate referent, when its source said where it is.
3825fn note(
3826 into: &mut Vec<Referent>,
3827 id: GlobalId,
3828 level: Level,
3829 location: Option<&Location>,
3830 metadata: &BTreeMap<String, Value>,
3831) {
3832 if let Some(location) = located(location) {
3833 into.push(Referent {
3834 id,
3835 origin: origin_of(metadata),
3836 level,
3837 location,
3838 });
3839 }
3840}
3841
3842/// What every location string this document names becomes, longest first.
3843///
3844/// Longest first because a shorter location may start where a longer one does — a
3845/// project's directory and a task's file under it — and the longer of the two is the
3846/// record that occurrence names.
3847fn table_for(referents: &[Referent], counterparts: &Counterparts) -> Vec<(String, Resolution)> {
3848 let mut table: Vec<(String, Resolution)> = Vec::new();
3849 for referent in referents {
3850 // Two referents reporting one location string: an occurrence of it cannot be
3851 // attributed to either, and a rewrite would be *confidently wrong* rather than
3852 // merely unhelpful. So neither is chosen and both occurrences are counted.
3853 if let Some(held) = table
3854 .iter_mut()
3855 .find(|(location, _)| location == &referent.location)
3856 {
3857 held.1 = Resolution::Ambiguous;
3858 continue;
3859 }
3860 table.push((referent.location.clone(), counterparts.resolve(referent)));
3861 }
3862 table.sort_by_key(|(location, _)| std::cmp::Reverse(location.len()));
3863 table
3864}
3865
3866/// Whether `content` holds `location` at least once, stopped on both sides.
3867fn holds(content: &str, location: &str) -> bool {
3868 (0..content.len()).any(|at| delimited_at(content, at, location))
3869}
3870
3871/// Whether `location` occurs at `at` **stopped on both sides** — by
3872/// [`stops_a_location`], or by the end of the content — rather than as part of a longer
3873/// location-like string.
3874///
3875/// A location string occurring inside a longer one is a different string naming a
3876/// different record: `/…/tasks/p/t.md` must not be rewritten inside `/…/tasks/p/t.md.bak`,
3877/// `https://example.invalid/1` must not be rewritten inside `https://example.invalid/12`,
3878/// and a project's location that is a directory prefix of a task's must not be rewritten
3879/// inside that task's. What deciding it this way costs is stated on [`stops_a_location`].
3880fn delimited_at(content: &str, at: usize, location: &str) -> bool {
3881 if !content.is_char_boundary(at) || !content[at..].starts_with(location) {
3882 return false;
3883 }
3884 let before = content[..at].chars().next_back();
3885 let after = content[at + location.len()..].chars().next();
3886 stops_a_location(before) && stops_a_location(after)
3887}
3888
3889/// Whether a character cannot continue a path or a link, so a location string beside one
3890/// ends there.
3891///
3892/// Stated as what *stops* a location rather than as what one may contain, because the
3893/// second list is unbounded — a path may hold very nearly any byte, and a URL more. Every
3894/// character not named here continues, which is what leaves the three cases above alone;
3895/// the end of the content counts as a stop. The set is what the artifact this exists for
3896/// really wraps a bare path in — a backtick in a table cell — plus the delimiters prose
3897/// and Markdown put next to one.
3898///
3899/// **Sentence punctuation is deliberately absent, and that is a stated cost rather than an
3900/// oversight.** `.`, `!`, `?` and `:` each equally *continue* a real location — `/…/t.md`
3901/// and `/…/t.md.bak` are two files, `…/1` and `…/1?q=2` two pages — so admitting them as
3902/// stops would rewrite one record's location into another's. The price is that a location
3903/// written bare at the end of a sentence is not recognised at all: its text is left
3904/// byte-for-byte and it is counted in neither figure, exactly as a reference to another
3905/// project's record is. That is the direction to be wrong in, because this edits the
3906/// content of somebody's document, where a confidently wrong rewrite is worse than one
3907/// that never happens.
3908fn stops_a_location(character: Option<char>) -> bool {
3909 match character {
3910 None => true,
3911 Some(character) => character.is_whitespace() || "`\"'()[]{}<>|,;".contains(character),
3912 }
3913}
3914
3915/// One document's content with every whole reference rewritten, and what that took.
3916///
3917/// A location string with no confident counterpart is left **byte-for-byte** as it was
3918/// rather than removed or guessed at, and so is every character of the content that is not
3919/// a rewritten reference. Nothing is added and nothing is reformatted.
3920fn substitute(content: &str, table: &[(String, Resolution)]) -> (String, Counted) {
3921 let mut written = String::with_capacity(content.len());
3922 let mut counts = Counted::default();
3923 let mut at = 0;
3924 while at < content.len() {
3925 if let Some((location, resolution)) = table
3926 .iter()
3927 .find(|(location, _)| delimited_at(content, at, location))
3928 {
3929 match resolution {
3930 Resolution::Rewrite(there) => {
3931 written.push_str(there);
3932 counts.rewritten += 1;
3933 }
3934 // Both left-alone outcomes count as unresolved on the branch that counts
3935 // them, which is what really holds `ambiguous` at or below `unresolved`.
3936 Resolution::NoCounterpart => {
3937 written.push_str(location);
3938 counts.unresolved += 1;
3939 }
3940 Resolution::Ambiguous => {
3941 written.push_str(location);
3942 counts.unresolved += 1;
3943 counts.ambiguous += 1;
3944 }
3945 }
3946 at += location.len();
3947 continue;
3948 }
3949 let character = content[at..]
3950 .chars()
3951 .next()
3952 .expect("a character at a boundary this walk only ever lands on");
3953 written.push(character);
3954 at += character.len_utf8();
3955 }
3956 (written, counts)
3957}
3958
3959/// The entry [`Engine::rewrite_references`] records for a document it rewrote from `read`
3960/// to `written`, or `None` to carry what the document records verbatim.
3961///
3962/// `None` unless `read` is still the rendering its entry vouches for, because only then is
3963/// the one change between it and `written` this copy's own: a hand edit is never laundered.
3964fn restamped(
3965 metadata: &BTreeMap<String, Value>,
3966 read: &str,
3967 written: &str,
3968) -> Option<TemplateProvenance> {
3969 let mut provenance = TemplateProvenance::read(metadata).ok().flatten()?;
3970 if provenance.body_digest.as_str() != body_digest(read) {
3971 return None;
3972 }
3973 provenance.body_digest =
3974 Sha256Digest::parse(body_digest(written)).expect("`body_digest` spells a digest");
3975 Some(provenance)
3976}
3977
3978/// The origin one item records, when it records a usable one.
3979fn origin_of(metadata: &BTreeMap<String, Value>) -> Option<GlobalId> {
3980 metadata
3981 .get(GlobalId::ORIGIN_KEY)?
3982 .as_str()?
3983 .parse::<GlobalId>()
3984 .ok()
3985}
3986
3987/// The destination item one item's `onetaskgraph.copies` entry names at `destination`, when
3988/// it holds a usable one.
3989///
3990/// An entry that is not a qualified id, or is one naming another source than the key it is
3991/// filed under, is no link at all: the copy goes on by the rules that follow and rewrites
3992/// it to what they find.
3993fn link_of(metadata: &BTreeMap<String, Value>, destination: &SourceName) -> Option<GlobalId> {
3994 let linked = metadata
3995 .get(MetadataKey::COPIES_KEY)?
3996 .as_object()?
3997 .get(destination.as_str())?
3998 .as_str()?
3999 .parse::<GlobalId>()
4000 .ok()?;
4001 (&linked.source == destination).then_some(linked)
4002}
4003
4004/// The entries of a `onetaskgraph.copies` value that are links: a source name, and a
4005/// qualified id of that same source.
4006fn well_formed(value: Option<&Value>) -> serde_json::Map<String, Value> {
4007 let Some(Value::Object(held)) = value else {
4008 return serde_json::Map::new();
4009 };
4010 held.iter()
4011 .filter(|(destination, linked)| {
4012 linked
4013 .as_str()
4014 .and_then(|linked| linked.parse::<GlobalId>().ok())
4015 .is_some_and(|linked| linked.source.as_str() == destination.as_str())
4016 })
4017 .map(|(destination, linked)| (destination.clone(), linked.clone()))
4018 .collect()
4019}
4020
4021/// Why a value is not one a `onetaskgraph.copies` entry may hold, or nothing when it is: a
4022/// JSON object mapping each destination source name to a qualified id of that source.
4023///
4024/// What the stdio boundary holds an incoming value of that key to, so a plugin is never
4025/// handed a link it could not answer a later copy with.
4026pub(crate) fn malformed_links(value: &Value) -> Option<String> {
4027 let Value::Object(held) = value else {
4028 return Some(format!(
4029 "{} holds an object of destination source names to qualified ids, not {value}",
4030 MetadataKey::COPIES_KEY
4031 ));
4032 };
4033 let wrong = held.len() - well_formed(Some(value)).len();
4034 (wrong > 0).then(|| {
4035 format!(
4036 "{} holds an object of destination source names to qualified ids of that source, \
4037 and {wrong} of its entries is not one: {value}",
4038 MetadataKey::COPIES_KEY
4039 )
4040 })
4041}
4042
4043/// The title and metadata of either kind of item.
4044fn described(item: &Item) -> (&str, &BTreeMap<String, Value>) {
4045 match item {
4046 Item::Task(task) => (&task.title, &task.metadata),
4047 Item::Project(project) => (&project.title, &project.metadata),
4048 Item::Document(document) => (&document.title, &document.metadata),
4049 }
4050}
4051
4052/// The item as the destination should hold it.
4053///
4054/// `url`, `location`, `created_at` and `updated_at` are the destination's own and are
4055/// never written — where the *source* holds an item says nothing about where the
4056/// destination does, which is why a copied document does not arrive claiming the path or
4057/// the link its source reported. The two reserved keys this product encodes typed fields
4058/// under are removed, because those fields travel as themselves — leaving the encoding
4059/// beside them would have the destination hold one thing twice, and disagree with itself
4060/// the moment one changed.
4061///
4062/// A task's `delivers` is `delivers`, already resolved against the destination, and its
4063/// `delivered_by` is the one the destination holds — never the source's, and empty for an
4064/// item this copy creates: that list is the store's to keep, at the destination. The
4065/// `onetaskgraph.copies` link is kept on the same terms: it says where the *destination*
4066/// item was copied to, which is the destination's to hold, never the source's to send.
4067fn outgoing(
4068 item: &Planned,
4069 id: NativeId,
4070 project: Option<NativeId>,
4071 origin: &Origin,
4072 delivers: &[TaskRef],
4073 held: Option<&Prior>,
4074) -> Item {
4075 let own = held.map(|held| described(&held.item).1);
4076 let carried = |metadata: &BTreeMap<String, Value>| carried(metadata, origin, own);
4077 match &item.item {
4078 Item::Task(task) => Item::Task(Box::new(Task {
4079 id,
4080 url: None,
4081 location: None,
4082 created_at: None,
4083 updated_at: None,
4084 project,
4085 metadata: carried(&task.metadata),
4086 delivers: delivers.to_vec(),
4087 delivered_by: match held.map(|held| &held.item) {
4088 Some(Item::Task(held)) => held.delivered_by.clone(),
4089 _ => Vec::new(),
4090 },
4091 ..(**task).clone()
4092 })),
4093 Item::Project(project) => Item::Project(Box::new(Project {
4094 id,
4095 url: None,
4096 location: None,
4097 created_at: None,
4098 updated_at: None,
4099 metadata: carried(&project.metadata),
4100 ..(**project).clone()
4101 })),
4102 Item::Document(document) => Item::Document(Box::new(Document {
4103 id,
4104 url: None,
4105 location: None,
4106 created_at: None,
4107 updated_at: None,
4108 project,
4109 metadata: carried(&document.metadata),
4110 ..(**document).clone()
4111 })),
4112 }
4113}
4114
4115/// The id a created item is offered to the destination under.
4116///
4117/// A task whose id is `<its project's id>/<rest>` is offered as `<rest>` under the
4118/// destination project it is filed in, so a destination whose ids are paths files it in that
4119/// project rather than the source's. Any other id, and every update, is left as it is. The
4120/// `a_project_copied_*` journeys in `crates/onetaskgraph/tests/e2e/copy.rs` hold the Markdown
4121/// plugin's id shape to this rule.
4122fn created_id(item: &Item, filed: Option<&NativeId>) -> NativeId {
4123 if let (Item::Task(task), Some(filed)) = (item, filed)
4124 && let Some(own) = &task.project
4125 && let Some(rest) = task
4126 .id
4127 .as_str()
4128 .strip_prefix(own.as_str())
4129 .and_then(|rest| rest.strip_prefix('/'))
4130 && !rest.is_empty()
4131 {
4132 return NativeId(format!("{}/{rest}", filed.as_str()));
4133 }
4134 item.id().clone()
4135}
4136
4137/// The metadata a copy carries: the caller's own keys untouched, the origin settled, and the
4138/// destination's own store-kept keys — its `onetaskgraph.copies` link, and the
4139/// `onetaskgraph.members` or `onetaskgraph.member_of` that ties it to its member projects or
4140/// its home — kept as `own`, the destination item, holds them.
4141///
4142/// The key is removed before it is settled rather than overwritten, because the item being
4143/// copied carries an origin of its own and [`Origin::Keeps`] must not let it through. Its
4144/// link is removed for the same reason: it names where *that* item was copied to, and on
4145/// the destination it would name the destination item's own counterpart as itself. Its
4146/// member keys name projects tied to *it*, which say nothing about the destination's.
4147fn carried(
4148 metadata: &BTreeMap<String, Value>,
4149 origin: &Origin,
4150 own: Option<&BTreeMap<String, Value>>,
4151) -> BTreeMap<String, Value> {
4152 let mut carried = metadata.clone();
4153 carried.remove(Repository::METADATA_KEY);
4154 carried.remove(DependencyEdge::RECORDED_KEY);
4155 carried.remove(TaskRef::DELIVERS_KEY);
4156 carried.remove(TaskRef::DELIVERED_BY_KEY);
4157 carried.remove(GlobalId::ORIGIN_KEY);
4158 for kept in [
4159 MetadataKey::COPIES_KEY,
4160 MetadataKey::MEMBERS_KEY,
4161 MetadataKey::MEMBER_OF_KEY,
4162 ] {
4163 carried.remove(kept);
4164 if let Some(held) = own.and_then(|own| own.get(kept)) {
4165 carried.insert(kept.to_owned(), held.clone());
4166 }
4167 }
4168 let held = match origin {
4169 Origin::Records(id) => Some(Value::String(id.to_string())),
4170 Origin::Keeps(held) => held.clone(),
4171 };
4172 if let Some(held) = held {
4173 carried.insert(GlobalId::ORIGIN_KEY.to_owned(), held);
4174 }
4175 carried
4176}
4177
4178/// What one landed item records at [`GlobalId::ORIGIN_KEY`].
4179enum Origin {
4180 /// The qualified id this item was copied from, as the id type rather than as its
4181 /// spelling: the key holds a [`GlobalId`] and nothing else may be recorded there.
4182 Records(GlobalId),
4183 /// Whatever the destination already holds there — `None` when it holds nothing, which
4184 /// is written as the key being absent rather than as a null.
4185 ///
4186 /// A [`Value`] and not a [`GlobalId`], because this variant does not interpret what it
4187 /// carries: it is the destination's own metadata entry, held for the length of one
4188 /// write and put back exactly as it was read. Parsing it would turn a value a
4189 /// destination holds and this engine cannot read into a value this engine deletes,
4190 /// which is the opposite of what keeping it means.
4191 Keeps(Option<Value>),
4192}
4193
4194/// Which of the two a copy of this item does.
4195///
4196/// A copy that reached its target by rule 1 is a copy-back: the item being copied names
4197/// the destination item, so the destination is the *original* and the id being copied
4198/// belongs to the copy that came out of it. Recording that id there would overwrite the
4199/// original's own provenance — and with it the correspondence every later copy from the
4200/// source it was authored in depends on. That copy would then match nothing and create a
4201/// second item beside the one it meant to update, which is the whole failure: nothing is
4202/// reported, and whoever reads that board now has two. So a copy-back leaves the
4203/// destination's origin exactly as the destination holds it, absent included, and every
4204/// other copy records the id it was copied from.
4205fn recorded(item: &Planned, held: Option<&Prior>) -> Origin {
4206 if let Target::Update {
4207 found: Found::Origin,
4208 ..
4209 } = &item.target
4210 {
4211 return Origin::Keeps(
4212 held.and_then(|held| described(&held.item).1.get(GlobalId::ORIGIN_KEY).cloned()),
4213 );
4214 }
4215 Origin::Records(item.source.clone())
4216}
4217
4218/// Whether the destination already reads exactly as this copy would leave it.
4219///
4220/// The destination's own `url` and timestamps are excluded because a copy never writes
4221/// them, so a difference there is not one this copy would close.
4222fn same(held: &Item, outgoing: &Item, destination: &SourceName) -> bool {
4223 match (held, outgoing) {
4224 (Item::Task(held), Item::Task(outgoing)) => {
4225 // Qualified before they are compared, so `T-1` and `folder:T-1` at the destination
4226 // `folder` are the one entry they are.
4227 targets(&held.delivers, destination) == targets(&outgoing.delivers, destination)
4228 && targets(&held.delivered_by, destination)
4229 == targets(&outgoing.delivered_by, destination)
4230 && held.title == outgoing.title
4231 && held.content == outgoing.content
4232 && held.status == outgoing.status
4233 && held.priority == outgoing.priority
4234 && held.labels == outgoing.labels
4235 && held.project == outgoing.project
4236 && held.metadata == outgoing.metadata
4237 && held.repositories == outgoing.repositories
4238 }
4239 (Item::Project(held), Item::Project(outgoing)) => {
4240 held.title == outgoing.title
4241 && held.content == outgoing.content
4242 && held.status == outgoing.status
4243 && held.labels == outgoing.labels
4244 && held.metadata == outgoing.metadata
4245 && held.repositories == outgoing.repositories
4246 }
4247 // No status, because a document has none; no edges, because it is in no graph.
4248 (Item::Document(held), Item::Document(outgoing)) => {
4249 held.title == outgoing.title
4250 && held.content == outgoing.content
4251 && held.labels == outgoing.labels
4252 && held.project == outgoing.project
4253 && held.metadata == outgoing.metadata
4254 && held.repositories == outgoing.repositories
4255 }
4256 _ => false,
4257 }
4258}
4259
4260/// Whether the destination's forward edges already say what this copy would write.
4261fn same_edges(held: &[DependencyEdge], outgoing: &[DependencyEdge]) -> bool {
4262 let ends = |edges: &[DependencyEdge]| {
4263 let mut ends: Vec<(String, ItemKind, DependencyKind)> = edges
4264 .iter()
4265 .map(|edge| (edge.to.id().to_owned(), edge.to.kind, edge.kind))
4266 .collect();
4267 ends.sort_by(|left, right| left.0.cmp(&right.0));
4268 ends
4269 };
4270 ends(held) == ends(outgoing)
4271}
4272
4273/// Each read edge as the destination should record it, or `None` when its far end is a
4274/// member of this copy whose destination id is not known yet.
4275///
4276/// `destination` is the source the near end lands in. A far end this copy landed in another
4277/// source — a routed copy's other half — is written qualified, which every source records
4278/// as a cross-source edge on the near item.
4279fn mapped_edges(
4280 edges: &[DependencyEdge],
4281 origin: &SourceName,
4282 destination: &SourceName,
4283 copied: &[GlobalId],
4284 written: &BTreeMap<String, GlobalId>,
4285) -> Vec<Option<DependencyEdge>> {
4286 edges
4287 .iter()
4288 .map(|edge| {
4289 let far = GlobalId::new(origin.clone(), NativeId(edge.to.id().to_owned()));
4290 let id = if let Some(native) = names(&edge.to, destination) {
4291 // A far end already qualified to the destination's own source is that
4292 // source's own item, so it is written the way that source names its own:
4293 // unqualified. Leaving it qualified would have the destination hold an
4294 // edge into itself written as if it left, which is the one spelling the
4295 // reserved key exists to keep for edges that really do.
4296 Some(native)
4297 } else if !edge.to.is_qualified() && copied.contains(&far) {
4298 // A member of this copy, inside one source included: a copy there lands
4299 // beside the item it was read from, so the edge names the copy rather than
4300 // the original the copy's own project does not hold.
4301 written
4302 .get(&far.to_string())
4303 .map(|landed| spelled_from(landed, destination))
4304 } else if edge.to.is_qualified() || origin == destination {
4305 // Already naming a source of its own, or a copy inside one source where
4306 // the far end's own id is the destination's id.
4307 Some(edge.to.id().to_owned())
4308 } else {
4309 Some(far.to_string())
4310 }?;
4311 DependencyEndpoint::new(id, edge.to.kind)
4312 .ok()
4313 .map(|to| DependencyEdge {
4314 from: edge.from.clone(),
4315 to,
4316 kind: edge.kind,
4317 })
4318 })
4319 .collect()
4320}
4321
4322/// Each `delivers` entry of one planned task as the destination should hold it, or `None`
4323/// where it names a member of this copy whose destination id is not known yet. Empty for
4324/// anything but a task.
4325fn delivers_of(
4326 item: &Planned,
4327 destination: &SourceName,
4328 copied: &[GlobalId],
4329 written: &BTreeMap<String, GlobalId>,
4330) -> Vec<Option<TaskRef>> {
4331 match &item.item {
4332 Item::Task(task) => mapped_delivers(
4333 &task.delivers,
4334 &item.source.source,
4335 destination,
4336 copied,
4337 written,
4338 ),
4339 Item::Project(_) | Item::Document(_) => Vec::new(),
4340 }
4341}
4342
4343/// Each `delivers` entry as the destination should hold it, or `None` when it names a member
4344/// of this copy whose destination id is not known yet.
4345///
4346/// An entry naming a member of the copied set becomes that member's own id at the
4347/// destination — bare, because the member is the destination's, unless the id holds a colon
4348/// a bare entry would be misread by. Every other entry is carried through qualified, so a
4349/// bare one read at `origin` goes on naming a task of `origin`.
4350fn mapped_delivers(
4351 entries: &[TaskRef],
4352 origin: &SourceName,
4353 destination: &SourceName,
4354 copied: &[GlobalId],
4355 written: &BTreeMap<String, GlobalId>,
4356) -> Vec<Option<TaskRef>> {
4357 entries
4358 .iter()
4359 .map(|entry| {
4360 let qualified = entry.in_source(origin);
4361 let Ok(far) = qualified.as_str().parse::<GlobalId>() else {
4362 return Some(qualified);
4363 };
4364 if !copied.contains(&far) {
4365 return Some(qualified);
4366 }
4367 let landed = written.get(&far.to_string())?;
4368 let native = &landed.native;
4369 Some(
4370 if &landed.source != destination || native.as_str().contains(':') {
4371 TaskRef::qualified(&landed.source, native)
4372 } else {
4373 TaskRef::new(native.as_str())
4374 .unwrap_or_else(|_| TaskRef::qualified(destination, native))
4375 },
4376 )
4377 })
4378 .collect()
4379}
4380
4381/// How many of one task's `delivers` entries name a member of the copied set.
4382fn members_named(entries: &[TaskRef], origin: &SourceName, copied: &[GlobalId]) -> u64 {
4383 let named = entries
4384 .iter()
4385 .filter(|entry| {
4386 entry
4387 .in_source(origin)
4388 .as_str()
4389 .parse::<GlobalId>()
4390 .is_ok_and(|far| copied.contains(&far))
4391 })
4392 .count();
4393 u64::try_from(named).unwrap_or(u64::MAX)
4394}
4395
4396/// The entries that could be resolved, which is every one of them on the second pass.
4397fn resolved_entries(entries: &[Option<TaskRef>]) -> Vec<TaskRef> {
4398 entries.iter().flatten().cloned().collect()
4399}
4400
4401/// How an item landed at `landed` is named from one landed in `near`: by its native id in
4402/// the same source, qualified from any other.
4403fn spelled_from(landed: &GlobalId, near: &SourceName) -> String {
4404 if &landed.source == near {
4405 landed.native.0.clone()
4406 } else {
4407 landed.to_string()
4408 }
4409}
4410
4411/// The native id a qualified endpoint names at `destination`, when it names one there.
4412fn names(endpoint: &DependencyEndpoint, destination: &SourceName) -> Option<String> {
4413 if !endpoint.is_qualified() {
4414 return None;
4415 }
4416 let id: GlobalId = endpoint.id().parse().ok()?;
4417 (&id.source == destination).then_some(id.native.0)
4418}
4419
4420/// Where a project copy files the tasks it carries: in each source a task lands in, the
4421/// home there or the home's member project.
4422type Filing = BTreeMap<SourceName, NativeId>;
4423
4424/// The edges that could be resolved, which is every one of them on the second pass.
4425fn resolved(edges: &[Option<DependencyEdge>]) -> Vec<DependencyEdge> {
4426 edges.iter().flatten().cloned().collect()
4427}
4428
4429/// Refuse an item of a member copy whose edge names a member that copy does not carry
4430/// and whose destination id nothing records.
4431///
4432/// `unrecorded` is empty for every other copy, which is what makes this a no-op there. An
4433/// edge already naming a source of its own, and every edge of a copy inside one source,
4434/// is written as it was read by [`mapped_edges`], so neither can need a recorded origin.
4435fn unrecorded_far_end(item: &Planned, unrecorded: &[GlobalId]) -> Result<(), EngineError> {
4436 if item.source.source == item.to {
4437 return Ok(());
4438 }
4439 for edge in &item.edges {
4440 if edge.to.is_qualified() {
4441 continue;
4442 }
4443 let far = GlobalId::new(
4444 item.source.source.clone(),
4445 NativeId(edge.to.id().to_owned()),
4446 );
4447 if unrecorded.contains(&far) {
4448 return Err(EngineError::UnrecordedMember {
4449 item: item.source.clone(),
4450 member: far,
4451 destination: item.to.clone(),
4452 });
4453 }
4454 }
4455 Ok(())
4456}