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