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