radicle_cli/commands/
inspect.rs

1#![allow(clippy::or_fun_call)]
2use std::collections::HashMap;
3use std::ffi::OsString;
4use std::path::Path;
5use std::str::FromStr;
6
7use anyhow::Context as _;
8use chrono::prelude::*;
9
10use radicle::identity::RepoId;
11use radicle::identity::{DocAt, Identity};
12use radicle::node::policy::SeedingPolicy;
13use radicle::node::AliasStore as _;
14use radicle::storage::git::{Repository, Storage};
15use radicle::storage::refs::RefsAt;
16use radicle::storage::{ReadRepository, ReadStorage};
17
18use crate::terminal as term;
19use crate::terminal::args::{Args, Error, Help};
20use crate::terminal::json;
21use crate::terminal::Element;
22
23pub const HELP: Help = Help {
24    name: "inspect",
25    description: "Inspect a Radicle repository",
26    version: env!("RADICLE_VERSION"),
27    usage: r#"
28Usage
29
30    rad inspect <path> [<option>...]
31    rad inspect <rid>  [<option>...]
32    rad inspect [<option>...]
33
34    Inspects the given path or RID. If neither is specified,
35    the current repository is inspected.
36
37Options
38
39    --rid        Return the repository identifier (RID)
40    --payload    Inspect the repository's identity payload
41    --refs       Inspect the repository's refs on the local device
42    --sigrefs    Inspect the values of `rad/sigrefs` for all remotes of this repository
43    --identity   Inspect the identity document
44    --visibility Inspect the repository's visibility
45    --delegates  Inspect the repository's delegates
46    --policy     Inspect the repository's seeding policy
47    --history    Show the history of the repository identity document
48    --help       Print help
49"#,
50};
51
52#[derive(Default, Debug, Eq, PartialEq)]
53pub enum Target {
54    Refs,
55    Payload,
56    Delegates,
57    Identity,
58    Visibility,
59    Sigrefs,
60    Policy,
61    History,
62    #[default]
63    RepoId,
64}
65
66#[derive(Default, Debug, Eq, PartialEq)]
67pub struct Options {
68    pub rid: Option<RepoId>,
69    pub target: Target,
70}
71
72impl Args for Options {
73    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
74        use lexopt::prelude::*;
75
76        let mut parser = lexopt::Parser::from_args(args);
77        let mut rid: Option<RepoId> = None;
78        let mut target = Target::default();
79
80        while let Some(arg) = parser.next()? {
81            match arg {
82                Long("help") | Short('h') => {
83                    return Err(Error::Help.into());
84                }
85                Long("refs") => {
86                    target = Target::Refs;
87                }
88                Long("payload") => {
89                    target = Target::Payload;
90                }
91                Long("policy") => {
92                    target = Target::Policy;
93                }
94                Long("delegates") => {
95                    target = Target::Delegates;
96                }
97                Long("history") => {
98                    target = Target::History;
99                }
100                Long("identity") => {
101                    target = Target::Identity;
102                }
103                Long("sigrefs") => {
104                    target = Target::Sigrefs;
105                }
106                Long("rid") => {
107                    target = Target::RepoId;
108                }
109                Long("visibility") => {
110                    target = Target::Visibility;
111                }
112                Value(val) if rid.is_none() => {
113                    let val = val.to_string_lossy();
114
115                    if let Ok(val) = RepoId::from_str(&val) {
116                        rid = Some(val);
117                    } else {
118                        rid = radicle::rad::at(Path::new(val.as_ref()))
119                            .map(|(_, id)| Some(id))
120                            .context("Supplied argument is not a valid path")?;
121                    }
122                }
123                _ => return Err(anyhow::anyhow!(arg.unexpected())),
124            }
125        }
126
127        Ok((Options { rid, target }, vec![]))
128    }
129}
130
131pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
132    let rid = match options.rid {
133        Some(rid) => rid,
134        None => radicle::rad::cwd()
135            .map(|(_, rid)| rid)
136            .context("Current directory is not a Radicle repository")?,
137    };
138
139    if options.target == Target::RepoId {
140        term::info!("{}", term::format::highlight(rid.urn()));
141        return Ok(());
142    }
143    let profile = ctx.profile()?;
144    let storage = &profile.storage;
145
146    match options.target {
147        Target::Refs => {
148            let (repo, _) = repo(rid, storage)?;
149            refs(&repo)?;
150        }
151        Target::Payload => {
152            let (_, doc) = repo(rid, storage)?;
153            json::to_pretty(&doc.payload(), Path::new("radicle.json"))?.print();
154        }
155        Target::Identity => {
156            let (_, doc) = repo(rid, storage)?;
157            json::to_pretty(&*doc, Path::new("radicle.json"))?.print();
158        }
159        Target::Sigrefs => {
160            let (repo, _) = repo(rid, storage)?;
161            for remote in repo.remote_ids()? {
162                let remote = remote?;
163                let refs = RefsAt::new(&repo, remote)?;
164
165                println!(
166                    "{:<48} {}",
167                    term::format::tertiary(remote.to_human()),
168                    term::format::secondary(refs.at)
169                );
170            }
171        }
172        Target::Policy => {
173            let policies = profile.policies()?;
174            let seed = policies.seed_policy(&rid)?;
175            match seed.policy {
176                SeedingPolicy::Allow { scope } => {
177                    println!(
178                        "Repository {} is {} with scope {}",
179                        term::format::tertiary(&rid),
180                        term::format::positive("being seeded"),
181                        term::format::dim(format!("`{scope}`"))
182                    );
183                }
184                SeedingPolicy::Block => {
185                    println!(
186                        "Repository {} is {}",
187                        term::format::tertiary(&rid),
188                        term::format::negative("not being seeded"),
189                    );
190                }
191            }
192        }
193        Target::Delegates => {
194            let (_, doc) = repo(rid, storage)?;
195            let aliases = profile.aliases();
196            for did in doc.delegates().iter() {
197                if let Some(alias) = aliases.alias(did) {
198                    println!(
199                        "{} {}",
200                        term::format::tertiary(&did),
201                        term::format::parens(term::format::dim(alias))
202                    );
203                } else {
204                    println!("{}", term::format::tertiary(&did));
205                }
206            }
207        }
208        Target::Visibility => {
209            let (_, doc) = repo(rid, storage)?;
210            println!("{}", term::format::visibility(doc.visibility()));
211        }
212        Target::History => {
213            let (repo, _) = repo(rid, storage)?;
214            let identity = Identity::load(&repo)?;
215            let head = repo.identity_head()?;
216            let history = repo.revwalk(head)?;
217
218            for oid in history {
219                let oid = oid?.into();
220                let tip = repo.commit(oid)?;
221
222                let Some(revision) = identity.revision(&tip.id().into()) else {
223                    continue;
224                };
225                if !revision.is_accepted() {
226                    continue;
227                }
228                let doc = &revision.doc;
229                let timezone = if tip.time().sign() == '+' {
230                    #[allow(deprecated)]
231                    FixedOffset::east(tip.time().offset_minutes() * 60)
232                } else {
233                    #[allow(deprecated)]
234                    FixedOffset::west(tip.time().offset_minutes() * 60)
235                };
236                let time = DateTime::<Utc>::from(
237                    std::time::UNIX_EPOCH
238                        + std::time::Duration::from_secs(tip.time().seconds() as u64),
239                )
240                .with_timezone(&timezone)
241                .to_rfc2822();
242
243                println!(
244                    "{} {}",
245                    term::format::yellow("commit"),
246                    term::format::yellow(oid),
247                );
248                if let Ok(parent) = tip.parent_id(0) {
249                    println!("parent {parent}");
250                }
251                println!("blob   {}", revision.blob);
252                println!("date   {time}");
253                println!();
254
255                if let Some(msg) = tip.message() {
256                    for line in msg.lines() {
257                        if line.is_empty() {
258                            println!();
259                        } else {
260                            term::indented(term::format::dim(line));
261                        }
262                    }
263                    term::blank();
264                }
265                for line in json::to_pretty(&doc, Path::new("radicle.json"))? {
266                    println!(" {line}");
267                }
268
269                println!();
270            }
271        }
272        Target::RepoId => {
273            // Handled above.
274        }
275    }
276
277    Ok(())
278}
279
280fn repo(rid: RepoId, storage: &Storage) -> anyhow::Result<(Repository, DocAt)> {
281    let repo = storage
282        .repository(rid)
283        .context("No repository with the given RID exists")?;
284    let doc = repo.identity_doc()?;
285
286    Ok((repo, doc))
287}
288
289fn refs(repo: &radicle::storage::git::Repository) -> anyhow::Result<()> {
290    let mut refs = Vec::new();
291    for r in repo.references()? {
292        let r = r?;
293        if let Some(namespace) = r.namespace {
294            refs.push(format!("{}/{}", namespace, r.name));
295        }
296    }
297
298    print!("{}", tree(refs));
299
300    Ok(())
301}
302
303/// Show the list of given git references as a newline terminated tree `String` similar to the tree command.
304fn tree(mut refs: Vec<String>) -> String {
305    refs.sort();
306
307    // List of references with additional unique entries for each 'directory'.
308    //
309    // i.e. "refs/heads/master" becomes ["refs"], ["refs", "heads"], and ["refs", "heads",
310    // "master"].
311    let mut refs_expanded: Vec<Vec<String>> = Vec::new();
312    // Number of entries per Git 'directory'.
313    let mut ref_entries: HashMap<Vec<String>, usize> = HashMap::new();
314    let mut last: Vec<String> = Vec::new();
315
316    for r in refs {
317        let r: Vec<String> = r.split('/').map(|s| s.to_string()).collect();
318
319        for (i, v) in r.iter().enumerate() {
320            let last_v = last.get(i);
321            if Some(v) != last_v {
322                last = r.clone().iter().take(i + 1).map(String::from).collect();
323
324                refs_expanded.push(last.clone());
325
326                let mut dir = last.clone();
327                dir.pop();
328                if dir.is_empty() {
329                    continue;
330                }
331
332                if let Some(num) = ref_entries.get_mut(&dir) {
333                    *num += 1;
334                } else {
335                    ref_entries.insert(dir, 1);
336                }
337            }
338        }
339    }
340    let mut tree = String::default();
341
342    for mut ref_components in refs_expanded {
343        // Better to explode when things do not go as expected.
344        let name = ref_components.pop().expect("non-empty vector");
345        if ref_components.is_empty() {
346            tree.push_str(&format!("{name}\n"));
347            continue;
348        }
349
350        for i in 1..ref_components.len() {
351            let parent: Vec<String> = ref_components.iter().take(i).cloned().collect();
352
353            let num = ref_entries.get(&parent).unwrap_or(&0);
354            if *num == 0 {
355                tree.push_str("    ");
356            } else {
357                tree.push_str("│   ");
358            }
359        }
360
361        if let Some(num) = ref_entries.get_mut(&ref_components) {
362            if *num == 1 {
363                tree.push_str(&format!("└── {name}\n"));
364            } else {
365                tree.push_str(&format!("├── {name}\n"));
366            }
367            *num -= 1;
368        }
369    }
370
371    tree
372}
373
374#[cfg(test)]
375mod test {
376    use super::*;
377
378    #[test]
379    fn test_tree() {
380        let arg = vec![
381            String::from("z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/refs/heads/master"),
382            String::from("z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/refs/rad/id"),
383            String::from("z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/refs/rad/sigrefs"),
384        ];
385        let exp = r#"
386z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
387└── refs
388    ├── heads
389    │   └── master
390    └── rad
391        ├── id
392        └── sigrefs
393"#
394        .trim_start();
395
396        assert_eq!(tree(arg), exp);
397        assert_eq!(tree(vec![String::new()]), "\n");
398    }
399}