1use std::time::{Duration, Instant};
3
4use anyhow::bail;
5
6use crate::config::{AccountConfig, Config, ConfigError, PasswordSource};
7use crate::credentials::{self, CredentialError, Secret};
8use crate::daemon::Client;
9use crate::paths::Paths;
10
11const POLL: Duration = Duration::from_millis(100);
13
14#[derive(Debug, thiserror::Error)]
15pub enum AddError {
16 #[error(transparent)]
17 Config(#[from] ConfigError),
18 #[error(transparent)]
19 Credential(#[from] CredentialError),
20 #[error("account '{0}' keeps its password in the keyring, so a password is required")]
21 MissingSecret(String),
22}
23
24fn with_account(config: &Config, account: &AccountConfig) -> Config {
25 let mut candidate = config.clone();
26 match candidate
27 .accounts
28 .iter_mut()
29 .find(|a| a.name == account.name)
30 {
31 Some(existing) => *existing = account.clone(),
32 None => candidate.accounts.push(account.clone()),
33 }
34 candidate
35}
36
37pub fn check(config: &Config, account: &AccountConfig) -> Result<(), ConfigError> {
39 with_account(config, account).validate()
40}
41
42pub fn add(
45 config: &mut Config,
46 paths: &Paths,
47 account: AccountConfig,
48 secret: Option<Secret>,
49) -> Result<(), AddError> {
50 check(config, &account)?;
51 if matches!(account.password, PasswordSource::Keyring { .. }) {
52 let secret = secret.ok_or_else(|| AddError::MissingSecret(account.name.clone()))?;
53 credentials::store(&account.name, &secret)?;
54 }
55 let candidate = with_account(config, &account);
56 candidate.save(&paths.config_file())?;
57 *config = candidate;
58 Ok(())
59}
60
61pub const START_TIMEOUT: Duration = Duration::from_secs(10);
63
64pub fn await_started(client: &Client, name: &str, timeout: Duration) -> anyhow::Result<()> {
68 let deadline = Instant::now() + timeout;
69 loop {
70 if client.status()?.accounts.iter().any(|a| a.name == name) {
71 return Ok(());
72 }
73 if Instant::now() >= deadline {
74 bail!(
75 "the daemon did not pick up account '{name}' within {} s",
76 timeout.as_secs_f32()
77 );
78 }
79 std::thread::sleep(POLL);
80 }
81}
82
83#[cfg(test)]
84mod tests {
85 use std::time::Duration;
86
87 use super::*;
88 use crate::config::{AccountConfig, Config, PasswordSource};
89 use crate::credentials::{self, Secret};
90 use crate::daemon::Client;
91 use crate::daemon::test_support::{TestDaemon, options, recording_connector, write_config};
92 use crate::paths::Paths;
93
94 fn home() -> (tempfile::TempDir, Paths) {
95 let dir = tempfile::tempdir().unwrap();
96 let paths = Paths::under(dir.path());
97 std::fs::create_dir_all(&paths.config_dir).unwrap();
98 (dir, paths)
99 }
100
101 fn keyring_account(name: &str) -> AccountConfig {
102 AccountConfig::new(
103 name,
104 "imap.example.com",
105 "me@example.com",
106 PasswordSource::Keyring { keyring: true },
107 )
108 }
109
110 fn command_account(name: &str) -> AccountConfig {
111 AccountConfig::new(
112 name,
113 "imap.example.com",
114 "me@example.com",
115 PasswordSource::Command {
116 command: "printf x".into(),
117 },
118 )
119 }
120
121 #[test]
122 fn add_saves_a_command_account_without_a_secret() {
123 let (_dir, paths) = home();
124 let mut config = Config::default();
125 add(&mut config, &paths, command_account("work"), None).unwrap();
126 let saved = Config::load(&paths.config_file()).unwrap();
127 assert_eq!(saved.accounts.len(), 1);
128 assert_eq!(saved.accounts[0].name, "work");
129 assert_eq!(config.accounts, saved.accounts);
130 }
131
132 #[test]
133 fn add_stores_the_keyring_secret_then_saves() {
134 let (_dir, paths) = home();
135 let mut config = Config::default();
136 let account = keyring_account("home");
137 add(
138 &mut config,
139 &paths,
140 account.clone(),
141 Some(Secret::new("hunter2".into())),
142 )
143 .unwrap();
144 assert_eq!(credentials::resolve(&account).unwrap().expose(), "hunter2");
145 assert!(paths.config_file().exists());
146 }
147
148 #[test]
149 fn add_refuses_a_keyring_account_without_a_secret_and_writes_nothing() {
150 let (_dir, paths) = home();
151 let mut config = Config::default();
152 let err = add(&mut config, &paths, keyring_account("nosecret"), None).unwrap_err();
153 assert!(
154 matches!(err, AddError::MissingSecret(ref name) if name == "nosecret"),
155 "{err}"
156 );
157 assert!(!paths.config_file().exists());
158 assert!(config.accounts.is_empty());
159 }
160
161 #[test]
162 fn add_replaces_an_account_of_the_same_name() {
163 let (_dir, paths) = home();
164 let mut config = Config::default();
165 add(&mut config, &paths, command_account("work"), None).unwrap();
166 add(&mut config, &paths, command_account("home"), None).unwrap();
167 let mut changed = command_account("work");
168 changed.port = 1993;
169 add(&mut config, &paths, changed, None).unwrap();
170 let saved = Config::load(&paths.config_file()).unwrap();
171 assert_eq!(config.accounts, saved.accounts);
172 let names: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
173 assert_eq!(names, ["work", "home"], "replaced in place");
174 assert_eq!(config.accounts[0].port, 1993);
175 }
176
177 #[test]
178 fn add_leaves_config_untouched_on_a_validation_error() {
179 let (_dir, paths) = home();
180 let mut config = Config::default();
181 add(&mut config, &paths, command_account("work"), None).unwrap();
182 let before = std::fs::read_to_string(paths.config_file()).unwrap();
183 let err = add(&mut config, &paths, command_account("my work"), None).unwrap_err();
184 assert!(
185 err.to_string().contains("may only contain letters, digits"),
186 "{err}"
187 );
188 assert_eq!(
189 std::fs::read_to_string(paths.config_file()).unwrap(),
190 before
191 );
192 assert_eq!(config.accounts.len(), 1);
193 }
194
195 #[test]
196 fn check_refuses_an_invalid_account_and_leaves_config_alone() {
197 let config = Config::default();
198 let err = check(&config, &command_account("my work")).unwrap_err();
199 assert!(
200 err.to_string().contains("may only contain letters, digits"),
201 "{err}"
202 );
203 assert!(check(&config, &command_account("work")).is_ok());
204 assert!(config.accounts.is_empty());
205 }
206
207 fn port_one_fails() -> crate::engine::Connector {
209 std::sync::Arc::new(|account: &AccountConfig| {
210 if account.port == 1 {
211 return Err(crate::sync::SyncError::Io(std::io::Error::other(
212 "connection refused",
213 )));
214 }
215 recording_connector()(account)
216 })
217 }
218
219 #[test]
220 fn adding_an_account_again_syncs_with_its_new_config() {
221 let home = tempfile::tempdir().unwrap();
222 let paths = Paths::under(home.path());
223 write_config(&paths);
224 let daemon = TestDaemon::serve(home, paths.clone(), options(port_one_fails(), None));
225 let client = Client::connect_or_start(&daemon.paths).unwrap();
226 let mut config = Config::load(&paths.config_file()).unwrap();
227 let mut account = command_account("late");
228 account.port = 1;
229 add(&mut config, &paths, account.clone(), None).unwrap();
230 await_started(&client, "late", Duration::from_secs(10)).unwrap();
231 let first = client.request("late", crate::sync::Command::SyncNow);
232 assert!(first.is_err(), "{first:?}");
233
234 account.port = 2;
235 add(&mut config, &paths, account, None).unwrap();
236 await_started(&client, "late", Duration::from_secs(10)).unwrap();
237 let second = client.request("late", crate::sync::Command::SyncNow);
238 assert!(
239 matches!(second, Ok(crate::sync::Event::Synced { .. })),
240 "{second:?}"
241 );
242 }
243
244 #[test]
245 fn await_started_waits_for_the_daemon_to_pick_up_the_account() {
246 let home = tempfile::tempdir().unwrap();
247 let paths = Paths::under(home.path());
248 write_config(&paths);
249 let daemon = TestDaemon::serve(home, paths.clone(), options(recording_connector(), None));
250 let client = Client::connect_or_start(&daemon.paths).unwrap();
251 let mut config = Config::load(&paths.config_file()).unwrap();
252 add(&mut config, &paths, command_account("late"), None).unwrap();
253 await_started(&client, "late", Duration::from_secs(10)).unwrap();
254 assert!(
255 client
256 .status()
257 .unwrap()
258 .accounts
259 .iter()
260 .any(|a| a.name == "late")
261 );
262 let err = await_started(&client, "never", Duration::from_millis(300)).unwrap_err();
263 assert!(err.to_string().contains("never"), "{err}");
264 }
265}