1use std::collections::BTreeMap;
2use std::env;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::ViaError;
9
10#[derive(Debug, Deserialize)]
11pub struct Config {
12 pub version: u32,
13 #[serde(default)]
14 pub providers: BTreeMap<String, ProviderConfig>,
15 #[serde(default)]
16 pub services: BTreeMap<String, ServiceConfig>,
17}
18
19#[derive(Debug, Deserialize)]
20#[serde(tag = "type")]
21pub enum ProviderConfig {
22 #[serde(rename = "1password")]
23 OnePassword {
24 #[serde(default)]
25 account: Option<String>,
26 },
27}
28
29#[derive(Debug, Deserialize)]
30pub struct ServiceConfig {
31 #[serde(default)]
32 pub description: Option<String>,
33 pub provider: String,
34 #[serde(default)]
35 pub secrets: BTreeMap<String, String>,
36 #[serde(default)]
37 pub commands: BTreeMap<String, CommandConfig>,
38}
39
40#[derive(Debug, Deserialize)]
41#[serde(tag = "mode")]
42pub enum CommandConfig {
43 #[serde(rename = "rest")]
44 Rest(RestCommandConfig),
45 #[serde(rename = "delegated")]
46 Delegated(DelegatedCommandConfig),
47}
48
49#[derive(Debug, Clone, Copy, Serialize)]
50#[serde(rename_all = "lowercase")]
51pub enum CapabilityMode {
52 Rest,
53 Delegated,
54}
55
56#[derive(Debug, Deserialize)]
57pub struct RestCommandConfig {
58 #[serde(default)]
59 pub description: Option<String>,
60 pub base_url: String,
61 #[serde(default = "default_method")]
62 pub method_default: String,
63 #[serde(default)]
64 pub auth: Option<AuthConfig>,
65 #[serde(default)]
66 pub headers: BTreeMap<String, String>,
67}
68
69#[derive(Debug, Deserialize)]
70#[serde(tag = "type")]
71pub enum AuthConfig {
72 #[serde(rename = "bearer")]
73 Bearer { secret: String },
74 #[serde(rename = "headers")]
75 Headers {
76 #[serde(default)]
77 headers: BTreeMap<String, SecretHeaderConfig>,
78 },
79 #[serde(rename = "github_app")]
80 GitHubApp {
81 #[serde(default)]
82 secret: Option<String>,
83 #[serde(default)]
84 credential: Option<String>,
85 #[serde(default)]
86 private_key: Option<String>,
87 },
88}
89
90#[derive(Debug, Deserialize)]
91pub struct SecretHeaderConfig {
92 pub secret: String,
93 #[serde(default)]
94 pub prefix: String,
95 #[serde(default)]
96 pub suffix: String,
97}
98
99#[derive(Debug, Deserialize)]
100pub struct DelegatedCommandConfig {
101 #[serde(default)]
102 pub description: Option<String>,
103 pub program: String,
104 #[serde(default)]
105 pub args_prefix: Vec<String>,
106 #[serde(default)]
107 pub inject: InjectConfig,
108 #[serde(default)]
109 pub check: Vec<String>,
110}
111
112#[derive(Debug, Default, Deserialize)]
113pub struct InjectConfig {
114 #[serde(default)]
115 pub env: BTreeMap<String, SecretBinding>,
116}
117
118#[derive(Debug, Deserialize)]
119pub struct SecretBinding {
120 pub secret: String,
121}
122
123impl Config {
124 pub fn load(path: Option<&Path>) -> Result<Self, ViaError> {
125 let path = resolve_path(path)?;
126
127 let raw = fs::read_to_string(&path).map_err(|source| ViaError::ReadConfig {
128 path: path.clone(),
129 source,
130 })?;
131 Self::from_toml_str(&raw)
132 }
133
134 pub(crate) fn from_toml_str(raw: &str) -> Result<Self, ViaError> {
135 let config: Self = toml::from_str(raw)?;
136 config.validate()?;
137 Ok(config)
138 }
139
140 fn validate(&self) -> Result<(), ViaError> {
141 if self.version != 1 {
142 return Err(ViaError::InvalidConfig(format!(
143 "unsupported config version {}; expected 1",
144 self.version
145 )));
146 }
147
148 for (service_name, service) in &self.services {
149 if !self.providers.contains_key(&service.provider) {
150 return Err(ViaError::InvalidConfig(format!(
151 "service `{service_name}` references unknown provider `{}`",
152 service.provider
153 )));
154 }
155
156 for (secret_name, reference) in &service.secrets {
157 if !reference.starts_with("op://") {
158 return Err(ViaError::InvalidConfig(format!(
159 "secret `{service_name}.{secret_name}` must be an op:// reference"
160 )));
161 }
162 }
163
164 for (command_name, command) in &service.commands {
165 command.validate(service_name, command_name, service)?;
166 }
167 }
168
169 Ok(())
170 }
171}
172
173pub fn resolve_path(path: Option<&Path>) -> Result<PathBuf, ViaError> {
174 match path {
175 Some(path) => Ok(path.to_path_buf()),
176 None => default_config_path(),
177 }
178}
179
180impl CommandConfig {
181 pub fn description(&self) -> Option<&String> {
182 match self {
183 CommandConfig::Rest(config) => config.description.as_ref(),
184 CommandConfig::Delegated(config) => config.description.as_ref(),
185 }
186 }
187
188 pub fn mode(&self) -> CapabilityMode {
189 match self {
190 CommandConfig::Rest(_) => CapabilityMode::Rest,
191 CommandConfig::Delegated(_) => CapabilityMode::Delegated,
192 }
193 }
194
195 fn validate(
196 &self,
197 service_name: &str,
198 command_name: &str,
199 service: &ServiceConfig,
200 ) -> Result<(), ViaError> {
201 match self {
202 CommandConfig::Rest(rest) => {
203 if rest.base_url.trim().is_empty() {
204 return Err(ViaError::InvalidConfig(format!(
205 "command `{service_name}.{command_name}` must set rest base_url"
206 )));
207 }
208
209 if let Some(auth) = &rest.auth {
210 match auth {
211 AuthConfig::Bearer { secret } => {
212 validate_secret_name(service_name, command_name, service, secret)?;
213 }
214 AuthConfig::Headers { headers } => {
215 if headers.is_empty() {
216 return Err(ViaError::InvalidConfig(format!(
217 "command `{service_name}.{command_name}` headers auth must configure at least one header"
218 )));
219 }
220 for secret_header in headers.values() {
221 validate_secret_name(
222 service_name,
223 command_name,
224 service,
225 &secret_header.secret,
226 )?;
227 }
228 }
229 AuthConfig::GitHubApp {
230 secret,
231 credential,
232 private_key,
233 } => validate_github_app_auth(
234 service_name,
235 command_name,
236 service,
237 secret.as_deref(),
238 credential.as_deref(),
239 private_key.as_deref(),
240 )?,
241 }
242 }
243 }
244 CommandConfig::Delegated(delegated) => {
245 if delegated.program.trim().is_empty() {
246 return Err(ViaError::InvalidConfig(format!(
247 "command `{service_name}.{command_name}` must set delegated program"
248 )));
249 }
250
251 for binding in delegated.inject.env.values() {
252 validate_secret_name(service_name, command_name, service, &binding.secret)?;
253 }
254 }
255 }
256
257 Ok(())
258 }
259}
260
261fn validate_secret_name(
262 service_name: &str,
263 command_name: &str,
264 service: &ServiceConfig,
265 secret: &str,
266) -> Result<(), ViaError> {
267 if service.secrets.contains_key(secret) {
268 return Ok(());
269 }
270
271 Err(ViaError::InvalidConfig(format!(
272 "command `{service_name}.{command_name}` references unknown secret `{secret}`"
273 )))
274}
275
276fn validate_github_app_auth(
277 service_name: &str,
278 command_name: &str,
279 service: &ServiceConfig,
280 secret: Option<&str>,
281 credential: Option<&str>,
282 private_key: Option<&str>,
283) -> Result<(), ViaError> {
284 match (secret, credential, private_key) {
285 (Some(secret), None, None) => {
286 validate_secret_name(service_name, command_name, service, secret)
287 }
288 (None, Some(credential), Some(private_key)) => {
289 validate_secret_name(service_name, command_name, service, credential)?;
290 validate_secret_name(service_name, command_name, service, private_key)
291 }
292 _ => Err(ViaError::InvalidConfig(format!(
293 "command `{service_name}.{command_name}` github_app auth must set either `secret` or both `credential` and `private_key`"
294 ))),
295 }
296}
297
298fn default_method() -> String {
299 "GET".to_owned()
300}
301
302fn default_config_path() -> Result<PathBuf, ViaError> {
303 if let Ok(path) = env::var("VIA_CONFIG") {
304 return Ok(PathBuf::from(path));
305 }
306
307 let local = PathBuf::from("via.toml");
308 if local.exists() {
309 return Ok(local);
310 }
311
312 let home = env::var_os("HOME")
313 .map(PathBuf::from)
314 .ok_or_else(|| ViaError::ConfigNotFound("HOME is not set".to_owned()))?;
315 Ok(home.join(".config").join("via").join("config.toml"))
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 const VALID: &str = r#"
323version = 1
324
325[providers.onepassword]
326type = "1password"
327
328[services.github]
329description = "GitHub access"
330provider = "onepassword"
331
332[services.github.secrets]
333token = "op://Private/GitHub/token"
334
335[services.github.commands.api]
336description = "REST access"
337mode = "rest"
338base_url = "https://api.github.com"
339
340[services.github.commands.api.auth]
341type = "bearer"
342secret = "token"
343
344[services.github.commands.gh]
345description = "GitHub CLI access"
346mode = "delegated"
347program = "gh"
348check = ["--version"]
349
350[services.github.commands.gh.inject.env.GH_TOKEN]
351secret = "token"
352"#;
353
354 #[test]
355 fn parses_valid_config() {
356 let config = Config::from_toml_str(VALID).unwrap();
357
358 assert_eq!(config.version, 1);
359 assert!(config.services["github"].commands.contains_key("api"));
360 assert!(config.services["github"].commands.contains_key("gh"));
361 }
362
363 #[test]
364 fn rejects_unknown_provider() {
365 let raw = VALID.replace("provider = \"onepassword\"", "provider = \"missing\"");
366
367 assert!(matches!(
368 Config::from_toml_str(&raw),
369 Err(ViaError::InvalidConfig(message)) if message.contains("unknown provider")
370 ));
371 }
372
373 #[test]
374 fn rejects_plaintext_secret_values() {
375 let raw = VALID.replace("op://Private/GitHub/token", "ghp_plaintext");
376
377 assert!(matches!(
378 Config::from_toml_str(&raw),
379 Err(ViaError::InvalidConfig(message)) if message.contains("must be an op:// reference")
380 ));
381 }
382
383 #[test]
384 fn rejects_unknown_rest_secret() {
385 let raw = VALID.replace("secret = \"token\"", "secret = \"missing\"");
386
387 assert!(matches!(
388 Config::from_toml_str(&raw),
389 Err(ViaError::InvalidConfig(message)) if message.contains("unknown secret")
390 ));
391 }
392
393 #[test]
394 fn accepts_github_app_rest_auth() {
395 let raw = VALID.replace(
396 r#"[services.github.commands.api.auth]
397type = "bearer"
398secret = "token""#,
399 r#"[services.github.commands.api.auth]
400type = "github_app"
401credential = "token"
402private_key = "token""#,
403 );
404
405 assert!(Config::from_toml_str(&raw).is_ok());
406 }
407
408 #[test]
409 fn accepts_secret_header_rest_auth() {
410 let raw = VALID.replace(
411 r#"[services.github.commands.api.auth]
412type = "bearer"
413secret = "token""#,
414 r#"[services.github.commands.api.auth]
415type = "headers"
416
417[services.github.commands.api.auth.headers.Authorization]
418secret = "token"
419prefix = "Token "
420
421[services.github.commands.api.auth.headers.X-Api-Key]
422secret = "token""#,
423 );
424
425 assert!(Config::from_toml_str(&raw).is_ok());
426 }
427
428 #[test]
429 fn rejects_empty_secret_header_rest_auth() {
430 let raw = VALID.replace(
431 r#"[services.github.commands.api.auth]
432type = "bearer"
433secret = "token""#,
434 r#"[services.github.commands.api.auth]
435type = "headers""#,
436 );
437
438 assert!(matches!(
439 Config::from_toml_str(&raw),
440 Err(ViaError::InvalidConfig(message)) if message.contains("at least one header")
441 ));
442 }
443
444 #[test]
445 fn rejects_unsupported_version() {
446 let raw = VALID.replace("version = 1", "version = 2");
447
448 assert!(matches!(
449 Config::from_toml_str(&raw),
450 Err(ViaError::InvalidConfig(message)) if message.contains("unsupported config version")
451 ));
452 }
453
454 #[test]
455 fn rejects_empty_rest_base_url() {
456 let raw = VALID.replace("base_url = \"https://api.github.com\"", "base_url = \"\"");
457
458 assert!(matches!(
459 Config::from_toml_str(&raw),
460 Err(ViaError::InvalidConfig(message)) if message.contains("base_url")
461 ));
462 }
463
464 #[test]
465 fn rejects_empty_delegated_program() {
466 let raw = VALID.replace("program = \"gh\"", "program = \"\"");
467
468 assert!(matches!(
469 Config::from_toml_str(&raw),
470 Err(ViaError::InvalidConfig(message)) if message.contains("delegated program")
471 ));
472 }
473}