1use serde::Serialize;
2
3use crate::config::{CapabilityMode, CommandConfig, Config};
4use crate::error::ViaError;
5
6#[derive(Serialize)]
7struct Capabilities<'a> {
8 services: Vec<ServiceCapabilities<'a>>,
9}
10
11#[derive(Serialize)]
12struct ServiceCapabilities<'a> {
13 name: &'a str,
14 description: Option<&'a str>,
15 #[serde(skip_serializing_if = "Option::is_none")]
16 hint: Option<&'a str>,
17 capabilities: Vec<CapabilitySummary<'a>>,
18}
19
20#[derive(Serialize)]
21struct CapabilitySummary<'a> {
22 name: &'a str,
23 description: Option<&'a str>,
24 mode: CapabilityMode,
25 #[serde(skip_serializing_if = "Vec::is_empty")]
26 asset_hosts: Vec<&'a str>,
27}
28
29pub fn print(config: &Config, json: bool) -> Result<(), ViaError> {
30 print!("{}", render(config, json)?);
31 Ok(())
32}
33
34pub fn render(config: &Config, json: bool) -> Result<String, ViaError> {
35 if json {
36 let capabilities = Capabilities {
37 services: config
38 .services
39 .iter()
40 .map(|(name, service)| ServiceCapabilities {
41 name,
42 description: service.description.as_deref(),
43 hint: service.hint.as_deref(),
44 capabilities: service
45 .commands
46 .iter()
47 .map(|(command_name, command)| CapabilitySummary {
48 name: command_name,
49 description: command.description().map(String::as_str),
50 mode: command.mode(),
51 asset_hosts: asset_hosts(command),
52 })
53 .collect(),
54 })
55 .collect(),
56 };
57 return Ok(format!(
58 "{}\n",
59 serde_json::to_string_pretty(&capabilities)?
60 ));
61 }
62
63 let mut output = String::new();
64 for (service_name, service) in &config.services {
65 match &service.description {
66 Some(description) => output.push_str(&format!("{service_name}: {description}\n")),
67 None => output.push_str(&format!("{service_name}\n")),
68 }
69 if let Some(hint) = &service.hint {
70 output.push_str(&format!(" hint: {hint}\n"));
71 }
72
73 for (command_name, command) in &service.commands {
74 match command.description() {
75 Some(description) => output.push_str(&format!(
76 " {command_name} ({:?}): {description}\n",
77 command.mode()
78 )),
79 None => output.push_str(&format!(" {command_name} ({:?})\n", command.mode())),
80 }
81 let asset_hosts = asset_hosts(command);
82 if !asset_hosts.is_empty() {
83 output.push_str(&format!(" asset hosts: {}\n", asset_hosts.join(", ")));
84 }
85 }
86 }
87
88 Ok(output)
89}
90
91fn asset_hosts(command: &CommandConfig) -> Vec<&str> {
92 match command {
93 CommandConfig::Rest(rest) => rest.asset_hosts.iter().map(String::as_str).collect(),
94 CommandConfig::Delegated(_) => Vec::new(),
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 fn config() -> Config {
103 Config::from_toml_str(
104 r#"
105version = 1
106
107[providers.onepassword]
108type = "1password"
109
110[services.github]
111description = "GitHub access"
112hint = "via github api /user"
113provider = "onepassword"
114
115[services.github.secrets]
116token = "op://Private/GitHub/token"
117
118[services.github.commands.api]
119description = "REST access"
120mode = "rest"
121base_url = "https://api.github.com"
122asset_hosts = ["uploads.linear.app"]
123
124[services.github.commands.api.auth]
125type = "bearer"
126secret = "token"
127"#,
128 )
129 .unwrap()
130 }
131
132 #[test]
133 fn renders_human_capabilities() {
134 let output = render(&config(), false).unwrap();
135
136 assert!(output.contains("github: GitHub access"));
137 assert!(output.contains("hint: via github api /user"));
138 assert!(output.contains("api (Rest): REST access"));
139 assert!(output.contains("asset hosts: uploads.linear.app"));
140 }
141
142 #[test]
143 fn renders_json_capabilities_without_secret_refs() {
144 let output = render(&config(), true).unwrap();
145
146 assert!(output.contains("\"name\": \"github\""));
147 assert!(output.contains("\"hint\": \"via github api /user\""));
148 assert!(output.contains("\"mode\": \"rest\""));
149 assert!(output.contains("\"asset_hosts\""));
150 assert!(output.contains("\"uploads.linear.app\""));
151 assert!(!output.contains("op://"));
152 }
153}