Skip to main content

asana_cli/
config.rs

1//! Configuration management utilities for the Asana CLI.
2//!
3//! Phase 1 establishes the persistent configuration surface and token storage.
4//! Subsequent phases will expand the persisted settings and runtime validation.
5
6use 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";
26/// Default Asana API base URL when no override is provided.
27pub const DEFAULT_API_BASE_URL: &str = "https://app.asana.com/api/1.0";
28
29/// Environment-derived configuration inputs, read once at the CLI entry edge.
30///
31/// Per `REPO_INVARIANTS.md` #5 the environment is read at the edge and threaded
32/// inward as typed values; [`Config::load`] consumes this rather than reading
33/// `std::env` itself.
34#[derive(Clone, Debug, Default)]
35pub struct EnvInputs {
36    /// Personal access token (`ASANA_PAT`).
37    pub token: Option<String>,
38    /// API base URL override (`ASANA_BASE_URL`).
39    pub base_url: Option<String>,
40    /// Default workspace (`ASANA_WORKSPACE`).
41    pub workspace: Option<String>,
42    /// Default assignee (`ASANA_ASSIGNEE`).
43    pub assignee: Option<String>,
44    /// Default project (`ASANA_PROJECT`).
45    pub project: Option<String>,
46    /// Config-home override (`ASANA_CLI_CONFIG_HOME`).
47    pub config_home: Option<PathBuf>,
48    /// Data-home override (`ASANA_CLI_DATA_HOME`).
49    pub data_home: Option<PathBuf>,
50}
51
52impl EnvInputs {
53    /// Read the Asana configuration environment at the CLI entry edge.
54    #[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/// Persisted configuration document.
73#[derive(Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
74#[serde(default)]
75pub struct FileConfig {
76    /// Optional custom API base URL for private deployments.
77    pub api_base_url: Option<String>,
78    /// Preferred default workspace identifier.
79    pub default_workspace: Option<String>,
80    /// Preferred default assignee identifier (email or gid).
81    pub default_assignee: Option<String>,
82    /// Preferred default project identifier.
83    pub default_project: Option<String>,
84    /// Stored Personal Access Token (if persisted on disk).
85    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
103/// Runtime configuration including environment overrides and persisted settings.
104pub struct Config {
105    file: FileConfig,
106    overrides: Overrides,
107    paths: ConfigPaths,
108}
109
110impl Config {
111    /// Load configuration from disk and environment.
112    ///
113    /// # Errors
114    /// Returns an error if configuration directories cannot be created or files cannot be read.
115    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    /// Return the path to the configuration file.
133    #[must_use]
134    pub fn path(&self) -> &Path {
135        &self.paths.config_file
136    }
137
138    /// Directory used for API response caching.
139    #[must_use]
140    pub fn cache_dir(&self) -> &Path {
141        &self.paths.cache_dir
142    }
143
144    /// Directory used for persistent data assets (templates, filters, etc.).
145    #[must_use]
146    pub fn data_dir(&self) -> &Path {
147        &self.paths.data_dir
148    }
149
150    /// Directory where user templates are stored.
151    #[must_use]
152    pub fn templates_dir(&self) -> PathBuf {
153        self.paths.data_dir.join("templates")
154    }
155
156    /// Directory storing reusable filter definitions.
157    #[must_use]
158    pub fn filters_dir(&self) -> PathBuf {
159        self.paths.data_dir.join("filters")
160    }
161
162    /// Return the computed API base URL, considering environment overrides.
163    #[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    /// Return the effective API base URL, falling back to the default value.
172    #[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    /// Return the default workspace identifier.
178    #[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    /// Update the stored default workspace identifier.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if the configuration file cannot be saved to disk.
191    pub fn set_default_workspace(&mut self, workspace: Option<String>) -> Result<()> {
192        self.file.default_workspace = workspace;
193        self.save()
194    }
195
196    /// Return the default assignee identifier.
197    #[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    /// Update the stored default assignee identifier.
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if the configuration file cannot be saved to disk.
210    pub fn set_default_assignee(&mut self, assignee: Option<String>) -> Result<()> {
211        self.file.default_assignee = assignee;
212        self.save()
213    }
214
215    /// Return the default project identifier.
216    #[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    /// Update the stored default project identifier.
225    ///
226    /// # Errors
227    ///
228    /// Returns an error if the configuration file cannot be saved to disk.
229    pub fn set_default_project(&mut self, project: Option<String>) -> Result<()> {
230        self.file.default_project = project;
231        self.save()
232    }
233
234    /// Persist the in-memory configuration to disk.
235    ///
236    /// # Errors
237    /// Returns an error when the configuration cannot be encoded or written to disk.
238    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    /// Store the provided Personal Access Token in the configuration file.
251    ///
252    /// # Errors
253    /// Returns an error if the configuration file cannot be updated.
254    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    /// Retrieve the Personal Access Token, taking environment overrides into account.
262    #[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    /// Remove any stored Personal Access Token.
277    ///
278    /// # Errors
279    /// Returns an error when stored secrets cannot be removed.
280    pub fn delete_personal_access_token(&mut self) -> Result<()> {
281        self.file.personal_access_token = None;
282        self.save()
283    }
284
285    /// Determine whether a token is persisted in the configuration file.
286    #[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    /// Determine whether a token is provided by environment overrides.
295    #[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    /// Expose the underlying file configuration for mutation.
304    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}