Skip to main content

authy/config/
project.rs

1use serde::Deserialize;
2use std::path::{Path, PathBuf};
3
4use crate::error::{AuthyError, Result};
5
6/// Project-level configuration from `.authy.toml`.
7///
8/// Example:
9/// ```toml
10/// [authy]
11/// scope = "my-project"
12/// keyfile = "~/.authy/keys/my-project.key"
13/// uppercase = true
14/// replace_dash = "_"
15/// aliases = ["claude", "aider"]
16/// ```
17#[derive(Debug, Clone, Deserialize)]
18pub struct ProjectConfigFile {
19    pub authy: ProjectConfig,
20}
21
22#[derive(Debug, Clone, Deserialize)]
23pub struct ProjectConfig {
24    /// Scope (policy name) for secret access (required)
25    pub scope: String,
26    /// Path to keyfile (supports ~ expansion)
27    pub keyfile: Option<String>,
28    /// Override vault path
29    pub vault: Option<String>,
30    /// Uppercase env var names (default false)
31    #[serde(default)]
32    pub uppercase: bool,
33    /// Replace dashes with this character, validated to single char
34    pub replace_dash: Option<String>,
35    /// Prefix for env var names
36    pub prefix: Option<String>,
37    /// Tool names to alias (e.g. ["claude", "aider"])
38    #[serde(default)]
39    pub aliases: Vec<String>,
40}
41
42const CONFIG_FILENAME: &str = ".authy.toml";
43
44impl ProjectConfig {
45    /// Load project config from a specific file path.
46    pub fn load(path: &Path) -> Result<Self> {
47        let content = std::fs::read_to_string(path)
48            .map_err(|e| AuthyError::Other(format!("Failed to read {}: {}", path.display(), e)))?;
49        let file: ProjectConfigFile = toml::from_str(&content)
50            .map_err(|e| AuthyError::Other(format!("Invalid .authy.toml: {}", e)))?;
51
52        let config = file.authy;
53
54        // Validate replace_dash is a single character
55        if let Some(ref rd) = config.replace_dash {
56            if rd.chars().count() != 1 {
57                return Err(AuthyError::Other(format!(
58                    "replace_dash must be a single character, got '{}'",
59                    rd
60                )));
61            }
62        }
63
64        // Validate scope is not empty
65        if config.scope.is_empty() {
66            return Err(AuthyError::Other(
67                "scope must not be empty in .authy.toml".to_string(),
68            ));
69        }
70
71        Ok(config)
72    }
73
74    /// Walk up from `start_dir` looking for `.authy.toml`.
75    /// Returns the config and the directory containing the file.
76    pub fn discover(start_dir: &Path) -> Result<Option<(Self, PathBuf)>> {
77        let mut dir = start_dir.to_path_buf();
78        loop {
79            let candidate = dir.join(CONFIG_FILENAME);
80            if candidate.is_file() {
81                let config = Self::load(&candidate)?;
82                return Ok(Some((config, dir)));
83            }
84            if !dir.pop() {
85                break;
86            }
87        }
88        Ok(None)
89    }
90
91    /// Convenience: discover from current working directory.
92    pub fn discover_from_cwd() -> Result<Option<(Self, PathBuf)>> {
93        let cwd = std::env::current_dir()
94            .map_err(|e| AuthyError::Other(format!("Cannot determine cwd: {}", e)))?;
95        Self::discover(&cwd)
96    }
97
98    /// Get replace_dash as a char.
99    pub fn replace_dash_char(&self) -> Option<char> {
100        self.replace_dash.as_ref().and_then(|s| s.chars().next())
101    }
102
103    /// Expand ~ in keyfile path.
104    pub fn expanded_keyfile(&self) -> Option<String> {
105        self.keyfile.as_ref().map(|kf| expand_tilde(kf))
106    }
107
108    /// Expand ~ in vault path.
109    pub fn expanded_vault(&self) -> Option<String> {
110        self.vault.as_ref().map(|v| expand_tilde(v))
111    }
112}
113
114/// Expand leading `~` to the user's home directory.
115fn expand_tilde(path: &str) -> String {
116    if path.starts_with("~/") || path == "~" {
117        if let Some(home) = dirs::home_dir() {
118            return path.replacen('~', &home.to_string_lossy(), 1);
119        }
120    }
121    path.to_string()
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use std::fs;
128    use tempfile::TempDir;
129
130    #[test]
131    fn test_load_valid_config() {
132        let dir = TempDir::new().unwrap();
133        let config_path = dir.path().join(".authy.toml");
134        fs::write(
135            &config_path,
136            r#"
137[authy]
138scope = "my-project"
139keyfile = "~/.authy/keys/test.key"
140uppercase = true
141replace_dash = "_"
142prefix = "APP_"
143aliases = ["claude", "aider"]
144"#,
145        )
146        .unwrap();
147
148        let config = ProjectConfig::load(&config_path).unwrap();
149        assert_eq!(config.scope, "my-project");
150        assert_eq!(config.keyfile.as_deref(), Some("~/.authy/keys/test.key"));
151        assert!(config.uppercase);
152        assert_eq!(config.replace_dash.as_deref(), Some("_"));
153        assert_eq!(config.prefix.as_deref(), Some("APP_"));
154        assert_eq!(config.aliases, vec!["claude", "aider"]);
155        assert_eq!(config.replace_dash_char(), Some('_'));
156    }
157
158    #[test]
159    fn test_load_minimal_config() {
160        let dir = TempDir::new().unwrap();
161        let config_path = dir.path().join(".authy.toml");
162        fs::write(
163            &config_path,
164            r#"
165[authy]
166scope = "test"
167"#,
168        )
169        .unwrap();
170
171        let config = ProjectConfig::load(&config_path).unwrap();
172        assert_eq!(config.scope, "test");
173        assert!(!config.uppercase);
174        assert!(config.replace_dash.is_none());
175        assert!(config.prefix.is_none());
176        assert!(config.aliases.is_empty());
177        assert!(config.keyfile.is_none());
178        assert!(config.vault.is_none());
179    }
180
181    #[test]
182    fn test_invalid_replace_dash() {
183        let dir = TempDir::new().unwrap();
184        let config_path = dir.path().join(".authy.toml");
185        fs::write(
186            &config_path,
187            r#"
188[authy]
189scope = "test"
190replace_dash = "abc"
191"#,
192        )
193        .unwrap();
194
195        let err = ProjectConfig::load(&config_path).unwrap_err();
196        assert!(err.to_string().contains("single character"));
197    }
198
199    #[test]
200    fn test_empty_scope_rejected() {
201        let dir = TempDir::new().unwrap();
202        let config_path = dir.path().join(".authy.toml");
203        fs::write(
204            &config_path,
205            r#"
206[authy]
207scope = ""
208"#,
209        )
210        .unwrap();
211
212        let err = ProjectConfig::load(&config_path).unwrap_err();
213        assert!(err.to_string().contains("scope must not be empty"));
214    }
215
216    #[test]
217    fn test_discover_walks_up() {
218        let root = TempDir::new().unwrap();
219        let nested = root.path().join("a").join("b").join("c");
220        fs::create_dir_all(&nested).unwrap();
221        fs::write(
222            root.path().join(".authy.toml"),
223            "[authy]\nscope = \"root-project\"\n",
224        )
225        .unwrap();
226
227        let result = ProjectConfig::discover(&nested).unwrap();
228        assert!(result.is_some());
229        let (config, dir) = result.unwrap();
230        assert_eq!(config.scope, "root-project");
231        assert_eq!(dir, root.path());
232    }
233
234    #[test]
235    fn test_discover_finds_closest() {
236        let root = TempDir::new().unwrap();
237        let sub = root.path().join("sub");
238        fs::create_dir_all(&sub).unwrap();
239
240        fs::write(
241            root.path().join(".authy.toml"),
242            "[authy]\nscope = \"root\"\n",
243        )
244        .unwrap();
245        fs::write(sub.join(".authy.toml"), "[authy]\nscope = \"sub\"\n").unwrap();
246
247        let result = ProjectConfig::discover(&sub).unwrap();
248        let (config, dir) = result.unwrap();
249        assert_eq!(config.scope, "sub");
250        assert_eq!(dir, sub);
251    }
252
253    #[test]
254    fn test_discover_none_when_not_found() {
255        let dir = TempDir::new().unwrap();
256        let result = ProjectConfig::discover(dir.path()).unwrap();
257        assert!(result.is_none());
258    }
259
260    #[test]
261    fn test_expand_tilde() {
262        let expanded = expand_tilde("~/foo/bar");
263        assert!(!expanded.starts_with('~'));
264        assert!(expanded.ends_with("/foo/bar"));
265
266        // Absolute path unchanged
267        assert_eq!(expand_tilde("/absolute/path"), "/absolute/path");
268    }
269}