radicle_cli/commands/
inbox.rs

1use std::ffi::OsString;
2use std::path::Path;
3use std::process;
4
5use anyhow::anyhow;
6
7use git_ref_format::Qualified;
8use localtime::LocalTime;
9use radicle::cob::TypedId;
10use radicle::identity::Identity;
11use radicle::issue::cache::Issues as _;
12use radicle::node::notifications;
13use radicle::node::notifications::*;
14use radicle::patch::cache::Patches as _;
15use radicle::prelude::{NodeId, Profile, RepoId};
16use radicle::storage::{BranchName, ReadRepository, ReadStorage};
17use radicle::{cob, git, Storage};
18
19use term::Element as _;
20
21use crate::terminal as term;
22use crate::terminal::args;
23use crate::terminal::args::{Args, Error, Help};
24
25pub const HELP: Help = Help {
26    name: "inbox",
27    description: "Manage your Radicle notifications",
28    version: env!("RADICLE_VERSION"),
29    usage: r#"
30Usage
31
32    rad inbox [<option>...]
33    rad inbox list [<option>...]
34    rad inbox show <id> [<option>...]
35    rad inbox clear [<option>...]
36
37    By default, this command lists all items in your inbox.
38    If your working directory is a Radicle repository, it only shows item
39    belonging to this repository, unless `--all` is used.
40
41    The `rad inbox show` command takes a notification ID (which can be found in
42    the `list` command) and displays the information related to that
43    notification. This will mark the notification as read.
44
45    The `rad inbox clear` command will delete all notifications in the inbox.
46
47Options
48
49    --all                Operate on all repositories
50    --repo <rid>         Operate on the given repository (default: rad .)
51    --sort-by <field>    Sort by `id` or `timestamp` (default: timestamp)
52    --reverse, -r        Reverse the list
53    --show-unknown       Show any updates that were not recognized
54    --help               Print help
55"#,
56};
57
58#[derive(Debug, Default, PartialEq, Eq)]
59enum Operation {
60    #[default]
61    List,
62    Show,
63    Clear,
64}
65
66#[derive(Default, Debug)]
67enum Mode {
68    #[default]
69    Contextual,
70    All,
71    ById(Vec<NotificationId>),
72    ByRepo(RepoId),
73}
74
75#[derive(Clone, Copy, Debug)]
76struct SortBy {
77    reverse: bool,
78    field: &'static str,
79}
80
81pub struct Options {
82    op: Operation,
83    mode: Mode,
84    sort_by: SortBy,
85    show_unknown: bool,
86}
87
88impl Args for Options {
89    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
90        use lexopt::prelude::*;
91
92        let mut parser = lexopt::Parser::from_args(args);
93        let mut op: Option<Operation> = None;
94        let mut mode = None;
95        let mut ids = Vec::new();
96        let mut reverse = None;
97        let mut field = None;
98        let mut show_unknown = false;
99
100        while let Some(arg) = parser.next()? {
101            match arg {
102                Long("help") | Short('h') => {
103                    return Err(Error::Help.into());
104                }
105                Long("all") | Short('a') if mode.is_none() => {
106                    mode = Some(Mode::All);
107                }
108                Long("reverse") | Short('r') => {
109                    reverse = Some(true);
110                }
111                Long("show-unknown") => {
112                    show_unknown = true;
113                }
114                Long("sort-by") => {
115                    let val = parser.value()?;
116
117                    match term::args::string(&val).as_str() {
118                        "timestamp" => field = Some("timestamp"),
119                        "id" => field = Some("rowid"),
120                        other => {
121                            return Err(anyhow!(
122                                "unknown sorting field `{other}`, see `rad inbox --help`"
123                            ))
124                        }
125                    }
126                }
127                Long("repo") if mode.is_none() => {
128                    let val = parser.value()?;
129                    let repo = args::rid(&val)?;
130
131                    mode = Some(Mode::ByRepo(repo));
132                }
133                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
134                    "list" => op = Some(Operation::List),
135                    "show" => op = Some(Operation::Show),
136                    "clear" => op = Some(Operation::Clear),
137                    cmd => return Err(anyhow!("unknown command `{cmd}`, see `rad inbox --help`")),
138                },
139                Value(val) if op.is_some() && mode.is_none() => {
140                    let id = term::args::number(&val)? as NotificationId;
141                    ids.push(id);
142                }
143                _ => return Err(anyhow::anyhow!(arg.unexpected())),
144            }
145        }
146        let mode = if ids.is_empty() {
147            mode.unwrap_or_default()
148        } else {
149            Mode::ById(ids)
150        };
151        let op = op.unwrap_or_default();
152
153        let sort_by = if let Some(field) = field {
154            SortBy {
155                field,
156                reverse: reverse.unwrap_or(false),
157            }
158        } else {
159            SortBy {
160                field: "timestamp",
161                reverse: true,
162            }
163        };
164
165        Ok((
166            Options {
167                op,
168                mode,
169                sort_by,
170                show_unknown,
171            },
172            vec![],
173        ))
174    }
175}
176
177pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
178    let profile = ctx.profile()?;
179    let storage = &profile.storage;
180    let mut notifs = profile.notifications_mut()?;
181    let Options {
182        op,
183        mode,
184        sort_by,
185        show_unknown,
186    } = options;
187
188    match op {
189        Operation::List => list(
190            mode,
191            sort_by,
192            show_unknown,
193            &notifs.read_only(),
194            storage,
195            &profile,
196        ),
197        Operation::Clear => clear(mode, &mut notifs),
198        Operation::Show => show(mode, &mut notifs, storage, &profile),
199    }
200}
201
202fn list(
203    mode: Mode,
204    sort_by: SortBy,
205    show_unknown: bool,
206    notifs: &notifications::StoreReader,
207    storage: &Storage,
208    profile: &Profile,
209) -> anyhow::Result<()> {
210    let repos: Vec<term::VStack<'_>> = match mode {
211        Mode::Contextual => {
212            if let Ok((_, rid)) = radicle::rad::cwd() {
213                list_repo(rid, sort_by, show_unknown, notifs, storage, profile)?
214                    .into_iter()
215                    .collect()
216            } else {
217                list_all(sort_by, show_unknown, notifs, storage, profile)?
218            }
219        }
220        Mode::ByRepo(rid) => list_repo(rid, sort_by, show_unknown, notifs, storage, profile)?
221            .into_iter()
222            .collect(),
223        Mode::All => list_all(sort_by, show_unknown, notifs, storage, profile)?,
224        Mode::ById(_) => anyhow::bail!("the `list` command does not take IDs"),
225    };
226
227    if repos.is_empty() {
228        term::print(term::format::italic("Your inbox is empty."));
229    } else {
230        for repo in repos {
231            repo.print();
232        }
233    }
234    Ok(())
235}
236
237fn list_all<'a>(
238    sort_by: SortBy,
239    show_unknown: bool,
240    notifs: &notifications::StoreReader,
241    storage: &Storage,
242    profile: &Profile,
243) -> anyhow::Result<Vec<term::VStack<'a>>> {
244    let mut repos = storage.repositories()?;
245    repos.sort_by_key(|r| r.rid);
246
247    let mut vstacks = Vec::new();
248    for repo in repos {
249        let vstack = list_repo(repo.rid, sort_by, show_unknown, notifs, storage, profile)?;
250        vstacks.extend(vstack.into_iter());
251    }
252    Ok(vstacks)
253}
254
255fn list_repo<'a, R: ReadStorage>(
256    rid: RepoId,
257    sort_by: SortBy,
258    show_unknown: bool,
259    notifs: &notifications::StoreReader,
260    storage: &R,
261    profile: &Profile,
262) -> anyhow::Result<Option<term::VStack<'a>>>
263where
264    <R as ReadStorage>::Repository: cob::Store<Namespace = NodeId>,
265{
266    let mut table = term::Table::new(term::TableOptions {
267        spacing: 3,
268        ..term::TableOptions::default()
269    });
270    let repo = storage.repository(rid)?;
271    let (_, head) = repo.head()?;
272    let doc = repo.identity_doc()?;
273    let proj = doc.project()?;
274    let issues = term::cob::issues(profile, &repo)?;
275    let patches = term::cob::patches(profile, &repo)?;
276
277    let mut notifs = notifs.by_repo(&rid, sort_by.field)?.collect::<Vec<_>>();
278    if !sort_by.reverse {
279        // Notifications are returned in descendant order by default.
280        notifs.reverse();
281    }
282
283    for n in notifs {
284        let n: Notification = n?;
285
286        let seen = if n.status.is_read() {
287            term::Label::blank()
288        } else {
289            term::format::tertiary(String::from("●")).into()
290        };
291        let author = n
292            .remote
293            .map(|r| {
294                let (alias, _) = term::format::Author::new(&r, profile).labels();
295                alias
296            })
297            .unwrap_or_default();
298        let notification_id = term::format::dim(format!("{:-03}", n.id)).into();
299        let timestamp = term::format::italic(term::format::timestamp(n.timestamp)).into();
300
301        let NotificationRow {
302            category,
303            summary,
304            state,
305            name,
306        } = match &n.kind {
307            NotificationKind::Branch { name } => NotificationRow::branch(name, head, &n, &repo)?,
308            NotificationKind::Cob { typed_id } => {
309                match NotificationRow::cob(typed_id, &n, &issues, &patches, &repo) {
310                    Ok(Some(row)) => row,
311                    Ok(None) => continue,
312                    Err(e) => {
313                        log::error!(target: "cli", "Error loading notification for {typed_id}: {e}");
314                        continue;
315                    }
316                }
317            }
318            NotificationKind::Unknown { refname } => {
319                if show_unknown {
320                    NotificationRow::unknown(refname, &n, &repo)?
321                } else {
322                    continue;
323                }
324            }
325        };
326        table.push([
327            notification_id,
328            seen,
329            name.into(),
330            summary.into(),
331            category.into(),
332            state.into(),
333            author,
334            timestamp,
335        ]);
336    }
337
338    if table.is_empty() {
339        Ok(None)
340    } else {
341        Ok(Some(
342            term::VStack::default()
343                .border(Some(term::colors::FAINT))
344                .child(term::label(term::format::bold(proj.name())))
345                .divider()
346                .child(table),
347        ))
348    }
349}
350
351struct NotificationRow {
352    category: term::Paint<String>,
353    summary: term::Paint<String>,
354    state: term::Paint<String>,
355    name: term::Paint<term::Paint<String>>,
356}
357
358impl NotificationRow {
359    fn new(
360        category: String,
361        summary: String,
362        state: term::Paint<String>,
363        name: term::Paint<String>,
364    ) -> Self {
365        Self {
366            category: term::format::dim(category),
367            summary: term::Paint::new(summary),
368            state,
369            name: term::format::tertiary(name),
370        }
371    }
372
373    fn branch<S>(
374        name: &BranchName,
375        head: git::Oid,
376        n: &Notification,
377        repo: &S,
378    ) -> anyhow::Result<Self>
379    where
380        S: ReadRepository,
381    {
382        let commit = if let Some(head) = n.update.new() {
383            repo.commit(head)?.summary().unwrap_or_default().to_owned()
384        } else {
385            String::new()
386        };
387
388        let state = match n
389            .update
390            .new()
391            .map(|oid| repo.is_ancestor_of(oid, head))
392            .transpose()
393        {
394            Ok(Some(true)) => term::Paint::<String>::from(term::format::secondary("merged")),
395            Ok(Some(false)) | Ok(None) => term::format::ref_update(&n.update).into(),
396            Err(e) => return Err(e.into()),
397        }
398        .to_owned();
399
400        Ok(Self::new(
401            "branch".to_string(),
402            commit,
403            state,
404            term::format::default(name.to_string()),
405        ))
406    }
407
408    fn cob<S, I, P>(
409        typed_id: &TypedId,
410        n: &Notification,
411        issues: &I,
412        patches: &P,
413        repo: &S,
414    ) -> anyhow::Result<Option<Self>>
415    where
416        S: ReadRepository + cob::Store,
417        I: cob::issue::cache::Issues,
418        P: cob::patch::cache::Patches,
419    {
420        let TypedId { id, .. } = typed_id;
421        let (category, summary, state) = if typed_id.is_issue() {
422            let Some(issue) = issues.get(id)? else {
423                // Issue could have been deleted after notification was created.
424                return Ok(None);
425            };
426            (
427                String::from("issue"),
428                issue.title().to_owned(),
429                term::format::issue::state(issue.state()),
430            )
431        } else if typed_id.is_patch() {
432            let Some(patch) = patches.get(id)? else {
433                // Patch could have been deleted after notification was created.
434                return Ok(None);
435            };
436            (
437                String::from("patch"),
438                patch.title().to_owned(),
439                term::format::patch::state(patch.state()),
440            )
441        } else if typed_id.is_identity() {
442            let Ok(identity) = Identity::get(id, repo) else {
443                log::error!(
444                    target: "cli",
445                    "Error retrieving identity {id} for notification {}", n.id
446                );
447                return Ok(None);
448            };
449            let Some(rev) = n.update.new().and_then(|id| identity.revision(&id)) else {
450                log::error!(
451                    target: "cli",
452                    "Error retrieving identity revision for notification {}", n.id
453                );
454                return Ok(None);
455            };
456            (
457                String::from("id"),
458                rev.title.clone(),
459                term::format::identity::state(&rev.state),
460            )
461        } else {
462            (
463                typed_id.type_name.to_string(),
464                "".to_owned(),
465                term::format::default(String::new()),
466            )
467        };
468        Ok(Some(Self::new(
469            category,
470            summary,
471            state,
472            term::format::cob(id),
473        )))
474    }
475
476    fn unknown<S>(refname: &Qualified<'static>, n: &Notification, repo: &S) -> anyhow::Result<Self>
477    where
478        S: ReadRepository,
479    {
480        let commit = if let Some(head) = n.update.new() {
481            repo.commit(head)?.summary().unwrap_or_default().to_owned()
482        } else {
483            String::new()
484        };
485        Ok(Self::new(
486            "unknown".to_string(),
487            commit,
488            "".into(),
489            term::format::default(refname.to_string()),
490        ))
491    }
492}
493
494fn clear(mode: Mode, notifs: &mut notifications::StoreWriter) -> anyhow::Result<()> {
495    let cleared = match mode {
496        Mode::All => notifs.clear_all()?,
497        Mode::ById(ids) => notifs.clear(&ids)?,
498        Mode::ByRepo(rid) => notifs.clear_by_repo(&rid)?,
499        Mode::Contextual => {
500            if let Ok((_, rid)) = radicle::rad::cwd() {
501                notifs.clear_by_repo(&rid)?
502            } else {
503                return Err(Error::WithHint {
504                    err: anyhow!("not a radicle repository"),
505                    hint: "to clear all repository notifications, use the `--all` flag",
506                }
507                .into());
508            }
509        }
510    };
511    if cleared > 0 {
512        term::success!("Cleared {cleared} item(s) from your inbox");
513    } else {
514        term::print(term::format::italic("Your inbox is empty."));
515    }
516    Ok(())
517}
518
519fn show(
520    mode: Mode,
521    notifs: &mut notifications::StoreWriter,
522    storage: &Storage,
523    profile: &Profile,
524) -> anyhow::Result<()> {
525    let id = match mode {
526        Mode::ById(ids) => match ids.as_slice() {
527            [id] => *id,
528            [] => anyhow::bail!("a Notification ID must be given"),
529            _ => anyhow::bail!("too many Notification IDs given"),
530        },
531        _ => anyhow::bail!("a Notification ID must be given"),
532    };
533    let n = notifs.get(id)?;
534    let repo = storage.repository(n.repo)?;
535
536    match n.kind {
537        NotificationKind::Cob { typed_id } if typed_id.is_issue() => {
538            let issues = term::cob::issues(profile, &repo)?;
539            let issue = issues.get(&typed_id.id)?.unwrap();
540
541            term::issue::show(
542                &issue,
543                &typed_id.id,
544                term::issue::Format::default(),
545                profile,
546            )?;
547        }
548        NotificationKind::Cob { typed_id } if typed_id.is_patch() => {
549            let patches = term::cob::patches(profile, &repo)?;
550            let patch = patches.get(&typed_id.id)?.unwrap();
551
552            term::patch::show(&patch, &typed_id.id, false, &repo, None, profile)?;
553        }
554        NotificationKind::Cob { typed_id } if typed_id.is_identity() => {
555            let identity = Identity::get(&typed_id.id, &repo)?;
556
557            term::json::to_pretty(&identity.doc, Path::new("radicle.json"))?.print();
558        }
559        NotificationKind::Branch { .. } => {
560            let refstr = if let Some(remote) = n.remote {
561                n.qualified
562                    .with_namespace(remote.to_component())
563                    .to_string()
564            } else {
565                n.qualified.to_string()
566            };
567            process::Command::new("git")
568                .current_dir(repo.path())
569                .args(["log", refstr.as_str()])
570                .spawn()?
571                .wait()?;
572        }
573        notification => {
574            term::json::to_pretty(&notification, Path::new("notification.json"))?.print();
575        }
576    }
577    notifs.set_status(NotificationStatus::ReadAt(LocalTime::now()), &[id])?;
578
579    Ok(())
580}