1use serde::Deserialize;
2use std::path::{Path, PathBuf};
3
4use crate::error::{AuthyError, Result};
5
6#[derive(Debug, Clone, Deserialize)]
18pub struct ProjectConfigFile {
19 pub authy: ProjectConfig,
20}
21
22#[derive(Debug, Clone, Deserialize)]
23pub struct ProjectConfig {
24 pub scope: String,
26 pub keyfile: Option<String>,
28 pub vault: Option<String>,
30 #[serde(default)]
32 pub uppercase: bool,
33 pub replace_dash: Option<String>,
35 pub prefix: Option<String>,
37 #[serde(default)]
39 pub aliases: Vec<String>,
40}
41
42const CONFIG_FILENAME: &str = ".authy.toml";
43
44impl ProjectConfig {
45 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 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 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 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 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 pub fn replace_dash_char(&self) -> Option<char> {
100 self.replace_dash.as_ref().and_then(|s| s.chars().next())
101 }
102
103 pub fn expanded_keyfile(&self) -> Option<String> {
105 self.keyfile.as_ref().map(|kf| expand_tilde(kf))
106 }
107
108 pub fn expanded_vault(&self) -> Option<String> {
110 self.vault.as_ref().map(|v| expand_tilde(v))
111 }
112}
113
114fn 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 assert_eq!(expand_tilde("/absolute/path"), "/absolute/path");
268 }
269}