radicle_cli/commands/
sync.rs

1use std::cmp::Ordering;
2use std::collections::BTreeMap;
3use std::collections::BTreeSet;
4use std::collections::HashSet;
5use std::ffi::OsString;
6use std::str::FromStr;
7use std::time;
8
9use anyhow::{anyhow, Context as _};
10
11use radicle::node;
12use radicle::node::address::Store;
13use radicle::node::sync;
14use radicle::node::sync::fetch::SuccessfulOutcome;
15use radicle::node::SyncedAt;
16use radicle::node::{AliasStore, Handle as _, Node, Seed, SyncStatus};
17use radicle::prelude::{NodeId, Profile, RepoId};
18use radicle::storage::ReadRepository;
19use radicle::storage::RefUpdate;
20use radicle::storage::{ReadStorage, RemoteRepository};
21use radicle_term::Element;
22
23use crate::node::SyncReporting;
24use crate::node::SyncSettings;
25use crate::terminal as term;
26use crate::terminal::args::{Args, Error, Help};
27use crate::terminal::format::Author;
28use crate::terminal::{Table, TableOptions};
29
30pub const HELP: Help = Help {
31    name: "sync",
32    description: "Sync repositories to the network",
33    version: env!("RADICLE_VERSION"),
34    usage: r#"
35Usage
36
37    rad sync [--fetch | --announce] [<rid>] [<option>...]
38    rad sync --inventory [<option>...]
39    rad sync status [<rid>] [<option>...]
40
41    By default, the current repository is synchronized both ways.
42    If an <rid> is specified, that repository is synced instead.
43
44    The process begins by fetching changes from connected seeds,
45    followed by announcing local refs to peers, thereby prompting
46    them to fetch from us.
47
48    When `--fetch` is specified, any number of seeds may be given
49    using the `--seed` option, eg. `--seed <nid>@<addr>:<port>`.
50
51    When `--replicas` is specified, the given replication factor will try
52    to be matched. For example, `--replicas 5` will sync with 5 seeds.
53
54    The synchronization process can be configured using `--replicas <min>` and
55    `--replicas-max <max>`. If these options are used independently, then the
56    replication factor is taken as the given `<min>`/`<max>` value. If the
57    options are used together, then the replication factor has a minimum and
58    maximum bound.
59
60    For fetching, the synchronization process will be considered successful if
61    at least `<min>` seeds were fetched from *or* all preferred seeds were
62    fetched from. If `<max>` is specified then the process will continue and
63    attempt to sync with `<max>` seeds.
64
65    For reference announcing, the synchronization process will be considered
66    successful if at least `<min>` seeds were pushed to *and* all preferred
67    seeds were pushed to.
68
69    When `--fetch` or `--announce` are specified on their own, this command
70    will only fetch or announce.
71
72    If `--inventory` is specified, the node's inventory is announced to
73    the network. This mode does not take an `<rid>`.
74
75Commands
76
77    status                    Display the sync status of a repository
78
79Options
80
81        --sort-by       <field>   Sort the table by column (options: nid, alias, status)
82    -f, --fetch                   Turn on fetching (default: true)
83    -a, --announce                Turn on ref announcing (default: true)
84    -i, --inventory               Turn on inventory announcing (default: false)
85        --timeout       <secs>    How many seconds to wait while syncing
86        --seed          <nid>     Sync with the given node (may be specified multiple times)
87    -r, --replicas      <count>   Sync with a specific number of seeds
88        --replicas-max  <count>   Sync with an upper bound number of seeds
89    -v, --verbose                 Verbose output
90        --debug                   Print debug information afer sync
91        --help                    Print help
92"#,
93};
94
95#[derive(Debug, Clone, PartialEq, Eq, Default)]
96pub enum Operation {
97    Synchronize(SyncMode),
98    #[default]
99    Status,
100}
101
102#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
103pub enum SortBy {
104    Nid,
105    Alias,
106    #[default]
107    Status,
108}
109
110impl FromStr for SortBy {
111    type Err = &'static str;
112
113    fn from_str(s: &str) -> Result<Self, Self::Err> {
114        match s {
115            "nid" => Ok(Self::Nid),
116            "alias" => Ok(Self::Alias),
117            "status" => Ok(Self::Status),
118            _ => Err("invalid `--sort-by` field"),
119        }
120    }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum SyncMode {
125    Repo {
126        settings: SyncSettings,
127        direction: SyncDirection,
128    },
129    Inventory,
130}
131
132impl Default for SyncMode {
133    fn default() -> Self {
134        Self::Repo {
135            settings: SyncSettings::default(),
136            direction: SyncDirection::default(),
137        }
138    }
139}
140
141#[derive(Debug, Default, PartialEq, Eq, Clone)]
142pub enum SyncDirection {
143    Fetch,
144    Announce,
145    #[default]
146    Both,
147}
148
149#[derive(Default, Debug)]
150pub struct Options {
151    pub rid: Option<RepoId>,
152    pub debug: bool,
153    pub verbose: bool,
154    pub sort_by: SortBy,
155    pub op: Operation,
156}
157
158impl Args for Options {
159    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
160        use lexopt::prelude::*;
161
162        let mut parser = lexopt::Parser::from_args(args);
163        let mut verbose = false;
164        let mut timeout = time::Duration::from_secs(9);
165        let mut rid = None;
166        let mut fetch = false;
167        let mut announce = false;
168        let mut inventory = false;
169        let mut debug = false;
170        let mut replicas = None;
171        let mut max_replicas = None;
172        let mut seeds = BTreeSet::new();
173        let mut sort_by = SortBy::default();
174        let mut op: Option<Operation> = None;
175
176        while let Some(arg) = parser.next()? {
177            match arg {
178                Long("debug") => {
179                    debug = true;
180                }
181                Long("verbose") | Short('v') => {
182                    verbose = true;
183                }
184                Long("fetch") | Short('f') => {
185                    fetch = true;
186                }
187                Long("replicas") | Short('r') => {
188                    let val = parser.value()?;
189                    let count = term::args::number(&val)?;
190
191                    if count == 0 {
192                        anyhow::bail!("value for `--replicas` must be greater than zero");
193                    }
194                    replicas = Some(count);
195                }
196                Long("replicas-max") => {
197                    let val = parser.value()?;
198                    let count = term::args::number(&val)?;
199
200                    if count == 0 {
201                        anyhow::bail!("value for `--replicas-max` must be greater than zero");
202                    }
203                    max_replicas = Some(count);
204                }
205                Long("seed") => {
206                    let val = parser.value()?;
207                    let nid = term::args::nid(&val)?;
208
209                    seeds.insert(nid);
210                }
211                Long("announce") | Short('a') => {
212                    announce = true;
213                }
214                Long("inventory") | Short('i') => {
215                    inventory = true;
216                }
217                Long("sort-by") if matches!(op, Some(Operation::Status)) => {
218                    let value = parser.value()?;
219                    sort_by = value.parse()?;
220                }
221                Long("timeout") | Short('t') => {
222                    let value = parser.value()?;
223                    let secs = term::args::parse_value("timeout", value)?;
224
225                    timeout = time::Duration::from_secs(secs);
226                }
227                Long("help") | Short('h') => {
228                    return Err(Error::Help.into());
229                }
230                Value(val) if rid.is_none() => match val.to_string_lossy().as_ref() {
231                    "s" | "status" => {
232                        op = Some(Operation::Status);
233                    }
234                    _ => {
235                        rid = Some(term::args::rid(&val)?);
236                    }
237                },
238                arg => {
239                    return Err(anyhow!(arg.unexpected()));
240                }
241            }
242        }
243
244        let sync = if inventory && fetch {
245            anyhow::bail!("`--inventory` cannot be used with `--fetch`");
246        } else if inventory {
247            SyncMode::Inventory
248        } else {
249            let direction = match (fetch, announce) {
250                (true, true) | (false, false) => SyncDirection::Both,
251                (true, false) => SyncDirection::Fetch,
252                (false, true) => SyncDirection::Announce,
253            };
254            let mut settings = SyncSettings::default().timeout(timeout);
255
256            let replicas = match (replicas, max_replicas) {
257                (None, None) => sync::ReplicationFactor::default(),
258                (None, Some(min)) => sync::ReplicationFactor::must_reach(min),
259                (Some(min), None) => sync::ReplicationFactor::must_reach(min),
260                (Some(min), Some(max)) => sync::ReplicationFactor::range(min, max),
261            };
262            settings.replicas = replicas;
263            if !seeds.is_empty() {
264                settings.seeds = seeds;
265            }
266            SyncMode::Repo {
267                settings,
268                direction,
269            }
270        };
271
272        Ok((
273            Options {
274                rid,
275                debug,
276                verbose,
277                sort_by,
278                op: op.unwrap_or(Operation::Synchronize(sync)),
279            },
280            vec![],
281        ))
282    }
283}
284
285pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
286    let profile = ctx.profile()?;
287    let mut node = radicle::Node::new(profile.socket());
288    if !node.is_running() {
289        anyhow::bail!(
290            "to sync a repository, your node must be running. To start it, run `rad node start`"
291        );
292    }
293
294    match &options.op {
295        Operation::Status => {
296            let rid = match options.rid {
297                Some(rid) => rid,
298                None => {
299                    let (_, rid) = radicle::rad::cwd()
300                        .context("Current directory is not a Radicle repository")?;
301                    rid
302                }
303            };
304            sync_status(rid, &mut node, &profile, &options)?;
305        }
306        Operation::Synchronize(SyncMode::Repo {
307            settings,
308            direction,
309        }) => {
310            let rid = match options.rid {
311                Some(rid) => rid,
312                None => {
313                    let (_, rid) = radicle::rad::cwd()
314                        .context("Current directory is not a Radicle repository")?;
315                    rid
316                }
317            };
318            let settings = settings.clone().with_profile(&profile);
319
320            if [SyncDirection::Fetch, SyncDirection::Both].contains(direction) {
321                if !profile.policies()?.is_seeding(&rid)? {
322                    anyhow::bail!("repository {rid} is not seeded");
323                }
324                let result = fetch(rid, settings.clone(), &mut node, &profile)?;
325                display_fetch_result(&result, options.verbose)
326            }
327            if [SyncDirection::Announce, SyncDirection::Both].contains(direction) {
328                announce_refs(rid, settings, &mut node, &profile, &options)?;
329            }
330        }
331        Operation::Synchronize(SyncMode::Inventory) => {
332            announce_inventory(node)?;
333        }
334    }
335    Ok(())
336}
337
338fn sync_status(
339    rid: RepoId,
340    node: &mut Node,
341    profile: &Profile,
342    options: &Options,
343) -> anyhow::Result<()> {
344    const SYMBOL_STATE: &str = "?";
345    const SYMBOL_STATE_UNKNOWN: &str = "•";
346
347    let mut table = Table::<5, term::Label>::new(TableOptions::bordered());
348    let mut seeds: Vec<_> = node.seeds(rid)?.into();
349    let local_nid = node.nid()?;
350    let aliases = profile.aliases();
351
352    table.header([
353        term::format::bold("Node ID").into(),
354        term::format::bold("Alias").into(),
355        term::format::bold(SYMBOL_STATE).into(),
356        term::format::bold("SigRefs").into(),
357        term::format::bold("Timestamp").into(),
358    ]);
359    table.divider();
360
361    sort_seeds_by(local_nid, &mut seeds, &aliases, &options.sort_by);
362
363    for seed in seeds {
364        let (status, head, time) = match seed.sync {
365            Some(SyncStatus::Synced {
366                at: SyncedAt { oid, timestamp },
367            }) => (
368                term::PREFIX_SUCCESS,
369                term::format::oid(oid),
370                term::format::timestamp(timestamp),
371            ),
372            Some(SyncStatus::OutOfSync {
373                remote: SyncedAt { timestamp, .. },
374                local,
375                ..
376            }) if seed.nid == local_nid => (
377                term::PREFIX_WARNING,
378                term::format::oid(local.oid),
379                term::format::timestamp(timestamp),
380            ),
381            Some(SyncStatus::OutOfSync {
382                remote: SyncedAt { oid, timestamp },
383                ..
384            }) => (
385                term::PREFIX_ERROR,
386                term::format::oid(oid),
387                term::format::timestamp(timestamp),
388            ),
389            None if options.verbose => (
390                term::format::dim(SYMBOL_STATE_UNKNOWN),
391                term::paint(String::new()),
392                term::paint(String::new()),
393            ),
394            None => continue,
395        };
396
397        let (alias, nid) = Author::new(&seed.nid, profile, options.verbose).labels();
398
399        table.push([
400            nid,
401            alias,
402            status.into(),
403            term::format::secondary(head).into(),
404            time.dim().italic().into(),
405        ]);
406    }
407    table.print();
408
409    if profile.hints() {
410        const COLUMN_WIDTH: usize = 16;
411        let status = format!(
412            "\n{:>4} … {}\n       {}   {}\n       {}   {}",
413            term::Paint::from(SYMBOL_STATE.to_string()).fg(radicle_term::Color::White),
414            term::format::dim("Status:"),
415            format_args!(
416                "{} {:width$}",
417                term::PREFIX_SUCCESS,
418                term::format::dim("… in sync"),
419                width = COLUMN_WIDTH,
420            ),
421            format_args!(
422                "{} {}",
423                term::PREFIX_ERROR,
424                term::format::dim("… out of sync")
425            ),
426            format_args!(
427                "{} {:width$}",
428                term::PREFIX_WARNING,
429                term::format::dim("… not announced"),
430                width = COLUMN_WIDTH,
431            ),
432            format_args!(
433                "{} {}",
434                term::format::dim(SYMBOL_STATE_UNKNOWN),
435                term::format::dim("… unknown")
436            ),
437        );
438        term::hint(status);
439    }
440
441    Ok(())
442}
443
444fn announce_refs(
445    rid: RepoId,
446    settings: SyncSettings,
447    node: &mut Node,
448    profile: &Profile,
449    options: &Options,
450) -> anyhow::Result<()> {
451    let Ok(repo) = profile.storage.repository(rid) else {
452        return Err(anyhow!(
453            "nothing to announce, repository {rid} is not available locally"
454        ));
455    };
456    if let Err(e) = repo.remote(&profile.public_key) {
457        if e.is_not_found() {
458            term::print(term::format::italic(
459                "Nothing to announce, you don't have a fork of this repository.",
460            ));
461            return Ok(());
462        } else {
463            return Err(anyhow!("failed to load local fork of {rid}: {e}"));
464        }
465    }
466
467    let result = crate::node::announce(
468        &repo,
469        settings,
470        SyncReporting {
471            debug: options.debug,
472            ..SyncReporting::default()
473        },
474        node,
475        profile,
476    )?;
477    if let Some(result) = result {
478        print_announcer_result(&result, options.verbose)
479    }
480
481    Ok(())
482}
483
484pub fn announce_inventory(mut node: Node) -> anyhow::Result<()> {
485    let peers = node.sessions()?.iter().filter(|s| s.is_connected()).count();
486    let spinner = term::spinner(format!("Announcing inventory to {peers} peers.."));
487
488    node.announce_inventory()?;
489    spinner.finish();
490
491    Ok(())
492}
493
494#[derive(Debug, thiserror::Error)]
495pub enum FetchError {
496    #[error(transparent)]
497    Node(#[from] node::Error),
498    #[error(transparent)]
499    Db(#[from] node::db::Error),
500    #[error(transparent)]
501    Address(#[from] node::address::Error),
502    #[error(transparent)]
503    Fetcher(#[from] sync::FetcherError),
504}
505
506pub fn fetch(
507    rid: RepoId,
508    settings: SyncSettings,
509    node: &mut Node,
510    profile: &Profile,
511) -> Result<sync::FetcherResult, FetchError> {
512    let db = profile.database()?;
513    let local = profile.id();
514    let is_private = profile.storage.repository(rid).ok().and_then(|repo| {
515        let doc = repo.identity_doc().ok()?.doc;
516        sync::PrivateNetwork::private_repo(&doc)
517    });
518    let config = match is_private {
519        Some(private) => sync::FetcherConfig::private(private, settings.replicas, *local),
520        None => {
521            // We push nodes that are in our seed list in attempt to fulfill the
522            // replicas, if needed.
523            let seeds = node.seeds(rid)?;
524            let (connected, disconnected) = seeds.partition();
525            let candidates = connected
526                .into_iter()
527                .map(|seed| seed.nid)
528                .chain(disconnected.into_iter().map(|seed| seed.nid))
529                .map(sync::fetch::Candidate::new);
530            sync::FetcherConfig::public(settings.seeds.clone(), settings.replicas, *local)
531                .with_candidates(candidates)
532        }
533    };
534    let mut fetcher = sync::Fetcher::new(config)?;
535
536    let mut progress = fetcher.progress();
537    term::info!(
538        "Fetching {} from the network, found {} potential seed(s).",
539        term::format::tertiary(rid),
540        term::format::tertiary(progress.candidate())
541    );
542    let mut spinner = FetcherSpinner::new(fetcher.target(), &progress);
543
544    while let Some(nid) = fetcher.next_node() {
545        match node.session(nid)? {
546            Some(session) if session.is_connected() => fetcher.ready_to_fetch(nid, session.addr),
547            _ => {
548                let addrs = db.addresses_of(&nid)?;
549                if addrs.is_empty() {
550                    fetcher.fetch_failed(nid, "Could not connect. No addresses known.");
551                } else if let Some(addr) = connect(
552                    nid,
553                    addrs.into_iter().map(|ka| ka.addr),
554                    settings.timeout,
555                    node,
556                    &mut spinner,
557                    &fetcher.progress(),
558                ) {
559                    fetcher.ready_to_fetch(nid, addr)
560                } else {
561                    fetcher
562                        .fetch_failed(nid, "Could not connect. At least one address is known but all attempts timed out.");
563                }
564            }
565        }
566        if let Some((nid, addr)) = fetcher.next_fetch() {
567            spinner.emit_fetching(&nid, &addr, &progress);
568            let result = node.fetch(rid, nid, settings.timeout)?;
569            match fetcher.fetch_complete(nid, result) {
570                std::ops::ControlFlow::Continue(update) => {
571                    spinner.emit_progress(&update);
572                    progress = update
573                }
574                std::ops::ControlFlow::Break(success) => {
575                    spinner.finished(success.outcome());
576                    return Ok(sync::FetcherResult::TargetReached(success));
577                }
578            }
579        }
580    }
581    let result = fetcher.finish();
582    match &result {
583        sync::FetcherResult::TargetReached(success) => {
584            spinner.finished(success.outcome());
585        }
586        sync::FetcherResult::TargetError(missed) => spinner.failed(missed),
587    }
588    Ok(result)
589}
590
591// Try all addresses until one succeeds.
592// FIXME(fintohaps): I think this could return a `Result<node::Address,
593// Vec<AddressError>>` which could report back why each address failed
594fn connect(
595    nid: NodeId,
596    addrs: impl Iterator<Item = node::Address>,
597    timeout: time::Duration,
598    node: &mut Node,
599    spinner: &mut FetcherSpinner,
600    progress: &sync::fetch::Progress,
601) -> Option<node::Address> {
602    for addr in addrs {
603        spinner.emit_dialing(&nid, &addr, progress);
604        let cr = node.connect(
605            nid,
606            addr.clone(),
607            node::ConnectOptions {
608                persistent: false,
609                timeout,
610            },
611        );
612
613        match cr {
614            Ok(node::ConnectResult::Connected) => {
615                return Some(addr);
616            }
617            Ok(node::ConnectResult::Disconnected { .. }) => {
618                continue;
619            }
620            Err(e) => {
621                log::warn!(target: "cli", "Failed to connect to {nid}@{addr}: {e}");
622                continue;
623            }
624        }
625    }
626    None
627}
628
629fn sort_seeds_by(local: NodeId, seeds: &mut [Seed], aliases: &impl AliasStore, sort_by: &SortBy) {
630    let compare = |a: &Seed, b: &Seed| match sort_by {
631        SortBy::Nid => a.nid.cmp(&b.nid),
632        SortBy::Alias => {
633            let a = aliases.alias(&a.nid);
634            let b = aliases.alias(&b.nid);
635            a.cmp(&b)
636        }
637        SortBy::Status => match (&a.sync, &b.sync) {
638            (Some(_), None) => Ordering::Less,
639            (None, Some(_)) => Ordering::Greater,
640            (Some(a), Some(b)) => a.cmp(b).reverse(),
641            (None, None) => Ordering::Equal,
642        },
643    };
644
645    // Always show our local node first.
646    seeds.sort_by(|a, b| {
647        if a.nid == local {
648            Ordering::Less
649        } else if b.nid == local {
650            Ordering::Greater
651        } else {
652            compare(a, b)
653        }
654    });
655}
656
657struct FetcherSpinner {
658    preferred_seeds: usize,
659    replicas: sync::ReplicationFactor,
660    spinner: term::Spinner,
661}
662
663impl FetcherSpinner {
664    fn new(target: &sync::fetch::Target, progress: &sync::fetch::Progress) -> Self {
665        let preferred_seeds = target.preferred_seeds().len();
666        let replicas = target.replicas();
667        let spinner = term::spinner(format!(
668            "{} of {} preferred seeds, and {} of at least {} total seeds.",
669            term::format::secondary(progress.preferred()),
670            term::format::secondary(preferred_seeds),
671            term::format::secondary(progress.succeeded()),
672            term::format::secondary(replicas.lower_bound())
673        ));
674        Self {
675            preferred_seeds: target.preferred_seeds().len(),
676            replicas: *target.replicas(),
677            spinner,
678        }
679    }
680
681    fn emit_progress(&mut self, progress: &sync::fetch::Progress) {
682        self.spinner.message(format!(
683            "{} of {} preferred seeds, and {} of at least {} total seeds.",
684            term::format::secondary(progress.preferred()),
685            term::format::secondary(self.preferred_seeds),
686            term::format::secondary(progress.succeeded()),
687            term::format::secondary(self.replicas.lower_bound()),
688        ))
689    }
690
691    fn emit_fetching(
692        &mut self,
693        node: &NodeId,
694        addr: &node::Address,
695        progress: &sync::fetch::Progress,
696    ) {
697        self.spinner.message(format!(
698            "{} of {} preferred seeds, and {} of at least {} total seeds… [fetch {}@{}]",
699            term::format::secondary(progress.preferred()),
700            term::format::secondary(self.preferred_seeds),
701            term::format::secondary(progress.succeeded()),
702            term::format::secondary(self.replicas.lower_bound()),
703            term::format::tertiary(term::format::node_id_human_compact(node)),
704            term::format::tertiary(term::format::addr_compact(addr)),
705        ))
706    }
707
708    fn emit_dialing(
709        &mut self,
710        node: &NodeId,
711        addr: &node::Address,
712        progress: &sync::fetch::Progress,
713    ) {
714        self.spinner.message(format!(
715            "{} of {} preferred seeds, and {} of at least {} total seeds… [dial {}@{}]",
716            term::format::secondary(progress.preferred()),
717            term::format::secondary(self.preferred_seeds),
718            term::format::secondary(progress.succeeded()),
719            term::format::secondary(self.replicas.lower_bound()),
720            term::format::tertiary(term::format::node_id_human_compact(node)),
721            term::format::tertiary(term::format::addr_compact(addr)),
722        ))
723    }
724
725    fn finished(mut self, outcome: &SuccessfulOutcome) {
726        match outcome {
727            SuccessfulOutcome::PreferredNodes { preferred } => {
728                self.spinner.message(format!(
729                    "Target met: {} preferred seed(s).",
730                    term::format::positive(preferred),
731                ));
732            }
733            SuccessfulOutcome::MinReplicas { succeeded, .. } => {
734                self.spinner.message(format!(
735                    "Target met: {} seed(s)",
736                    term::format::positive(succeeded)
737                ));
738            }
739            SuccessfulOutcome::MaxReplicas {
740                succeeded,
741                min,
742                max,
743            } => {
744                self.spinner.message(format!(
745                    "Target met: {} of {} min and {} max seed(s)",
746                    succeeded,
747                    term::format::secondary(min),
748                    term::format::secondary(max)
749                ));
750            }
751        }
752        self.spinner.finish()
753    }
754
755    fn failed(mut self, missed: &sync::fetch::TargetMissed) {
756        let mut message = "Target not met: ".to_string();
757        let missing_preferred_seeds = missed
758            .missed_nodes()
759            .iter()
760            .map(|nid| term::format::node_id_human(nid).to_string())
761            .collect::<Vec<_>>();
762        let required = missed.required_nodes();
763        if !missing_preferred_seeds.is_empty() {
764            message.push_str(&format!(
765                "could not fetch from [{}], and required {} more seed(s)",
766                missing_preferred_seeds.join(", "),
767                required
768            ));
769        } else {
770            message.push_str(&format!("required {required} more seed(s)"));
771        }
772        self.spinner.message(message);
773        self.spinner.failed();
774    }
775}
776
777fn display_fetch_result(result: &sync::FetcherResult, verbose: bool) {
778    match result {
779        sync::FetcherResult::TargetReached(success) => {
780            let progress = success.progress();
781            let results = success.fetch_results();
782            display_success(results.success(), verbose);
783            let failed = progress.failed();
784            if failed > 0 && verbose {
785                term::warning(format!("Failed to fetch from {failed} seed(s)."));
786                for (node, reason) in results.failed() {
787                    term::warning(format!(
788                        "{}: {}",
789                        term::format::node_id_human(node),
790                        term::format::yellow(reason),
791                    ))
792                }
793            }
794        }
795        sync::FetcherResult::TargetError(failed) => {
796            let results = failed.fetch_results();
797            let progress = failed.progress();
798            let target = failed.target();
799            let succeeded = progress.succeeded();
800            let missed = failed.missed_nodes();
801            term::error(format!(
802                "Fetched from {} preferred seed(s), could not reach {} seed(s)",
803                succeeded,
804                target.replicas().lower_bound(),
805            ));
806            term::error(format!(
807                "Could not replicate from {} preferred seed(s)",
808                missed.len()
809            ));
810            for (node, reason) in results.failed() {
811                term::error(format!(
812                    "{}: {}",
813                    term::format::node_id_human(node),
814                    term::format::negative(reason),
815                ))
816            }
817            if succeeded > 0 {
818                term::info!("Successfully fetched from the following seeds:");
819                display_success(results.success(), verbose)
820            }
821        }
822    }
823}
824
825fn display_success<'a>(
826    results: impl Iterator<Item = (&'a NodeId, &'a [RefUpdate], HashSet<NodeId>)>,
827    verbose: bool,
828) {
829    for (node, updates, _) in results {
830        term::println(
831            "🌱 Fetched from",
832            term::format::secondary(term::format::node_id_human(node)),
833        );
834        if verbose {
835            let mut updates = updates
836                .iter()
837                .filter(|up| !matches!(up, RefUpdate::Skipped { .. }))
838                .peekable();
839            if updates.peek().is_none() {
840                term::indented(term::format::italic("no references were updated"));
841            } else {
842                for update in updates {
843                    term::indented(term::format::ref_update_verbose(update))
844                }
845            }
846        }
847    }
848}
849
850fn print_announcer_result(result: &sync::AnnouncerResult, verbose: bool) {
851    use sync::announce::SuccessfulOutcome::*;
852    match result {
853        sync::AnnouncerResult::Success(success) if verbose => {
854            // N.b. Printing how many seeds were synced with is printed
855            // elsewhere
856            match success.outcome() {
857                MinReplicationFactor { preferred, synced }
858                | MaxReplicationFactor { preferred, synced }
859                | PreferredNodes {
860                    preferred,
861                    total_nodes_synced: synced,
862                } => {
863                    if preferred == 0 {
864                        term::success!("Synced {} seed(s)", term::format::positive(synced));
865                    } else {
866                        term::success!(
867                            "Synced {} preferred seed(s) and {} total seed(s)",
868                            term::format::positive(preferred),
869                            term::format::positive(synced)
870                        );
871                    }
872                }
873            }
874            print_synced(success.synced());
875        }
876        sync::AnnouncerResult::Success(_) => {
877            // Successes are ignored when `!verbose`.
878        }
879        sync::AnnouncerResult::TimedOut(result) => {
880            if result.synced().is_empty() {
881                term::error("All seeds timed out, use `rad sync -v` to see the list of seeds");
882                return;
883            }
884            let timed_out = result.timed_out();
885            term::warning(format!(
886                "{} seed(s) timed out, use `rad sync -v` to see the list of seeds",
887                timed_out.len(),
888            ));
889            if verbose {
890                print_synced(result.synced());
891                for node in timed_out {
892                    term::warning(format!("{} timed out", term::format::node_id_human(node)));
893                }
894            }
895        }
896        sync::AnnouncerResult::NoNodes(result) => {
897            term::info!("Announcement could not sync with anymore seeds.");
898            if verbose {
899                print_synced(result.synced())
900            }
901        }
902    }
903}
904
905fn print_synced(synced: &BTreeMap<NodeId, sync::announce::SyncStatus>) {
906    for (node, status) in synced.iter() {
907        let mut message = format!("🌱 Synced with {}", term::format::node_id_human(node));
908
909        match status {
910            sync::announce::SyncStatus::AlreadySynced => {
911                message.push_str(&format!("{}", term::format::dim(" (already in sync)")));
912            }
913            sync::announce::SyncStatus::Synced { duration } => {
914                message.push_str(&format!(
915                    "{}",
916                    term::format::dim(format!(" in {}s", duration.as_secs()))
917                ));
918            }
919        }
920        term::info!("{}", message);
921    }
922}