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