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