1use crate::error::{Error, Result};
7use directories::ProjectDirs;
8use secrecy::{ExposeSecret, SecretString};
9use serde::{Deserialize, Serialize};
10use std::env;
11use std::fmt;
12use std::fs;
13use std::path::{Path, PathBuf};
14use tracing::debug;
15
16#[cfg(unix)]
17use std::os::unix::fs::PermissionsExt;
18
19const ENV_TOKEN: &str = "ASANA_PAT";
20const ENV_BASE_URL: &str = "ASANA_BASE_URL";
21const ENV_WORKSPACE: &str = "ASANA_WORKSPACE";
22const ENV_ASSIGNEE: &str = "ASANA_ASSIGNEE";
23const ENV_PROJECT: &str = "ASANA_PROJECT";
24const ENV_CONFIG_HOME: &str = "ASANA_CLI_CONFIG_HOME";
25const ENV_DATA_HOME: &str = "ASANA_CLI_DATA_HOME";
26pub const DEFAULT_API_BASE_URL: &str = "https://app.asana.com/api/1.0";
28
29#[derive(Clone, Debug, Default)]
35pub struct EnvInputs {
36 pub token: Option<String>,
38 pub base_url: Option<String>,
40 pub workspace: Option<String>,
42 pub assignee: Option<String>,
44 pub project: Option<String>,
46 pub config_home: Option<PathBuf>,
48 pub data_home: Option<PathBuf>,
50}
51
52impl EnvInputs {
53 #[must_use]
55 #[allow(
56 clippy::disallowed_methods,
57 reason = "ASANA_* env vars are intentional overrides layered over the TOML config (api_base_url, default_workspace, …), read once at the CLI entry edge (REPO_INVARIANTS.md #5)"
58 )]
59 pub fn from_env() -> Self {
60 Self {
61 token: env::var(ENV_TOKEN).ok(),
62 base_url: env::var(ENV_BASE_URL).ok(),
63 workspace: env::var(ENV_WORKSPACE).ok(),
64 assignee: env::var(ENV_ASSIGNEE).ok(),
65 project: env::var(ENV_PROJECT).ok(),
66 config_home: env::var_os(ENV_CONFIG_HOME).map(PathBuf::from),
67 data_home: env::var_os(ENV_DATA_HOME).map(PathBuf::from),
68 }
69 }
70}
71
72#[derive(Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
74#[serde(default)]
75pub struct FileConfig {
76 pub api_base_url: Option<String>,
78 pub default_workspace: Option<String>,
80 pub default_assignee: Option<String>,
82 pub default_project: Option<String>,
84 pub personal_access_token: Option<String>,
86}
87
88impl fmt::Debug for FileConfig {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 f.debug_struct("FileConfig")
91 .field("api_base_url", &self.api_base_url)
92 .field("default_workspace", &self.default_workspace)
93 .field("default_assignee", &self.default_assignee)
94 .field("default_project", &self.default_project)
95 .field(
96 "personal_access_token",
97 &self.personal_access_token.as_ref().map(|_| "REDACTED"),
98 )
99 .finish()
100 }
101}
102
103pub struct Config {
105 file: FileConfig,
106 overrides: Overrides,
107 paths: ConfigPaths,
108}
109
110impl Config {
111 pub fn load(inputs: &EnvInputs) -> Result<Self> {
116 let paths = resolve_paths(inputs)?;
117 if let Some(parent) = paths.config_file.parent() {
118 fs::create_dir_all(parent)?;
119 }
120 fs::create_dir_all(&paths.cache_dir)?;
121
122 let file = read_config_file(&paths.config_file)?;
123 let overrides = Overrides::from_inputs(inputs);
124
125 Ok(Self {
126 file,
127 overrides,
128 paths,
129 })
130 }
131
132 #[must_use]
134 pub fn path(&self) -> &Path {
135 &self.paths.config_file
136 }
137
138 #[must_use]
140 pub fn cache_dir(&self) -> &Path {
141 &self.paths.cache_dir
142 }
143
144 #[must_use]
146 pub fn data_dir(&self) -> &Path {
147 &self.paths.data_dir
148 }
149
150 #[must_use]
152 pub fn templates_dir(&self) -> PathBuf {
153 self.paths.data_dir.join("templates")
154 }
155
156 #[must_use]
158 pub fn filters_dir(&self) -> PathBuf {
159 self.paths.data_dir.join("filters")
160 }
161
162 #[must_use]
164 pub fn api_base_url(&self) -> Option<&str> {
165 self.overrides
166 .api_base_url
167 .as_deref()
168 .or(self.file.api_base_url.as_deref())
169 }
170
171 #[must_use]
173 pub fn effective_api_base_url(&self) -> &str {
174 self.api_base_url().unwrap_or(DEFAULT_API_BASE_URL)
175 }
176
177 #[must_use]
179 pub fn default_workspace(&self) -> Option<&str> {
180 self.overrides
181 .default_workspace
182 .as_deref()
183 .or(self.file.default_workspace.as_deref())
184 }
185
186 pub fn set_default_workspace(&mut self, workspace: Option<String>) -> Result<()> {
192 self.file.default_workspace = workspace;
193 self.save()
194 }
195
196 #[must_use]
198 pub fn default_assignee(&self) -> Option<&str> {
199 self.overrides
200 .default_assignee
201 .as_deref()
202 .or(self.file.default_assignee.as_deref())
203 }
204
205 pub fn set_default_assignee(&mut self, assignee: Option<String>) -> Result<()> {
211 self.file.default_assignee = assignee;
212 self.save()
213 }
214
215 #[must_use]
217 pub fn default_project(&self) -> Option<&str> {
218 self.overrides
219 .default_project
220 .as_deref()
221 .or(self.file.default_project.as_deref())
222 }
223
224 pub fn set_default_project(&mut self, project: Option<String>) -> Result<()> {
230 self.file.default_project = project;
231 self.save()
232 }
233
234 pub fn save(&self) -> Result<()> {
239 if let Some(parent) = self.paths.config_file.parent() {
240 fs::create_dir_all(parent)?;
241 secure_directory(parent)?;
242 }
243
244 let serialized = toml::to_string_pretty(&self.file)?;
245 fs::write(&self.paths.config_file, serialized)?;
246 secure_file(&self.paths.config_file)?;
247 Ok(())
248 }
249
250 pub fn store_personal_access_token(&mut self, token: &SecretString) -> Result<()> {
255 self.file
256 .personal_access_token
257 .replace(token.expose_secret().to_owned());
258 self.save()
259 }
260
261 #[must_use]
263 pub fn personal_access_token(&self) -> Option<SecretString> {
264 if let Some(token) = self.overrides.personal_access_token.clone() {
265 return Some(token);
266 }
267 self.file.personal_access_token.as_ref().and_then(|value| {
268 if value.trim().is_empty() {
269 None
270 } else {
271 Some(SecretString::new(value.clone().into()))
272 }
273 })
274 }
275
276 pub fn delete_personal_access_token(&mut self) -> Result<()> {
281 self.file.personal_access_token = None;
282 self.save()
283 }
284
285 #[must_use]
287 pub fn has_persisted_token(&self) -> bool {
288 self.file
289 .personal_access_token
290 .as_ref()
291 .is_some_and(|value| !value.trim().is_empty())
292 }
293
294 #[must_use]
296 pub fn environment_token_available(&self) -> bool {
297 self.overrides
298 .personal_access_token
299 .as_ref()
300 .is_some_and(|value| !value.expose_secret().trim().is_empty())
301 }
302
303 pub fn file_config_mut(&mut self) -> &mut FileConfig {
305 &mut self.file
306 }
307}
308
309impl fmt::Debug for Config {
310 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
311 f.debug_struct("Config")
312 .field("file", &self.file)
313 .field("overrides", &self.overrides)
314 .field("paths", &self.paths)
315 .finish()
316 }
317}
318
319fn secure_directory(path: &Path) -> Result<()> {
320 #[cfg(unix)]
321 {
322 let metadata = fs::metadata(path)?;
323 if metadata.is_dir() {
324 let perms = metadata.permissions();
325 let mode = perms.mode();
326 if mode & 0o077 != 0 {
327 let mut tightened = perms;
328 tightened.set_mode(0o700);
329 fs::set_permissions(path, tightened)?;
330 }
331 }
332 }
333 Ok(())
334}
335
336fn secure_file(path: &Path) -> Result<()> {
337 #[cfg(unix)]
338 {
339 let metadata = fs::metadata(path)?;
340 if metadata.is_file() {
341 let perms = metadata.permissions();
342 let mode = perms.mode();
343 if mode & 0o177 != 0o100 {
344 let mut tightened = perms;
345 tightened.set_mode(0o600);
346 fs::set_permissions(path, tightened)?;
347 }
348 }
349 }
350 Ok(())
351}
352
353#[derive(Clone, Default)]
354struct Overrides {
355 api_base_url: Option<String>,
356 default_workspace: Option<String>,
357 default_assignee: Option<String>,
358 default_project: Option<String>,
359 personal_access_token: Option<SecretString>,
360}
361
362impl fmt::Debug for Overrides {
363 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
364 f.debug_struct("Overrides")
365 .field("api_base_url", &self.api_base_url)
366 .field("default_workspace", &self.default_workspace)
367 .field("default_assignee", &self.default_assignee)
368 .field("default_project", &self.default_project)
369 .field(
370 "personal_access_token",
371 &self.personal_access_token.as_ref().map(|_| "REDACTED"),
372 )
373 .finish()
374 }
375}
376
377impl Overrides {
378 fn from_inputs(inputs: &EnvInputs) -> Self {
379 Self {
380 api_base_url: inputs.base_url.clone(),
381 default_workspace: inputs.workspace.clone(),
382 default_assignee: inputs.assignee.clone(),
383 default_project: inputs.project.clone(),
384 personal_access_token: inputs.token.clone().map(|s| SecretString::new(s.into())),
385 }
386 }
387}
388
389#[derive(Clone)]
390struct ConfigPaths {
391 config_file: PathBuf,
392 data_dir: PathBuf,
393 cache_dir: PathBuf,
394}
395
396impl fmt::Debug for ConfigPaths {
397 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398 f.debug_struct("ConfigPaths")
399 .field("config_file", &self.config_file)
400 .field("data_dir", &self.data_dir)
401 .field("cache_dir", &self.cache_dir)
402 .finish()
403 }
404}
405
406fn resolve_paths(inputs: &EnvInputs) -> Result<ConfigPaths> {
407 let project_dirs = if let Some(base) = inputs.config_home.clone() {
408 ProjectDirs::from_path(base.clone()).ok_or_else(|| {
409 Error::ProjectDirs(format!(
410 "failed to construct project directories from {} (check {ENV_CONFIG_HOME})",
411 base.display()
412 ))
413 })?
414 } else {
415 ProjectDirs::from("com", "asana", "asana-cli").ok_or_else(|| {
416 Error::ProjectDirs("unable to resolve standard project directories".to_owned())
417 })?
418 };
419
420 let config_dir = inputs
421 .config_home
422 .clone()
423 .unwrap_or_else(|| project_dirs.config_dir().to_path_buf());
424
425 let data_dir = inputs
426 .data_home
427 .clone()
428 .unwrap_or_else(|| project_dirs.data_local_dir().to_path_buf());
429 let cache_dir = data_dir.join("cache");
430
431 Ok(ConfigPaths {
432 config_file: config_dir.join("config.toml"),
433 data_dir,
434 cache_dir,
435 })
436}
437
438fn read_config_file(path: &Path) -> Result<FileConfig> {
439 if !path.exists() {
440 debug!(config_path = %path.display(), "configuration file not found; using defaults");
441 return Ok(FileConfig::default());
442 }
443
444 let contents = fs::read_to_string(path)?;
445 let parsed: FileConfig = toml::from_str(&contents)?;
446 Ok(parsed)
447}
448
449#[cfg(test)]
450#[allow(unsafe_code)]
451mod tests {
452 use super::*;
453 use serial_test::serial;
454 use std::ffi::OsStr;
455 use tempfile::TempDir;
456
457 fn set_env<K: AsRef<OsStr>, V: AsRef<OsStr>>(key: K, value: V) {
458 unsafe {
459 env::set_var(key, value);
460 }
461 }
462
463 fn remove_env<K: AsRef<OsStr>>(key: K) {
464 unsafe {
465 env::remove_var(key);
466 }
467 }
468
469 fn with_temp_env<F>(config_home: &TempDir, data_home: &TempDir, f: F)
470 where
471 F: FnOnce(),
472 {
473 set_env(ENV_CONFIG_HOME, config_home.path());
474 set_env(ENV_DATA_HOME, data_home.path());
475 f();
476 remove_env(ENV_CONFIG_HOME);
477 remove_env(ENV_DATA_HOME);
478 remove_env(ENV_TOKEN);
479 remove_env(ENV_BASE_URL);
480 remove_env(ENV_WORKSPACE);
481 remove_env(ENV_ASSIGNEE);
482 remove_env(ENV_PROJECT);
483 }
484
485 #[test]
486 #[serial]
487 fn load_creates_directories_and_defaults() {
488 let config_home = TempDir::new().unwrap();
489 let data_home = TempDir::new().unwrap();
490
491 with_temp_env(&config_home, &data_home, || {
492 let cfg = Config::load(&EnvInputs::from_env()).expect("load config");
493 let parent = cfg.path().parent().expect("config path has parent");
494 assert!(parent.exists(), "config directory should exist");
495 assert!(
496 !cfg.path().exists(),
497 "config file should not be created automatically"
498 );
499 assert!(cfg.personal_access_token().is_none());
500 assert!(cfg.api_base_url().is_none());
501 });
502 }
503
504 #[test]
505 #[serial]
506 fn environment_overrides_take_precedence() {
507 let config_home = TempDir::new().unwrap();
508 let data_home = TempDir::new().unwrap();
509
510 with_temp_env(&config_home, &data_home, || {
511 set_env(ENV_BASE_URL, "https://override.example.com");
512 set_env(ENV_WORKSPACE, "workspace-123");
513 set_env(ENV_ASSIGNEE, "owner@example.com");
514 set_env(ENV_TOKEN, "env-token");
515
516 let mut cfg = Config::load(&EnvInputs::from_env()).expect("load config");
517 cfg.file_config_mut().api_base_url = Some("https://file.example.com".into());
518 cfg.file_config_mut().default_workspace = Some("workspace-456".into());
519 cfg.file_config_mut().default_assignee = Some("file@example.com".into());
520 cfg.file_config_mut().personal_access_token = Some("file-token".into());
521 cfg.save().expect("save config");
522
523 let cfg = Config::load(&EnvInputs::from_env()).expect("reload config");
524 assert_eq!(cfg.api_base_url(), Some("https://override.example.com"));
525 assert_eq!(cfg.default_workspace(), Some("workspace-123"));
526 assert_eq!(cfg.default_assignee(), Some("owner@example.com"));
527 let token = cfg.personal_access_token().expect("token present");
528 assert_eq!(token.expose_secret(), "env-token");
529 });
530 }
531
532 #[test]
533 #[serial]
534 fn token_round_trip_in_config_file() {
535 let config_home = TempDir::new().unwrap();
536 let data_home = TempDir::new().unwrap();
537
538 with_temp_env(&config_home, &data_home, || {
539 let mut cfg = Config::load(&EnvInputs::from_env()).expect("load config");
540 let token = SecretString::new("token-value".into());
541 cfg.store_personal_access_token(&token)
542 .expect("store token");
543
544 let reloaded = Config::load(&EnvInputs::from_env()).expect("reload config");
545 let loaded = reloaded.personal_access_token();
546 assert_eq!(
547 loaded.as_ref().map(|s| s.expose_secret().to_string()),
548 Some("token-value".into())
549 );
550 });
551 }
552
553 #[test]
554 #[serial]
555 fn default_assignee_round_trip() {
556 let config_home = TempDir::new().unwrap();
557 let data_home = TempDir::new().unwrap();
558
559 with_temp_env(&config_home, &data_home, || {
560 let mut cfg = Config::load(&EnvInputs::from_env()).expect("load config");
561 cfg.set_default_assignee(Some("user@example.com".into()))
562 .expect("store assignee");
563
564 let cfg = Config::load(&EnvInputs::from_env()).expect("reload config");
565 assert_eq!(cfg.default_assignee(), Some("user@example.com"));
566
567 let mut cfg = Config::load(&EnvInputs::from_env()).expect("load config to clear");
568 cfg.set_default_assignee(None).expect("clear assignee");
569 let cfg = Config::load(&EnvInputs::from_env()).expect("reload after clear");
570 assert!(cfg.default_assignee().is_none());
571 });
572 }
573}