radicle_cli/commands/
follow.rs

1use std::ffi::OsString;
2
3use anyhow::anyhow;
4
5use radicle::node::{policy, Alias, AliasStore, Handle, NodeId};
6use radicle::{prelude::*, Node};
7use radicle_term::{Element as _, Paint, Table};
8
9use crate::terminal as term;
10use crate::terminal::args::{Args, Error, Help};
11
12pub const HELP: Help = Help {
13    name: "follow",
14    description: "Manage node follow policies",
15    version: env!("RADICLE_VERSION"),
16    usage: r#"
17Usage
18
19    rad follow [<nid>] [--alias <name>] [<option>...]
20
21    The `follow` command will print all nodes being followed, optionally filtered by alias, if no
22    Node ID is provided.
23    Otherwise, it takes a Node ID, optionally in DID format, and updates the follow policy
24    for that peer, optionally giving the peer the alias provided.
25
26Options
27
28    --alias <name>         Associate an alias to a followed peer
29    --verbose, -v          Verbose output
30    --help                 Print help
31"#,
32};
33
34#[derive(Debug)]
35pub enum Operation {
36    Follow { nid: NodeId, alias: Option<Alias> },
37    List { alias: Option<Alias> },
38}
39
40#[derive(Debug, Default)]
41pub enum OperationName {
42    Follow,
43    #[default]
44    List,
45}
46
47#[derive(Debug)]
48pub struct Options {
49    pub op: Operation,
50    pub verbose: bool,
51}
52
53impl Args for Options {
54    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
55        use lexopt::prelude::*;
56
57        let mut parser = lexopt::Parser::from_args(args);
58        let mut verbose = false;
59        let mut nid: Option<NodeId> = None;
60        let mut alias: Option<Alias> = None;
61
62        while let Some(arg) = parser.next()? {
63            match &arg {
64                Value(val) if nid.is_none() => {
65                    if let Ok(did) = term::args::did(val) {
66                        nid = Some(did.into());
67                    } else if let Ok(val) = term::args::nid(val) {
68                        nid = Some(val);
69                    } else {
70                        anyhow::bail!("invalid Node ID `{}` specified", val.to_string_lossy());
71                    }
72                }
73                Long("alias") if alias.is_none() => {
74                    let name = parser.value()?;
75                    let name = term::args::alias(&name)?;
76
77                    alias = Some(name.to_owned());
78                }
79                Long("verbose") | Short('v') => verbose = true,
80                Long("help") | Short('h') => {
81                    return Err(Error::Help.into());
82                }
83                _ => {
84                    return Err(anyhow!(arg.unexpected()));
85                }
86            }
87        }
88
89        let op = match nid {
90            Some(nid) => Operation::Follow { nid, alias },
91            None => Operation::List { alias },
92        };
93        Ok((Options { op, verbose }, vec![]))
94    }
95}
96
97pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
98    let profile = ctx.profile()?;
99    let mut node = radicle::Node::new(profile.socket());
100
101    match options.op {
102        Operation::Follow { nid, alias } => follow(nid, alias, &mut node, &profile)?,
103        Operation::List { alias } => following(&profile, alias)?,
104    }
105
106    Ok(())
107}
108
109pub fn follow(
110    nid: NodeId,
111    alias: Option<Alias>,
112    node: &mut Node,
113    profile: &Profile,
114) -> Result<(), anyhow::Error> {
115    let followed = match node.follow(nid, alias.clone()) {
116        Ok(updated) => updated,
117        Err(e) if e.is_connection_err() => {
118            let mut config = profile.policies_mut()?;
119            config.follow(&nid, alias.as_ref())?
120        }
121        Err(e) => return Err(e.into()),
122    };
123    let outcome = if followed { "updated" } else { "exists" };
124
125    if let Some(alias) = alias {
126        term::success!(
127            "Follow policy {outcome} for {} ({alias})",
128            term::format::tertiary(nid),
129        );
130    } else {
131        term::success!(
132            "Follow policy {outcome} for {}",
133            term::format::tertiary(nid),
134        );
135    }
136
137    Ok(())
138}
139
140pub fn following(profile: &Profile, alias: Option<Alias>) -> anyhow::Result<()> {
141    let store = profile.policies()?;
142    let aliases = profile.aliases();
143    let mut t = term::Table::new(term::table::TableOptions::bordered());
144    t.header([
145        term::format::default(String::from("DID")),
146        term::format::default(String::from("Alias")),
147        term::format::default(String::from("Policy")),
148    ]);
149    t.divider();
150    push_policies(&mut t, &aliases, store.follow_policies()?, &alias);
151    t.print();
152    Ok(())
153}
154
155fn push_policies(
156    t: &mut Table<3, Paint<String>>,
157    aliases: &impl AliasStore,
158    policies: impl Iterator<Item = Result<policy::FollowPolicy, policy::store::Error>>,
159    filter: &Option<Alias>,
160) {
161    for policy in policies {
162        match policy {
163            Ok(policy::FollowPolicy {
164                nid: id,
165                alias,
166                policy,
167            }) => {
168                if match (filter, &alias) {
169                    (None, _) => false,
170                    (Some(filter), Some(alias)) => *filter != *alias,
171                    (Some(_), None) => true,
172                } {
173                    continue;
174                }
175
176                t.push([
177                    term::format::highlight(Did::from(id).to_string()),
178                    match alias {
179                        None => term::format::secondary(fallback_alias(&id, aliases)),
180                        Some(alias) => term::format::secondary(alias.to_string()),
181                    },
182                    term::format::secondary(policy.to_string()),
183                ]);
184            }
185            Err(err) => {
186                term::error(format!("Failed to read a follow policy: {err}"));
187            }
188        }
189    }
190}
191
192fn fallback_alias(nid: &PublicKey, aliases: &impl AliasStore) -> String {
193    aliases
194        .alias(nid)
195        .map_or("n/a".to_string(), |alias| alias.to_string())
196}