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 _ => anyhow::bail!(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 }
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
303fn tree(mut refs: Vec<String>) -> String {
305 refs.sort();
306
307 let mut refs_expanded: Vec<Vec<String>> = Vec::new();
312 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 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}