Author: Kevin Schoon [me@kevinschoon.com]
Hash: f411141775b5000c04d94636ab54bb6719d59fc8
Timestamp: Sun, 07 Apr 2024 17:34:43 +0000 (1 month ago)

+470 -27 +/-16 browse
init quipu
init quipu

Implement a rough draft of Quipu - the CLI and RPC client for Ayllu. This adds
a simple REST server into the existing Ayllu HTTP server as well as a new
binary called quipu for interacting with the new endpoints. Eventually I would
like to implement a custom transport built on Tarpc expose both the REST API
and the internal RPC communication if desired.
1diff --git a/Cargo.lock b/Cargo.lock
2index 814f8c7..660e41a 100644
3--- a/Cargo.lock
4+++ b/Cargo.lock
5 @@ -698,6 +698,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
6 checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
7
8 [[package]]
9+ name = "base64"
10+ version = "0.22.0"
11+ source = "registry+https://github.com/rust-lang/crates.io-index"
12+ checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51"
13+
14+ [[package]]
15 name = "base64ct"
16 version = "1.6.0"
17 source = "registry+https://github.com/rust-lang/crates.io-index"
18 @@ -2350,6 +2356,7 @@ dependencies = [
19 "pin-project-lite",
20 "smallvec",
21 "tokio",
22+ "want",
23 ]
24
25 [[package]]
26 @@ -2366,12 +2373,29 @@ dependencies = [
27 ]
28
29 [[package]]
30+ name = "hyper-tls"
31+ version = "0.6.0"
32+ source = "registry+https://github.com/rust-lang/crates.io-index"
33+ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
34+ dependencies = [
35+ "bytes",
36+ "http-body-util",
37+ "hyper 1.2.0",
38+ "hyper-util",
39+ "native-tls",
40+ "tokio",
41+ "tokio-native-tls",
42+ "tower-service",
43+ ]
44+
45+ [[package]]
46 name = "hyper-util"
47 version = "0.1.3"
48 source = "registry+https://github.com/rust-lang/crates.io-index"
49 checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa"
50 dependencies = [
51 "bytes",
52+ "futures-channel",
53 "futures-util",
54 "http 1.1.0",
55 "http-body 1.0.0",
56 @@ -2379,6 +2403,9 @@ dependencies = [
57 "pin-project-lite",
58 "socket2 0.5.6",
59 "tokio",
60+ "tower",
61+ "tower-service",
62+ "tracing",
63 ]
64
65 [[package]]
66 @@ -2582,7 +2609,7 @@ dependencies = [
67 "socket2 0.5.6",
68 "widestring",
69 "windows-sys 0.48.0",
70- "winreg",
71+ "winreg 0.50.0",
72 ]
73
74 [[package]]
75 @@ -4108,6 +4135,25 @@ dependencies = [
76 ]
77
78 [[package]]
79+ name = "quipu"
80+ version = "0.2.1"
81+ dependencies = [
82+ "ayllu_api",
83+ "ayllu_config",
84+ "ayllu_git",
85+ "ayllu_rpc",
86+ "clap 4.5.3",
87+ "clap_complete",
88+ "reqwest 0.12.3",
89+ "serde",
90+ "thiserror",
91+ "tokio",
92+ "tracing",
93+ "tracing-subscriber",
94+ "url",
95+ ]
96+
97+ [[package]]
98 name = "quote"
99 version = "1.0.35"
100 source = "registry+https://github.com/rust-lang/crates.io-index"
101 @@ -4254,7 +4300,7 @@ dependencies = [
102 "http 0.2.12",
103 "http-body 0.4.6",
104 "hyper 0.14.28",
105- "hyper-tls",
106+ "hyper-tls 0.5.0",
107 "ipnet",
108 "js-sys",
109 "log",
110 @@ -4263,7 +4309,7 @@ dependencies = [
111 "once_cell",
112 "percent-encoding",
113 "pin-project-lite",
114- "rustls-pemfile",
115+ "rustls-pemfile 1.0.4",
116 "serde",
117 "serde_json",
118 "serde_urlencoded",
119 @@ -4278,7 +4324,49 @@ dependencies = [
120 "wasm-bindgen-futures",
121 "wasm-streams",
122 "web-sys",
123- "winreg",
124+ "winreg 0.50.0",
125+ ]
126+
127+ [[package]]
128+ name = "reqwest"
129+ version = "0.12.3"
130+ source = "registry+https://github.com/rust-lang/crates.io-index"
131+ checksum = "3e6cc1e89e689536eb5aeede61520e874df5a4707df811cd5da4aa5fbb2aae19"
132+ dependencies = [
133+ "base64 0.22.0",
134+ "bytes",
135+ "encoding_rs",
136+ "futures-core",
137+ "futures-util",
138+ "h2 0.4.3",
139+ "http 1.1.0",
140+ "http-body 1.0.0",
141+ "http-body-util",
142+ "hyper 1.2.0",
143+ "hyper-tls 0.6.0",
144+ "hyper-util",
145+ "ipnet",
146+ "js-sys",
147+ "log",
148+ "mime",
149+ "native-tls",
150+ "once_cell",
151+ "percent-encoding",
152+ "pin-project-lite",
153+ "rustls-pemfile 2.1.2",
154+ "serde",
155+ "serde_json",
156+ "serde_urlencoded",
157+ "sync_wrapper",
158+ "system-configuration",
159+ "tokio",
160+ "tokio-native-tls",
161+ "tower-service",
162+ "url",
163+ "wasm-bindgen",
164+ "wasm-bindgen-futures",
165+ "web-sys",
166+ "winreg 0.52.0",
167 ]
168
169 [[package]]
170 @@ -4417,6 +4505,22 @@ dependencies = [
171 ]
172
173 [[package]]
174+ name = "rustls-pemfile"
175+ version = "2.1.2"
176+ source = "registry+https://github.com/rust-lang/crates.io-index"
177+ checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d"
178+ dependencies = [
179+ "base64 0.22.0",
180+ "rustls-pki-types",
181+ ]
182+
183+ [[package]]
184+ name = "rustls-pki-types"
185+ version = "1.4.1"
186+ source = "registry+https://github.com/rust-lang/crates.io-index"
187+ checksum = "ecd36cc4259e3e4514335c4a138c6b43171a8d61d8f5c9348f9fc7529416f247"
188+
189+ [[package]]
190 name = "rustls-webpki"
191 version = "0.101.7"
192 source = "registry+https://github.com/rust-lang/crates.io-index"
193 @@ -4895,7 +4999,7 @@ dependencies = [
194 "paste",
195 "percent-encoding",
196 "rustls",
197- "rustls-pemfile",
198+ "rustls-pemfile 1.0.4",
199 "serde",
200 "serde_json",
201 "sha2",
202 @@ -5447,9 +5551,9 @@ dependencies = [
203
204 [[package]]
205 name = "tokio"
206- version = "1.36.0"
207+ version = "1.37.0"
208 source = "registry+https://github.com/rust-lang/crates.io-index"
209- checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
210+ checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787"
211 dependencies = [
212 "backtrace",
213 "bytes",
214 @@ -5937,6 +6041,7 @@ dependencies = [
215 "form_urlencoded",
216 "idna 0.5.0",
217 "percent-encoding",
218+ "serde",
219 ]
220
221 [[package]]
222 @@ -6144,7 +6249,7 @@ version = "0.5.1"
223 source = "registry+https://github.com/rust-lang/crates.io-index"
224 checksum = "f395336b42f1be22490390830ccfe7a735725eef086cd0574bf8759408ecb3af"
225 dependencies = [
226- "reqwest",
227+ "reqwest 0.11.26",
228 "serde",
229 ]
230
231 @@ -6380,6 +6485,16 @@ dependencies = [
232 ]
233
234 [[package]]
235+ name = "winreg"
236+ version = "0.52.0"
237+ source = "registry+https://github.com/rust-lang/crates.io-index"
238+ checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
239+ dependencies = [
240+ "cfg-if 1.0.0",
241+ "windows-sys 0.48.0",
242+ ]
243+
244+ [[package]]
245 name = "wio"
246 version = "0.2.2"
247 source = "registry+https://github.com/rust-lang/crates.io-index"
248 @@ -6411,7 +6526,7 @@ source = "git+https://ayllu-forge.org/forks/xmpp-rs?branch=ayllu#3df636d178234f7
249 dependencies = [
250 "futures",
251 "log",
252- "reqwest",
253+ "reqwest 0.11.26",
254 "tokio",
255 "tokio-util",
256 "tokio-xmpp",
257 diff --git a/Cargo.toml b/Cargo.toml
258index 0ddcdf2..1dc3f8f 100644
259--- a/Cargo.toml
260+++ b/Cargo.toml
261 @@ -1,4 +1,5 @@
262 [workspace]
263+ resolver = "2"
264 members = [
265 "crates/api",
266 "crates/config",
267 @@ -9,18 +10,6 @@ members = [
268 "ayllu",
269 "ayllu-build",
270 "ayllu-mail",
271- "ayllu-xmpp"
272+ "ayllu-xmpp",
273+ "quipu"
274 ]
275-
276- resolver = "2"
277-
278- # [workspace.dependencies]
279- # ayllu_api = { version = "0.2.1", path = "./crates/api"}
280- # ayllu_config = { version = "0.2.1", path = "./crates/config"}
281- # ayllu_database = { version = "0.2.1", path = "./crates/database"}
282- # ayllu_git = { version = "0.2.1", path = "./crates/git"}
283- # ayllu_rpc = { version = "0.2.1", path = "./crates/rpc"}
284- # ayllu_scheduler = {version = "0.2.1", path = "./crates/scheduler"}
285- # ayllu-xmpp = { version = "0.2.1", path = "ayllu-xmpp"}
286- # ayllu-build = {version = "0.2.1", path = "ayllu-build"}
287- # ayllu-mail = { version = "0.2.1", path = "ayllu-mail"}
288 diff --git a/ayllu-spaces/README.md b/ayllu-spaces/README.md
289deleted file mode 100644
290index a158610..0000000
291--- a/ayllu-spaces/README.md
292+++ /dev/null
293 @@ -1,4 +0,0 @@
294- # ayllu-spaces
295-
296- Placeholder for "Code Spaces" like functionality in Ayllu designed around
297- LXC.
298 diff --git a/ayllu/src/web2/routes/mod.rs b/ayllu/src/web2/routes/mod.rs
299index 98e70a1..de35b73 100644
300--- a/ayllu/src/web2/routes/mod.rs
301+++ b/ayllu/src/web2/routes/mod.rs
302 @@ -12,6 +12,7 @@ pub mod index;
303 pub mod log;
304 pub mod mail;
305 pub mod refs;
306+ pub mod rest;
307 pub mod repo;
308 pub mod robots;
309 pub mod rss;
310 diff --git a/ayllu/src/web2/routes/rest/discovery.rs b/ayllu/src/web2/routes/rest/discovery.rs
311new file mode 100644
312index 0000000..2bf6dd4
313--- /dev/null
314+++ b/ayllu/src/web2/routes/rest/discovery.rs
315 @@ -0,0 +1,31 @@
316+ use axum::{extract::Extension, response::Json};
317+
318+ use crate::config::Config;
319+ use crate::web2::error::Error;
320+
321+ use ayllu_api::discovery::{Collection, Repository};
322+ use ayllu_git::{Scanner, Wrapper};
323+
324+ pub async fn serve(Extension(cfg): Extension<Config>) -> Result<Json<Vec<Collection>>, Error> {
325+ let mut collections: Vec<Collection> = Vec::new();
326+ for config in cfg.collections {
327+ if config.hidden.is_some_and(|hidden| hidden) {
328+ continue;
329+ }
330+ let mut repositories: Vec<Repository> = Vec::new();
331+ for repo in Scanner::from_path(&config.path)? {
332+ let repository = Wrapper::new(repo.as_path())?;
333+ let repo_config = repository.config()?;
334+ repositories.push(Repository {
335+ name: repository.name(),
336+ description: repo_config.description.clone(),
337+ });
338+ }
339+ collections.push(Collection {
340+ name: config.name.clone(),
341+ description: config.description.clone(),
342+ repositories,
343+ });
344+ }
345+ Ok(Json(collections))
346+ }
347 diff --git a/ayllu/src/web2/routes/rest/mod.rs b/ayllu/src/web2/routes/rest/mod.rs
348new file mode 100644
349index 0000000..fc4b5cb
350--- /dev/null
351+++ b/ayllu/src/web2/routes/rest/mod.rs
352 @@ -0,0 +1 @@
353+ pub mod discovery;
354 diff --git a/ayllu/src/web2/server.rs b/ayllu/src/web2/server.rs
355index 1738afe..51f4c10 100644
356--- a/ayllu/src/web2/server.rs
357+++ b/ayllu/src/web2/server.rs
358 @@ -41,6 +41,7 @@ use crate::web2::routes::log as log_route;
359 use crate::web2::routes::mail;
360 use crate::web2::routes::refs;
361 use crate::web2::routes::repo;
362+ use crate::web2::routes::rest::discovery;
363 use crate::web2::routes::robots;
364 use crate::web2::routes::rss;
365 use crate::web2::routes::xmpp;
366 @@ -176,6 +177,10 @@ pub async fn serve(cfg: &Config) -> Result<(), Box<dyn Error>> {
367 )),
368 )
369 .nest(
370+ "/0",
371+ Router::new().route("/index", routing::get(discovery::serve)),
372+ )
373+ .nest(
374 "/mail",
375 Router::new()
376 .route("/", routing::get(mail::lists))
377 diff --git a/crates/api/src/discovery.rs b/crates/api/src/discovery.rs
378new file mode 100644
379index 0000000..1efd80b
380--- /dev/null
381+++ b/crates/api/src/discovery.rs
382 @@ -0,0 +1,16 @@
383+ use serde::{Deserialize, Serialize};
384+
385+ #[derive(Debug, Deserialize, Serialize)]
386+ /// A Git based repository
387+ pub struct Repository {
388+ pub name: String,
389+ pub description: Option<String>,
390+ }
391+
392+ #[derive(Debug, Deserialize, Serialize)]
393+ /// A collection of Git based repositories
394+ pub struct Collection {
395+ pub name: String,
396+ pub description: Option<String>,
397+ pub repositories: Vec<Repository>,
398+ }
399 diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs
400index 1daadf3..cc02f23 100644
401--- a/crates/api/src/lib.rs
402+++ b/crates/api/src/lib.rs
403 @@ -1,4 +1,5 @@
404 pub mod build;
405+ pub mod discovery;
406 pub mod error;
407 pub mod jobs;
408 pub mod mail;
409 diff --git a/quipu/Cargo.toml b/quipu/Cargo.toml
410new file mode 100644
411index 0000000..5966609
412--- /dev/null
413+++ b/quipu/Cargo.toml
414 @@ -0,0 +1,23 @@
415+ [package]
416+ name = "quipu"
417+ version = "0.2.1"
418+ edition = "2021"
419+
420+ [[bin]]
421+ name = "quipu"
422+
423+ [dependencies]
424+ ayllu_api = { path = "../crates/api" }
425+ ayllu_config = { path = "../crates/config" }
426+ ayllu_rpc = { path = "../crates/rpc" }
427+ ayllu_git = { path = "../crates/git" }
428+
429+ clap = { version = "4.3.0", features = ["derive"] }
430+ tokio = { version = "1.37.0", features = ["full"] }
431+ reqwest = { version = "0.12.3", features = ["json"] }
432+ tracing = "0.1.40"
433+ tracing-subscriber = "0.3.18"
434+ clap_complete = "4.5.1"
435+ thiserror = "1.0.58"
436+ serde = { version = "1.0.197", features = ["derive"] }
437+ url = { version = "2.5.0", features = ["serde"] }
438 diff --git a/quipu/README.md b/quipu/README.md
439new file mode 100644
440index 0000000..dca820a
441--- /dev/null
442+++ b/quipu/README.md
443 @@ -0,0 +1,10 @@
444+ # quipu
445+
446+ Quipu is a CLI tool for interacting with remote instances of Ayllu.
447+
448+ ## Name
449+
450+ The name [Quipu](https://en.wikipedia.org/wiki/Quipu) _/kē′poo͞/_ is the Quechua
451+ word for an ancient recording device used by several cultures from the Andean
452+ region of South America.
453+
454 diff --git a/quipu/src/client_rest.rs b/quipu/src/client_rest.rs
455new file mode 100644
456index 0000000..e0d4f1d
457--- /dev/null
458+++ b/quipu/src/client_rest.rs
459 @@ -0,0 +1,32 @@
460+ use reqwest::{Client, ClientBuilder};
461+ use url::Url;
462+
463+ use ayllu_api::discovery::Collection;
464+
465+ use crate::error::QuipuError;
466+
467+ const QUIPU_USER_AGENT: &str = "Quipu 0.0.0";
468+
469+ pub struct Quipu {
470+ endpoint: Url,
471+ client: Client,
472+ }
473+
474+ impl Quipu {
475+ pub fn new(endpoint: Url) -> Self {
476+ let client = ClientBuilder::new()
477+ .user_agent(QUIPU_USER_AGENT)
478+ .build()
479+ .unwrap();
480+ Self { endpoint, client }
481+ }
482+
483+ pub async fn get_index(&self) -> Result<Vec<Collection>, QuipuError> {
484+ let response = self
485+ .client
486+ .get(self.endpoint.join("/0/index")?)
487+ .send()
488+ .await?;
489+ Ok(response.json().await?)
490+ }
491+ }
492 diff --git a/quipu/src/config.rs b/quipu/src/config.rs
493new file mode 100644
494index 0000000..32bd9f5
495--- /dev/null
496+++ b/quipu/src/config.rs
497 @@ -0,0 +1,47 @@
498+ use serde::{Deserialize, Serialize};
499+ use url::Url;
500+
501+ use ayllu_config::Configurable;
502+
503+ #[derive(Clone, Debug, Deserialize, Serialize)]
504+ pub struct Instance {
505+ pub url: Url,
506+ pub name: String,
507+ }
508+
509+ #[derive(Clone, Debug, Deserialize, Serialize)]
510+ pub struct Quipu {
511+ pub instances: Vec<Instance>,
512+ }
513+
514+ #[derive(Clone, Debug, Serialize, Deserialize)]
515+ pub struct Config {
516+ pub log_level: String,
517+ pub quipu: Option<Quipu>,
518+ }
519+
520+ impl Config {
521+ // return the first configured instance if it exists
522+ pub fn default_instance(&self) -> Option<Instance> {
523+ if let Some(quipu_conf) = self.quipu.as_ref() {
524+ quipu_conf.instances.first().cloned()
525+ } else {
526+ None
527+ }
528+ }
529+
530+ /// find an instance by name
531+ pub fn instance_by_name(&self, name: &str) -> Option<Instance> {
532+ if let Some(quipu_conf) = self.quipu.as_ref() {
533+ quipu_conf
534+ .instances
535+ .iter()
536+ .find(|instance| instance.name == name)
537+ .cloned()
538+ } else {
539+ None
540+ }
541+ }
542+ }
543+
544+ impl Configurable for Config {}
545 diff --git a/quipu/src/error.rs b/quipu/src/error.rs
546new file mode 100644
547index 0000000..6d1ef8a
548--- /dev/null
549+++ b/quipu/src/error.rs
550 @@ -0,0 +1,22 @@
551+ use reqwest::Error as ReqwestError;
552+ use thiserror::Error;
553+ use tracing::metadata::ParseLevelError;
554+ use url::ParseError as ParseUrlError;
555+
556+ use ayllu_config::Error as ConfigError;
557+
558+ #[derive(Error, Debug)]
559+ pub enum QuipuError {
560+ #[error("IO Error")]
561+ Disconnect(#[from] std::io::Error),
562+ #[error("Configuration Error")]
563+ Config(#[from] ConfigError),
564+ #[error("Invalid Log Level")]
565+ LogLevel(#[from] ParseLevelError),
566+ #[error("Invalid Url")]
567+ URLParsing(#[from] ParseUrlError),
568+ #[error("Request Error")]
569+ Request(#[from] ReqwestError),
570+ #[error("Internal Quipu Error")]
571+ Message(String)
572+ }
573 diff --git a/quipu/src/main.rs b/quipu/src/main.rs
574new file mode 100644
575index 0000000..f1defe2
576--- /dev/null
577+++ b/quipu/src/main.rs
578 @@ -0,0 +1,123 @@
579+ use std::io::stdout;
580+ use std::path::PathBuf;
581+ use std::str::FromStr;
582+
583+ use clap::{arg, Command, CommandFactory, Parser, Subcommand, ValueEnum};
584+ use clap_complete::{generate, Generator, Shell};
585+ use tracing::Level;
586+ use url::Url;
587+
588+ use ayllu_config::Reader;
589+
590+ mod client_rest;
591+ mod config;
592+ mod error;
593+ mod output;
594+
595+ #[derive(Parser)]
596+ #[command(author, version, about, long_about = None)]
597+ #[command(name = "quipu")]
598+ #[command(about = "Ayllu RPC Client")]
599+ struct Cli {
600+ /// Path to your configuration file
601+ #[arg(short, long, value_name = "FILE")]
602+ config: Option<PathBuf>,
603+
604+ /// Sets the logging level
605+ #[arg(short, long, value_name = "LEVEL")]
606+ level: Option<Level>,
607+
608+ #[command(subcommand)]
609+ command: Commands,
610+ }
611+
612+ #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
613+ /// a resource that exists on the remote server
614+ enum Resource {
615+ /// an amalgamation of collections and repositories
616+ Index,
617+ }
618+
619+ #[derive(Subcommand, Debug, PartialEq)]
620+ enum Commands {
621+ /// generate autocomplete commands for common shells
622+ Complete {
623+ #[arg(long)]
624+ shell: Shell,
625+ },
626+ /// get resources from a remote Ayllu server
627+ Get {
628+ #[arg(short, long)]
629+ /// instance name from your quipu configuration
630+ instance: Option<String>,
631+ #[arg(short, long)]
632+ /// alternative url to contact
633+ url: Option<Url>,
634+ /// resource to request from remote server
635+ resource: Resource,
636+ },
637+ }
638+
639+ fn print_completions<G: Generator>(gen: G, cmd: &mut Command) {
640+ generate(gen, cmd, cmd.get_name().to_string(), &mut stdout());
641+ }
642+
643+ fn get_instance(
644+ cfg: &config::Config,
645+ url: Option<Url>,
646+ name: Option<String>,
647+ ) -> Result<config::Instance, error::QuipuError> {
648+ if let Some(url) = url {
649+ Ok(config::Instance {
650+ url,
651+ name: String::new(),
652+ })
653+ } else if let Some(name) = name {
654+ if let Some(instance) = cfg.instance_by_name(&name) {
655+ Ok(instance)
656+ } else {
657+ Err(error::QuipuError::Message(format!(
658+ "no configured instance: {}",
659+ name
660+ )))
661+ }
662+ } else if let Some(instance) = cfg.default_instance() {
663+ Ok(instance)
664+ } else {
665+ Err(error::QuipuError::Message(String::from(
666+ "no configured ayllu instances",
667+ )))
668+ }
669+ }
670+
671+ #[tokio::main(flavor = "current_thread")]
672+ async fn main() -> Result<(), error::QuipuError> {
673+ let cli = Cli::parse();
674+ let cfg: config::Config = Reader::load(cli.config.as_deref())?;
675+ let log_level = Level::from_str(&cfg.log_level)?;
676+ tracing_subscriber::fmt()
677+ .compact()
678+ .with_line_number(true)
679+ .with_level(true)
680+ .with_max_level(cli.level.unwrap_or(log_level))
681+ .init();
682+ tracing::info!("logger initialized");
683+ match cli.command {
684+ Commands::Complete { shell } => {
685+ let mut cmd = Cli::command();
686+ print_completions(shell, &mut cmd);
687+ Ok(())
688+ }
689+ Commands::Get {
690+ instance,
691+ url,
692+ resource: _,
693+ } => {
694+ let instance = get_instance(&cfg, url, instance)?;
695+ let client = client_rest::Quipu::new(instance.url);
696+ let collections = client.get_index().await?;
697+ output::pretty(output::Resource::Collections(collections))?;
698+ Ok(())
699+ }
700+ }
701+ }
702 diff --git a/quipu/src/output.rs b/quipu/src/output.rs
703new file mode 100644
704index 0000000..627058a
705--- /dev/null
706+++ b/quipu/src/output.rs
707 @@ -0,0 +1,31 @@
708+ use ayllu_api::discovery::Collection;
709+
710+ use crate::error::QuipuError;
711+
712+ pub enum Resource {
713+ Collections(Vec<Collection>),
714+ }
715+
716+ /// pretty print the resource to the terminal
717+ pub fn pretty(resource: Resource) -> Result<(), QuipuError> {
718+ match resource {
719+ Resource::Collections(collections) => {
720+ for collection in collections {
721+ println!(
722+ "{} - {} [{} repositories]",
723+ collection.name,
724+ collection.description.unwrap_or("None".to_string()),
725+ collection.repositories.len()
726+ );
727+ for repository in collection.repositories {
728+ println!(
729+ "\t{} - {}",
730+ repository.name,
731+ repository.description.unwrap_or("None".to_string())
732+ );
733+ }
734+ }
735+ }
736+ };
737+ Ok(())
738+ }