Skip to main content

onetaskgraph_core/engine/
copy.rs

1//! The copy verb: one item out of one source and into another, by the rules that make a
2//! second copy an update rather than a duplicate.
3//!
4//! Correspondence lives on the item and never in a table. A copied item carries
5//! [`GlobalId::ORIGIN_KEY`], whose value is the qualified id it was copied from, and the
6//! two match rules below read exactly that — so nothing here is written down outside the
7//! plugin that owns the item, and the invariant this engine is built around is untouched.
8//!
9//! 1. **Follow the origin.** An item already carrying an origin whose source half is the
10//!    destination names the destination item *directly*, and the copy updates it. This is
11//!    the half that makes an edit's copy-back an update: the local file came from the
12//!    remote item and knows which one.
13//! 2. **Search by origin.** Otherwise the destination is scanned, one page at a time, for
14//!    an item whose origin is the id being copied. Found, the copy updates it; not found,
15//!    the copy creates one carrying that origin.
16//!
17//! A destination write is at the user's explicit request, names its destination, goes
18//! through that source's own write interface into that source's own store, and is never
19//! read back to answer a query. That is what makes it a write and not a cache.
20
21use std::collections::BTreeMap;
22
23use onetaskgraph_plugin_api::{
24    DependencyEdge, DependencyEndpoint, DependencyKind, Direction, ItemKind, ItemWrite, NativeId,
25    Page, PageRequest, Project, ProjectQuery, Repository, SourceError, SourceName, Task, TaskQuery,
26};
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30
31use crate::GlobalId;
32use crate::resolve::ResolvedSource;
33
34use super::local::ProjectSelector;
35use super::{Engine, EngineError, Filters, Paging, TaskRequest};
36
37/// A request to copy work into one configured destination.
38#[derive(Debug, Clone)]
39pub struct CopyRequest {
40    /// The qualified items to copy, in the order they were named.
41    pub items: CopyItems,
42    /// What those ids name, and what comes with them.
43    pub scope: CopyScope,
44    /// The configured source to copy into — a source name, never a qualified id.
45    pub destination: SourceName,
46    /// How to re-establish a correspondence the two origin rules cannot find.
47    pub match_by: Option<MatchBy>,
48    /// Whether an origin naming nothing at the destination falls through to the search
49    /// rule instead of refusing.
50    pub recreate: bool,
51    /// Whether to perform every read and no write.
52    pub dry_run: bool,
53}
54
55/// The items one copy names: at least one, because a copy naming none is not a copy.
56///
57/// A newtype rather than a bare `Vec`, for the reason [`Repository`] is one: the empty
58/// list is not a copy of nothing, it is a caller mistake, and a type that can hold it
59/// leaves every reader to decide what it means — a report with no entries, an error, a
60/// silent success. None of those is better than not being able to say it.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct CopyItems(Vec<GlobalId>);
63
64impl CopyItems {
65    /// The items a caller named, or `None` when they named none.
66    #[must_use]
67    pub fn new(items: Vec<GlobalId>) -> Option<Self> {
68        (!items.is_empty()).then_some(Self(items))
69    }
70
71    /// The items, in the order they were named.
72    #[must_use]
73    pub fn as_slice(&self) -> &[GlobalId] {
74        &self.0
75    }
76}
77
78/// What the ids a copy names are, and what travels with them.
79///
80/// One value rather than a kind beside a flag, because three of the four combinations
81/// those two would make are real and the fourth — tasks, with the tasks of each also
82/// copied — means nothing.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum CopyScope {
85    /// The ids name tasks, and only those tasks are copied.
86    Tasks,
87    /// The ids name projects.
88    Projects {
89        /// Whether the tasks in each project are copied too.
90        tasks: bool,
91    },
92}
93
94/// The caller-named escape for a correspondence neither origin rule can find.
95///
96/// A person editing Markdown who deletes or corrupts the origin key leaves an item rule 1
97/// cannot use and rule 2 cannot find, and the next copy would create a second item. This
98/// is how that is re-established without hand-editing ids.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum MatchBy {
101    /// Match the item whose title is the same.
102    Title,
103    /// Match the item whose value at this metadata key is the same.
104    Metadata(String),
105}
106
107impl MatchBy {
108    /// The spelling a caller types, `title` or any metadata key.
109    #[must_use]
110    pub fn parse(key: &str) -> Self {
111        if key == "title" {
112            Self::Title
113        } else {
114            Self::Metadata(key.to_owned())
115        }
116    }
117}
118
119/// What a copy did, one entry per item.
120///
121/// The same per-item outcomes reach every consumer: the machine-readable output renders
122/// this, the rendered output renders this, and a Rust caller is handed it.
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
124pub struct CopyReport {
125    /// One entry per item the copy considered, in the order it considered them.
126    pub items: Vec<CopyOutcome>,
127}
128
129/// What happened to one item.
130///
131/// `action` and `destination` are one value rather than two fields side by side: an
132/// updated item without a destination id, or an orphan without one, are states this type
133/// must not be able to say — the id *is* what those outcomes are about. The one outcome
134/// that legitimately has none is a dry run that would create, because nothing was
135/// created and there is no id to report.
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
137pub struct CopyOutcome {
138    /// The qualified id the item was read from.
139    pub source: GlobalId,
140    /// What happened to it, and where.
141    #[serde(flatten)]
142    pub action: CopyAction,
143}
144
145impl CopyOutcome {
146    /// The qualified id this outcome landed on, when it landed on one.
147    #[must_use]
148    pub fn destination(&self) -> Option<&GlobalId> {
149        self.action.destination()
150    }
151}
152
153/// The four things a copy can do to one item, and the id each of them is about.
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
155#[serde(tag = "action", rename_all = "kebab-case")]
156pub enum CopyAction {
157    // llmlint: ignore[names_match_behavior] `created` is Contract D's serialized action
158    // for both a completed create and a dry run that would create; the optional destination
159    // distinguishes those cases, and renaming this public variant would break Rust callers.
160    /// The destination held no counterpart, so one was created.
161    Created {
162        /// The id it was created under, or `null` for a dry run that would have created
163        /// one — there is no id, because nothing was.
164        destination: Option<GlobalId>,
165    },
166    /// The destination held a counterpart and it now reads as the source does.
167    Updated {
168        /// The item that was updated.
169        destination: GlobalId,
170    },
171    /// The destination held a counterpart that already read that way; nothing was written.
172    Unchanged {
173        /// The item that already said it.
174        destination: GlobalId,
175    },
176    /// The destination holds a counterpart the source no longer does. A copy never
177    /// deletes, so it was left exactly as it is.
178    Orphaned {
179        /// The item that was left alone.
180        destination: GlobalId,
181    },
182}
183
184impl CopyAction {
185    /// The qualified id this action is about, when there is one.
186    #[must_use]
187    pub fn destination(&self) -> Option<&GlobalId> {
188        match self {
189            Self::Created { destination } => destination.as_ref(),
190            Self::Updated { destination }
191            | Self::Unchanged { destination }
192            | Self::Orphaned { destination } => Some(destination),
193        }
194    }
195
196    /// The word this action serializes as, taken from its own `Serialize`.
197    ///
198    /// Read back off the wire form rather than written out again in a `match`, for the
199    /// reason `render::wire` gives: a second spelling of `unchanged` would be a second
200    /// place for it to drift from the one a caller reads.
201    #[must_use]
202    pub fn name(&self) -> String {
203        serde_json::to_value(self).expect("a contract enum serialises")["action"]
204            .as_str()
205            .expect("an internally tagged enum carries its tag")
206            .to_owned()
207    }
208}
209
210/// Where one item is going at the destination.
211enum Target {
212    /// Update the destination item with this id.
213    Update(NativeId),
214    /// Create one.
215    Create,
216}
217
218/// What a scan of the destination is looking for.
219enum Wanted {
220    /// An item recording this qualified id as its origin.
221    Origin(String),
222    /// An item whose title is this.
223    Title(String),
224    /// An item holding this value at this metadata key.
225    Metadata(String, Value),
226}
227
228impl Wanted {
229    /// Whether one destination item is the one being looked for.
230    fn found(&self, title: &str, metadata: &BTreeMap<String, Value>) -> bool {
231        match self {
232            Self::Origin(id) => {
233                metadata.get(GlobalId::ORIGIN_KEY) == Some(&Value::String(id.clone()))
234            }
235            Self::Title(wanted) => title == wanted,
236            Self::Metadata(key, value) => metadata.get(key) == Some(value),
237        }
238    }
239}
240
241/// One item, read and resolved, on its way into the destination.
242struct Planned {
243    /// Where it came from.
244    source: GlobalId,
245    /// The item as its source reported it.
246    item: Item,
247    /// Its forward edges, as its source reported them.
248    edges: Vec<DependencyEdge>,
249    /// Where it is going.
250    target: Target,
251}
252
253/// A task or a project, so the copy path is written once.
254enum Item {
255    /// A task.
256    Task(Box<Task>),
257    /// A project.
258    Project(Box<Project>),
259}
260
261impl Item {
262    fn id(&self) -> &NativeId {
263        match self {
264            Self::Task(task) => &task.id,
265            Self::Project(project) => &project.id,
266        }
267    }
268
269    fn kind(&self) -> ItemKind {
270        match self {
271            Self::Task(_) => ItemKind::Task,
272            Self::Project(_) => ItemKind::Project,
273        }
274    }
275}
276
277impl Engine {
278    /// Copy every item a request names into one configured destination.
279    ///
280    /// This is the whole of the verb, and the command line drives exactly this: a copy a
281    /// Rust caller makes and a copy typed at a shell are the same call, so the two cannot
282    /// answer the same copy differently.
283    ///
284    /// # Errors
285    ///
286    /// Returns [`EngineError`] when the destination is not configured, cannot be built or
287    /// cannot be written; when an id names nothing; when an origin names an item the
288    /// destination no longer holds and `--recreate` was not given; and when the
289    /// destination refuses the write — including a field or a metadata key it cannot
290    /// carry, which it names rather than dropping.
291    pub async fn copy(&self, request: &CopyRequest) -> Result<CopyReport, EngineError> {
292        let destination = self.writable(&request.destination)?;
293        match request.scope {
294            // Every id at once, because the ids named together are the copied set: an
295            // edge between two of them is recreated at the destination, and one item at a
296            // time could not know that the far end was coming.
297            CopyScope::Tasks => Ok(CopyReport {
298                items: self
299                    .copy_items(
300                        destination,
301                        request,
302                        ItemKind::Task,
303                        request.items.as_slice(),
304                        None,
305                    )
306                    .await?,
307            }),
308            CopyScope::Projects { tasks } => {
309                let mut items = Vec::new();
310                for id in request.items.as_slice() {
311                    items.extend(self.copy_project(destination, request, id, tasks).await?);
312                }
313                Ok(CopyReport { items })
314            }
315        }
316    }
317
318    /// The destination source, once it is established it exists and can be written.
319    fn writable(&self, name: &SourceName) -> Result<&ResolvedSource, EngineError> {
320        let name = self.known(name)?;
321        if let Some(unavailable) = self.unavailable().find(|source| source.name() == &name) {
322            return Err(EngineError::DestinationUnavailable {
323                name: name.to_string(),
324                error: unavailable.error().clone(),
325            });
326        }
327        let source = self
328            .ready()
329            .find(|source| source.name() == &name)
330            .ok_or(EngineError::NoSources)?;
331        if !source.source().writes().is_supported() {
332            return Err(EngineError::NotWritable {
333                name: name.to_string(),
334                kind: source.kind().to_owned(),
335            });
336        }
337        Ok(source)
338    }
339
340    /// Copy one project and, unless they are excluded, every task in it.
341    async fn copy_project(
342        &self,
343        destination: &ResolvedSource,
344        request: &CopyRequest,
345        id: &GlobalId,
346        tasks: bool,
347    ) -> Result<Vec<CopyOutcome>, EngineError> {
348        let members = if tasks {
349            self.project_members(id).await?
350        } else {
351            Vec::new()
352        };
353        // On a repeat copy, compare the project with its final remapped edges before the
354        // first pass temporarily rewrites it. This preserves an `unchanged` outcome when
355        // the project and every copied member already have counterparts.
356        let project_plan = self
357            .plan(destination, request, ItemKind::Project, id)
358            .await?;
359        let mut copied = vec![id.clone()];
360        copied.extend(members.iter().cloned());
361        let mut known = BTreeMap::new();
362        if let Target::Update(target) = &project_plan.target {
363            known.insert(id.to_string(), target.clone());
364        }
365        for member in &members {
366            let member_plan = self
367                .plan(destination, request, ItemKind::Task, member)
368                .await?;
369            if let Target::Update(target) = member_plan.target {
370                known.insert(member.to_string(), target);
371            }
372        }
373        let project_was_unchanged = if let Target::Update(target) = &project_plan.target {
374            let edges = mapped_edges(
375                &project_plan.edges,
376                &id.source,
377                destination,
378                &copied,
379                &known,
380            );
381            !edges.iter().any(Option::is_none)
382                && !self
383                    .changes(destination, &project_plan, target, &None, &resolved(&edges))
384                    .await?
385        } else {
386            false
387        };
388        let mut outcomes = self
389            .copy_items(
390                destination,
391                request,
392                ItemKind::Project,
393                std::slice::from_ref(id),
394                None,
395            )
396            .await?;
397        if !tasks {
398            return Ok(outcomes);
399        }
400        // `None` when a dry run would have created the project: nothing was written, so
401        // there is no destination project id to file the tasks under. Every task is still
402        // read and still reported, because that is what a dry run is for.
403        let project = outcomes.first().and_then(CopyOutcome::destination).cloned();
404        let task_outcomes = self
405            .copy_items(
406                destination,
407                request,
408                ItemKind::Task,
409                &members,
410                project.as_ref().map(|project| project.native.clone()),
411            )
412            .await?;
413        outcomes.extend(task_outcomes);
414        if let Some(project) = project {
415            if !request.dry_run {
416                // The project was necessarily written before its members so they could
417                // be filed under it. Now every destination id is known, repair project
418                // edges whose far ends are tasks in the copied set.
419                let planned = self
420                    .plan(destination, request, ItemKind::Project, id)
421                    .await?;
422                let mut copied = vec![id.clone()];
423                copied.extend(members.iter().cloned());
424                let written: BTreeMap<String, NativeId> = outcomes
425                    .iter()
426                    .filter_map(|outcome| {
427                        outcome.destination().map(|destination| {
428                            (outcome.source.to_string(), destination.native.clone())
429                        })
430                    })
431                    .collect();
432                let edges =
433                    mapped_edges(&planned.edges, &id.source, destination, &copied, &written);
434                self.write(
435                    destination,
436                    &planned,
437                    Some(project.native.clone()),
438                    self.filed(destination, &planned, None).await?,
439                    &resolved(&edges),
440                )
441                .await?;
442            }
443            if project_was_unchanged {
444                outcomes[0].action = CopyAction::Unchanged {
445                    destination: project.clone(),
446                };
447            }
448            outcomes.extend(
449                self.orphans(destination, id, &project.native, &members)
450                    .await?,
451            );
452        }
453        Ok(outcomes)
454    }
455
456    /// Every task the source holds in `project`, by qualified id.
457    async fn project_members(&self, project: &GlobalId) -> Result<Vec<GlobalId>, EngineError> {
458        let mut request = TaskRequest {
459            sources: vec![project.source.clone()],
460            filters: Filters::default(),
461            project: ProjectSelector::Qualified(project.clone()),
462            paging: Paging {
463                limit: PROJECT_PAGE,
464                token: None,
465            },
466        };
467        let mut members = Vec::new();
468        loop {
469            let response = self.tasks(&request).await?;
470            if let Some(failure) = response.errors.first() {
471                return Err(EngineError::SourceRefused {
472                    name: failure.source.to_string(),
473                    error: failure.error.clone(),
474                });
475            }
476            members.extend(response.items.into_iter().map(|task| task.id));
477            match response.next {
478                Some(token) => request.paging.token = Some(token),
479                None => return Ok(members),
480            }
481        }
482    }
483
484    /// Destination tasks filed under the copied project whose origin the source no longer
485    /// holds.
486    ///
487    /// A copy never deletes, so each is left exactly as it is and reported.
488    async fn orphans(
489        &self,
490        destination: &ResolvedSource,
491        project: &GlobalId,
492        at_destination: &NativeId,
493        copied: &[GlobalId],
494    ) -> Result<Vec<CopyOutcome>, EngineError> {
495        let mut orphans = Vec::new();
496        let mut cursor = None;
497        loop {
498            let page: Page<Task> = destination
499                .source()
500                .query_tasks(&TaskQuery::default(), &request_for(destination, cursor))
501                .await
502                .map_err(|error| refused(destination, error))?;
503            for task in &page.items {
504                if task.project.as_ref() != Some(at_destination) {
505                    continue;
506                }
507                let Some(origin) = origin_of(&task.metadata) else {
508                    continue;
509                };
510                if origin.source != project.source || copied.contains(&origin) {
511                    continue;
512                }
513                orphans.push(CopyOutcome {
514                    source: origin,
515                    action: CopyAction::Orphaned {
516                        destination: GlobalId::new(destination.name().clone(), task.id.clone()),
517                    },
518                });
519            }
520            match page.next {
521                Some(next) => cursor = Some(next),
522                None => return Ok(orphans),
523            }
524        }
525    }
526
527    /// Read, resolve and write every item named, then repair the edges among them.
528    ///
529    /// Two passes, because an edge between two items of one copy can point at a member
530    /// whose destination id is not known until it has been created. The second pass runs
531    /// only for the items that had one.
532    async fn copy_items(
533        &self,
534        destination: &ResolvedSource,
535        request: &CopyRequest,
536        kind: ItemKind,
537        items: &[GlobalId],
538        project: Option<NativeId>,
539    ) -> Result<Vec<CopyOutcome>, EngineError> {
540        let mut planned = Vec::new();
541        for id in items {
542            planned.push(self.plan(destination, request, kind, id).await?);
543        }
544
545        let copied: Vec<GlobalId> = planned.iter().map(|item| item.source.clone()).collect();
546        // Keyed by the qualified id's own rendering, which is what a recorded origin holds
547        // anyway — making `GlobalId` orderable for one local map would put an ordering on
548        // a contract type for a reason no caller of it has.
549        let mut written: BTreeMap<String, NativeId> = BTreeMap::new();
550        for item in &planned {
551            if let Target::Update(id) = &item.target {
552                written.insert(item.source.to_string(), id.clone());
553            }
554        }
555
556        // Resolved once per item, and used by both passes: the second pass writes the
557        // same item again, and re-deriving this there could file it somewhere else.
558        let mut filed = Vec::new();
559        for item in &planned {
560            filed.push(self.filed(destination, item, project.clone()).await?);
561        }
562
563        let mut outcomes = Vec::new();
564        let mut deferred = Vec::new();
565        for (index, item) in planned.iter().enumerate() {
566            let edges = mapped_edges(
567                &item.edges,
568                &item.source.source,
569                destination,
570                &copied,
571                &written,
572            );
573            if edges.iter().any(Option::is_none) {
574                deferred.push(index);
575            }
576            let outcome = self
577                .land(destination, request, item, filed[index].clone(), &edges)
578                .await?;
579            if let Some(id) = outcome.destination() {
580                written.insert(item.source.to_string(), id.native.clone());
581            }
582            outcomes.push(outcome);
583        }
584
585        if request.dry_run {
586            return Ok(outcomes);
587        }
588        for index in deferred {
589            let item = &planned[index];
590            let Some(id) = outcomes[index].destination().cloned() else {
591                continue;
592            };
593            let edges = mapped_edges(
594                &item.edges,
595                &item.source.source,
596                destination,
597                &copied,
598                &written,
599            );
600            self.write(
601                destination,
602                item,
603                Some(id.native),
604                filed[index].clone(),
605                &resolved(&edges),
606            )
607            .await?;
608        }
609        Ok(outcomes)
610    }
611
612    /// Read one item and its forward edges, and decide where it is going.
613    async fn plan(
614        &self,
615        destination: &ResolvedSource,
616        request: &CopyRequest,
617        kind: ItemKind,
618        id: &GlobalId,
619    ) -> Result<Planned, EngineError> {
620        let source = self.readable(&id.source)?;
621        let item = match kind {
622            ItemKind::Task => source
623                .source()
624                .get_task(&id.native)
625                .await
626                .map_err(|error| refused(source, error))?
627                .map(|task| Item::Task(Box::new(task))),
628            ItemKind::Project => source
629                .source()
630                .get_project(&id.native)
631                .await
632                .map_err(|error| refused(source, error))?
633                .map(|project| Item::Project(Box::new(project))),
634        }
635        .ok_or_else(|| EngineError::NoSuchItem { id: id.to_string() })?;
636        let edges = forward_edges(source, &id.native, item.kind()).await?;
637        let target = self.target(destination, request, id, &item).await?;
638        Ok(Planned {
639            source: id.clone(),
640            item,
641            edges,
642            target,
643        })
644    }
645
646    /// Which destination item this one corresponds to, by the two origin rules and the
647    /// caller's escape.
648    async fn target(
649        &self,
650        destination: &ResolvedSource,
651        request: &CopyRequest,
652        id: &GlobalId,
653        item: &Item,
654    ) -> Result<Target, EngineError> {
655        let (title, metadata) = described(item);
656        if let Some(origin) = origin_of(metadata)
657            && &origin.source == destination.name()
658        {
659            if exists(destination, &origin.native, item.kind()).await? {
660                return Ok(Target::Update(origin.native));
661            }
662            if !request.recreate {
663                return Err(EngineError::StaleOrigin {
664                    item: id.to_string(),
665                    origin: origin.to_string(),
666                });
667            }
668        }
669        if let Some(found) = self
670            .scan(destination, item.kind(), &Wanted::Origin(id.to_string()))
671            .await?
672        {
673            return Ok(Target::Update(found));
674        }
675        let wanted = match &request.match_by {
676            Some(MatchBy::Title) => Some(Wanted::Title(title.to_owned())),
677            Some(MatchBy::Metadata(key)) => metadata
678                .get(key)
679                .map(|value| Wanted::Metadata(key.clone(), value.clone())),
680            None => None,
681        };
682        if let Some(wanted) = wanted
683            && let Some(found) = self.scan(destination, item.kind(), &wanted).await?
684        {
685            return Ok(Target::Update(found));
686        }
687        Ok(Target::Create)
688    }
689
690    /// Walk the destination one page at a time, looking for `wanted`.
691    ///
692    /// One page is held at a time and nothing is written down, which is the same bound
693    /// every other compensation in this engine works under.
694    async fn scan(
695        &self,
696        destination: &ResolvedSource,
697        kind: ItemKind,
698        wanted: &Wanted,
699    ) -> Result<Option<NativeId>, EngineError> {
700        let mut cursor = None;
701        loop {
702            let next = match kind {
703                ItemKind::Task => {
704                    let page = destination
705                        .source()
706                        .query_tasks(&TaskQuery::default(), &request_for(destination, cursor))
707                        .await
708                        .map_err(|error| refused(destination, error))?;
709                    for task in &page.items {
710                        if wanted.found(&task.title, &task.metadata) {
711                            return Ok(Some(task.id.clone()));
712                        }
713                    }
714                    page.next
715                }
716                ItemKind::Project => {
717                    let page = destination
718                        .source()
719                        .query_projects(&ProjectQuery::default(), &request_for(destination, cursor))
720                        .await
721                        .map_err(|error| refused(destination, error))?;
722                    for project in &page.items {
723                        if wanted.found(&project.title, &project.metadata) {
724                            return Ok(Some(project.id.clone()));
725                        }
726                    }
727                    page.next
728                }
729            };
730            match next {
731                Some(next) => cursor = Some(next),
732                None => return Ok(None),
733            }
734        }
735    }
736
737    /// Write one planned item, or say what a dry run would have done.
738    async fn land(
739        &self,
740        destination: &ResolvedSource,
741        request: &CopyRequest,
742        item: &Planned,
743        project: Option<NativeId>,
744        edges: &[Option<DependencyEdge>],
745    ) -> Result<CopyOutcome, EngineError> {
746        let target = match &item.target {
747            Target::Update(id) => Some(id.clone()),
748            Target::Create => None,
749        };
750        let edges = resolved(edges);
751        let qualified = |native: NativeId| GlobalId::new(destination.name().clone(), native);
752        if let Some(id) = &target
753            && !self
754                .changes(destination, item, id, &project, &edges)
755                .await?
756        {
757            return Ok(CopyOutcome {
758                source: item.source.clone(),
759                action: CopyAction::Unchanged {
760                    destination: qualified(id.clone()),
761                },
762            });
763        }
764        if request.dry_run {
765            return Ok(CopyOutcome {
766                source: item.source.clone(),
767                action: match target {
768                    Some(id) => CopyAction::Updated {
769                        destination: qualified(id),
770                    },
771                    // Null only here: nothing was created, so there is no id to report.
772                    None => CopyAction::Created { destination: None },
773                },
774            });
775        }
776        let updating = target.is_some();
777        let written = qualified(
778            self.write(destination, item, target, project, &edges)
779                .await?,
780        );
781        Ok(CopyOutcome {
782            source: item.source.clone(),
783            action: if updating {
784                CopyAction::Updated {
785                    destination: written,
786                }
787            } else {
788                CopyAction::Created {
789                    destination: Some(written),
790                }
791            },
792        })
793    }
794
795    /// Which destination project this item is filed under, when it is filed at all.
796    ///
797    /// A task copied as part of a project copy is filed under that project's counterpart,
798    /// which the copy has just established. A task copied on its own has to find it.
799    async fn filed(
800        &self,
801        destination: &ResolvedSource,
802        item: &Planned,
803        project: Option<NativeId>,
804    ) -> Result<Option<NativeId>, EngineError> {
805        match (&item.item, project) {
806            (Item::Task(task), None) => self.counterpart(destination, item, task).await,
807            (Item::Task(_), filed) => Ok(filed),
808            (Item::Project(_), _) => Ok(None),
809        }
810    }
811
812    /// The destination project this task's own project corresponds to, when there is one.
813    ///
814    /// A task copied on its own keeps its source's project id when the destination holds
815    /// no counterpart: the field is opaque to this engine, and dropping it would lose
816    /// what the source said.
817    async fn counterpart(
818        &self,
819        destination: &ResolvedSource,
820        item: &Planned,
821        task: &Task,
822    ) -> Result<Option<NativeId>, EngineError> {
823        let Some(project) = &task.project else {
824            return Ok(None);
825        };
826        let qualified = GlobalId::new(item.source.source.clone(), project.clone());
827        let found = self
828            .scan(
829                destination,
830                ItemKind::Project,
831                &Wanted::Origin(qualified.to_string()),
832            )
833            .await?;
834        Ok(Some(found.unwrap_or_else(|| project.clone())))
835    }
836
837    /// Whether writing this item would change what the destination already holds.
838    async fn changes(
839        &self,
840        destination: &ResolvedSource,
841        item: &Planned,
842        target: &NativeId,
843        project: &Option<NativeId>,
844        edges: &[DependencyEdge],
845    ) -> Result<bool, EngineError> {
846        let held = match &item.item {
847            Item::Task(_) => destination
848                .source()
849                .get_task(target)
850                .await
851                .map_err(|error| refused(destination, error))?
852                .map(|task| Item::Task(Box::new(task))),
853            Item::Project(_) => destination
854                .source()
855                .get_project(target)
856                .await
857                .map_err(|error| refused(destination, error))?
858                .map(|project| Item::Project(Box::new(project))),
859        };
860        let Some(held) = held else {
861            return Ok(true);
862        };
863        let outgoing = outgoing(item, target.clone(), project.clone());
864        if !same(&held, &outgoing) {
865            return Ok(true);
866        }
867        let at_destination = forward_edges(destination, target, item.item.kind()).await?;
868        Ok(!same_edges(&at_destination, edges))
869    }
870
871    /// Hand one item to the destination's own write interface.
872    async fn write(
873        &self,
874        destination: &ResolvedSource,
875        item: &Planned,
876        target: Option<NativeId>,
877        project: Option<NativeId>,
878        edges: &[DependencyEdge],
879    ) -> Result<NativeId, EngineError> {
880        let suggested = target.clone().unwrap_or_else(|| item.item.id().clone());
881        match outgoing(item, suggested, project) {
882            Item::Task(task) => destination
883                .source()
884                .write_task(&ItemWrite {
885                    target,
886                    item: *task,
887                    depends_on: edges.to_vec(),
888                })
889                .await
890                .map_err(|error| refused(destination, error)),
891            Item::Project(project) => destination
892                .source()
893                .write_project(&ItemWrite {
894                    target,
895                    item: *project,
896                    depends_on: edges.to_vec(),
897                })
898                .await
899                .map_err(|error| refused(destination, error)),
900        }
901    }
902
903    /// A configured source that built, for reading an item out of.
904    fn readable(&self, name: &SourceName) -> Result<&ResolvedSource, EngineError> {
905        let name = self.known(name)?;
906        if let Some(unavailable) = self.unavailable().find(|source| source.name() == &name) {
907            return Err(EngineError::SourceRefused {
908                name: name.to_string(),
909                error: unavailable.error().clone(),
910            });
911        }
912        self.ready()
913            .find(|source| source.name() == &name)
914            .ok_or(EngineError::NoSources)
915    }
916}
917
918/// How many tasks of a project are read at once while walking it.
919const PROJECT_PAGE: std::num::NonZeroU32 = std::num::NonZeroU32::new(50).expect("50 is not zero");
920
921/// One page request against `source`, at the largest page it will serve.
922fn request_for(
923    source: &ResolvedSource,
924    cursor: Option<onetaskgraph_plugin_api::Cursor>,
925) -> PageRequest {
926    PageRequest {
927        cursor,
928        limit: source.source().capabilities().max_page_size.max(1),
929    }
930}
931
932/// One source failing while a copy was mid-flight.
933fn refused(source: &ResolvedSource, error: SourceError) -> EngineError {
934    EngineError::SourceRefused {
935        name: source.name().to_string(),
936        error,
937    }
938}
939
940/// Every forward edge at one item, walked to exhaustion one page at a time.
941async fn forward_edges(
942    source: &ResolvedSource,
943    id: &NativeId,
944    kind: ItemKind,
945) -> Result<Vec<DependencyEdge>, EngineError> {
946    let mut edges = Vec::new();
947    let mut cursor = None;
948    loop {
949        let page = match kind {
950            ItemKind::Task => {
951                source
952                    .source()
953                    .task_dependencies(id, Direction::DependsOn, &request_for(source, cursor))
954                    .await
955            }
956            ItemKind::Project => {
957                source
958                    .source()
959                    .project_dependencies(id, Direction::DependsOn, &request_for(source, cursor))
960                    .await
961            }
962        }
963        .map_err(|error| refused(source, error))?;
964        edges.extend(page.items);
965        match page.next {
966            Some(next) => cursor = Some(next),
967            None => return Ok(edges),
968        }
969    }
970}
971
972/// The origin one item records, when it records a usable one.
973fn origin_of(metadata: &BTreeMap<String, Value>) -> Option<GlobalId> {
974    metadata
975        .get(GlobalId::ORIGIN_KEY)?
976        .as_str()?
977        .parse::<GlobalId>()
978        .ok()
979}
980
981/// The title and metadata of either kind of item.
982fn described(item: &Item) -> (&str, &BTreeMap<String, Value>) {
983    match item {
984        Item::Task(task) => (&task.title, &task.metadata),
985        Item::Project(project) => (&project.title, &project.metadata),
986    }
987}
988
989/// The item as the destination should hold it.
990///
991/// `url`, `created_at` and `updated_at` are the destination's own and are never written.
992/// The two reserved keys this product encodes typed fields under are removed, because
993/// those fields travel as themselves — leaving the encoding beside them would have the
994/// destination hold one thing twice, and disagree with itself the moment one changed.
995fn outgoing(item: &Planned, id: NativeId, project: Option<NativeId>) -> Item {
996    let origin = item.source.to_string();
997    match &item.item {
998        Item::Task(task) => Item::Task(Box::new(Task {
999            id,
1000            url: None,
1001            created_at: None,
1002            updated_at: None,
1003            project,
1004            metadata: carried(&task.metadata, &origin),
1005            ..(**task).clone()
1006        })),
1007        Item::Project(project) => Item::Project(Box::new(Project {
1008            id,
1009            url: None,
1010            created_at: None,
1011            updated_at: None,
1012            metadata: carried(&project.metadata, &origin),
1013            ..(**project).clone()
1014        })),
1015    }
1016}
1017
1018/// The metadata a copy carries: the caller's own keys untouched, and the origin recorded.
1019fn carried(metadata: &BTreeMap<String, Value>, origin: &str) -> BTreeMap<String, Value> {
1020    let mut carried = metadata.clone();
1021    carried.remove(Repository::METADATA_KEY);
1022    carried.remove(DependencyEdge::RECORDED_KEY);
1023    carried.insert(
1024        GlobalId::ORIGIN_KEY.to_owned(),
1025        Value::String(origin.to_owned()),
1026    );
1027    carried
1028}
1029
1030/// Whether the destination already reads exactly as this copy would leave it.
1031///
1032/// The destination's own `url` and timestamps are excluded because a copy never writes
1033/// them, so a difference there is not one this copy would close.
1034fn same(held: &Item, outgoing: &Item) -> bool {
1035    match (held, outgoing) {
1036        (Item::Task(held), Item::Task(outgoing)) => {
1037            held.title == outgoing.title
1038                && held.content == outgoing.content
1039                && held.status == outgoing.status
1040                && held.labels == outgoing.labels
1041                && held.project == outgoing.project
1042                && held.metadata == outgoing.metadata
1043                && held.repositories == outgoing.repositories
1044        }
1045        (Item::Project(held), Item::Project(outgoing)) => {
1046            held.title == outgoing.title
1047                && held.content == outgoing.content
1048                && held.status == outgoing.status
1049                && held.labels == outgoing.labels
1050                && held.metadata == outgoing.metadata
1051                && held.repositories == outgoing.repositories
1052        }
1053        _ => false,
1054    }
1055}
1056
1057/// Whether the destination's forward edges already say what this copy would write.
1058fn same_edges(held: &[DependencyEdge], outgoing: &[DependencyEdge]) -> bool {
1059    let ends = |edges: &[DependencyEdge]| {
1060        let mut ends: Vec<(String, ItemKind, DependencyKind)> = edges
1061            .iter()
1062            .map(|edge| (edge.to.id().to_owned(), edge.to.kind, edge.kind))
1063            .collect();
1064        ends.sort_by(|left, right| left.0.cmp(&right.0));
1065        ends
1066    };
1067    ends(held) == ends(outgoing)
1068}
1069
1070/// Each read edge as the destination should record it, or `None` when its far end is a
1071/// member of this copy whose destination id is not known yet.
1072fn mapped_edges(
1073    edges: &[DependencyEdge],
1074    origin: &SourceName,
1075    destination: &ResolvedSource,
1076    copied: &[GlobalId],
1077    written: &BTreeMap<String, NativeId>,
1078) -> Vec<Option<DependencyEdge>> {
1079    edges
1080        .iter()
1081        .map(|edge| {
1082            let far = GlobalId::new(origin.clone(), NativeId(edge.to.id().to_owned()));
1083            let id = if let Some(native) = names(&edge.to, destination.name()) {
1084                // A far end already qualified to the destination's own source is that
1085                // source's own item, so it is written the way that source names its own:
1086                // unqualified. Leaving it qualified would have the destination hold an
1087                // edge into itself written as if it left, which is the one spelling the
1088                // reserved key exists to keep for edges that really do.
1089                Some(native)
1090            } else if edge.to.is_qualified() || origin == destination.name() {
1091                // Already naming a source of its own, or a copy inside one source where
1092                // the far end's own id is the destination's id.
1093                Some(edge.to.id().to_owned())
1094            } else if copied.contains(&far) {
1095                written.get(&far.to_string()).map(|native| native.0.clone())
1096            } else {
1097                Some(far.to_string())
1098            }?;
1099            DependencyEndpoint::new(id, edge.to.kind)
1100                .ok()
1101                .map(|to| DependencyEdge {
1102                    from: edge.from.clone(),
1103                    to,
1104                    kind: edge.kind,
1105                })
1106        })
1107        .collect()
1108}
1109
1110/// The native id a qualified endpoint names at `destination`, when it names one there.
1111fn names(endpoint: &DependencyEndpoint, destination: &SourceName) -> Option<String> {
1112    if !endpoint.is_qualified() {
1113        return None;
1114    }
1115    let id: GlobalId = endpoint.id().parse().ok()?;
1116    (&id.source == destination).then_some(id.native.0)
1117}
1118
1119/// The edges that could be resolved, which is every one of them on the second pass.
1120fn resolved(edges: &[Option<DependencyEdge>]) -> Vec<DependencyEdge> {
1121    edges.iter().flatten().cloned().collect()
1122}
1123
1124/// Whether the destination holds an item with this id.
1125async fn exists(
1126    destination: &ResolvedSource,
1127    id: &NativeId,
1128    kind: ItemKind,
1129) -> Result<bool, EngineError> {
1130    let found = match kind {
1131        ItemKind::Task => destination
1132            .source()
1133            .get_task(id)
1134            .await
1135            .map_err(|error| refused(destination, error))?
1136            .is_some(),
1137        ItemKind::Project => destination
1138            .source()
1139            .get_project(id)
1140            .await
1141            .map_err(|error| refused(destination, error))?
1142            .is_some(),
1143    };
1144    Ok(found)
1145}