1pub mod web;
6
7#[cfg(unix)]
8use std::{
9 fs::{Permissions, set_permissions},
10 os::unix::fs::PermissionsExt,
11};
12
13use std::{
14 fs::{read_to_string, write},
15 io::Result as IoResult,
16 path::Path,
17};
18
19use serde::{Deserialize, Serialize};
20
21use crate::errors::QobuzApiError::{self, CredentialsError};
22
23#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
27pub struct Credential {
28 pub user_id: Option<String>,
30 pub user_auth_token: Option<String>,
32 pub email: Option<String>,
34 pub password: Option<String>,
36 pub app_id: Option<String>,
38 pub app_secret: Option<String>,
40}
41
42pub fn load_app_credentials(path: &Path) -> Result<Option<(String, String)>, QobuzApiError> {
56 if !path.exists() {
57 return Ok(None);
58 }
59
60 let mut app_id = None;
61 let mut app_secret = None;
62
63 for (key, value) in parse_env_file(path).map_err(|e| CredentialsError {
64 message: format!("Failed to read .env file: {e}"),
65 })? {
66 match key.as_str() {
67 "QOBUZ_APP_ID" => app_id = Some(value),
68 "QOBUZ_APP_SECRET" => app_secret = Some(value),
69 _ => {}
70 }
71 }
72
73 match (app_id, app_secret) {
74 (Some(id), Some(secret)) => Ok(Some((id, secret))),
75 _ => Ok(None),
76 }
77}
78
79pub fn save_app_credentials(
95 path: &Path,
96 app_id: &str,
97 app_secret: &str,
98) -> Result<(), QobuzApiError> {
99 let existing = if path.exists() {
100 read_to_string(path)?
101 } else {
102 String::new()
103 };
104
105 let lines: Vec<&str> = existing.lines().collect();
106 let mut updated_lines: Vec<String> = lines.iter().map(ToString::to_string).collect();
107 let mut id_written = false;
108 let mut secret_written = false;
109
110 for line in &mut updated_lines {
111 if line.starts_with("QOBUZ_APP_ID=") || line == "# QOBUZ_APP_ID=" {
112 *line = format!("QOBUZ_APP_ID={app_id}");
113 id_written = true;
114 }
115 if line.starts_with("QOBUZ_APP_SECRET=") || line == "# QOBUZ_APP_SECRET=" {
116 *line = format!("QOBUZ_APP_SECRET={app_secret}");
117 secret_written = true;
118 }
119 }
120
121 if !id_written {
122 updated_lines.push(format!("QOBUZ_APP_ID={app_id}"));
123 }
124 if !secret_written {
125 updated_lines.push(format!("QOBUZ_APP_SECRET={app_secret}"));
126 }
127
128 let content = updated_lines.join("\n");
129 write(path, content)?;
130
131 set_file_permissions(path)?;
132
133 Ok(())
134}
135
136fn set_file_permissions(path: &Path) -> Result<(), QobuzApiError> {
150 #[cfg(unix)]
151 {
152 set_permissions(path, Permissions::from_mode(0o600))?;
153 }
154 Ok(())
155}
156
157fn parse_env_line(line: &str) -> Option<(String, String)> {
161 let trimmed = line.trim();
162 if trimmed.is_empty() || trimmed.starts_with('#') {
163 return None;
164 }
165
166 let (key, value) = trimmed.split_once('=')?;
167 let key = key.trim().to_string();
168 let value = value.trim();
169
170 let value = unquote(value).to_string();
171
172 Some((key, value))
173}
174
175fn unquote(s: &str) -> &str {
177 s.strip_prefix('"')
178 .and_then(|inner| inner.strip_suffix('"'))
179 .or_else(|| {
180 let inner = s.strip_prefix('\'')?;
181 inner.strip_suffix('\'')
182 })
183 .unwrap_or(s)
184}
185
186pub fn parse_env_file(path: &Path) -> IoResult<Vec<(String, String)>> {
202 let content = read_to_string(path)?;
203 Ok(content.lines().filter_map(parse_env_line).collect())
204}
205
206#[cfg(test)]
207mod tests {
208 use std::{fs::read_to_string, io::Write, path::Path};
209
210 use {
211 anyhow::{Result, anyhow, ensure},
212 tempfile::{NamedTempFile, tempdir},
213 };
214
215 use crate::credentials::{load_app_credentials, save_app_credentials};
216
217 fn create_temp_env(contents: &str) -> Result<NamedTempFile> {
227 let mut file = NamedTempFile::new()?;
228 write!(file, "{contents}")?;
229 Ok(file)
230 }
231
232 #[test]
238 fn load_app_credentials_reads_existing_file() -> Result<()> {
239 let file = create_temp_env("QOBUZ_APP_ID=12345\nQOBUZ_APP_SECRET=secret123\n")?;
240 let result = load_app_credentials(file.path())?;
241 let (id, secret) = result.ok_or_else(|| anyhow!("expected Some"))?;
242 ensure!(id == "12345", "id mismatch");
243 ensure!(secret == "secret123", "secret mismatch");
244 Ok(())
245 }
246
247 #[test]
253 fn load_app_credentials_returns_none_for_missing_fields() -> Result<()> {
254 let file = create_temp_env("QOBUZ_APP_ID=12345\n")?;
255 let result = load_app_credentials(file.path())?;
256 ensure!(result.is_none(), "expected None when fields missing");
257 Ok(())
258 }
259
260 #[test]
266 fn load_app_credentials_returns_none_for_nonexistent_file() -> Result<()> {
267 let result = load_app_credentials(Path::new("/nonexistent/.env"))?;
268 ensure!(result.is_none(), "expected None for nonexistent file");
269 Ok(())
270 }
271
272 #[test]
278 fn save_app_credentials_creates_new_file() -> Result<()> {
279 let dir = tempdir()?;
280 let path = dir.path().join(".env");
281
282 save_app_credentials(&path, "app123", "secret456")?;
283
284 let content = read_to_string(&path)?;
285 ensure!(content.contains("QOBUZ_APP_ID=app123"), "missing app_id");
286 ensure!(
287 content.contains("QOBUZ_APP_SECRET=secret456"),
288 "missing app_secret"
289 );
290 Ok(())
291 }
292
293 #[test]
299 fn save_app_credentials_updates_existing_values() -> Result<()> {
300 let file = create_temp_env("QOBUZ_APP_ID=old\nQOBUZ_APP_SECRET=oldsecret\n")?;
301 save_app_credentials(file.path(), "new", "newsecret")?;
302
303 let content = read_to_string(file.path())?;
304 ensure!(
305 content.contains("QOBUZ_APP_ID=new"),
306 "should contain new id"
307 );
308 ensure!(
309 content.contains("QOBUZ_APP_SECRET=newsecret"),
310 "should contain new secret"
311 );
312 ensure!(!content.contains("old"), "should not contain old values");
313 Ok(())
314 }
315
316 #[test]
322 fn save_app_credentials_replaces_commented_placeholders() -> Result<()> {
323 let file = create_temp_env("# QOBUZ_APP_ID=\n# QOBUZ_APP_SECRET=\nQOBUZ_USER_ID=123\n")?;
324 save_app_credentials(file.path(), "app_id_val", "secret_val")?;
325
326 let content = read_to_string(file.path())?;
327 ensure!(
328 content.contains("QOBUZ_APP_ID=app_id_val"),
329 "should contain app_id"
330 );
331 ensure!(
332 content.contains("QOBUZ_APP_SECRET=secret_val"),
333 "should contain app_secret"
334 );
335 ensure!(
336 !content.contains("# QOBUZ_APP_ID="),
337 "should not contain commented placeholder"
338 );
339 ensure!(
340 !content.contains("# QOBUZ_APP_SECRET="),
341 "should not contain commented placeholder"
342 );
343 ensure!(
344 content.contains("QOBUZ_USER_ID=123"),
345 "should preserve other lines"
346 );
347 Ok(())
348 }
349}