Skip to main content

auths_cli/factories/
mod.rs

1pub mod storage;
2
3use std::io::IsTerminal;
4use std::sync::Arc;
5
6use anyhow::Result;
7
8use auths_sdk::core_config::{EnvironmentConfig, load_config};
9use auths_sdk::keychain::{get_passphrase_cache, parse_duration_str};
10use auths_sdk::paths::auths_home;
11use auths_sdk::ports::agent::AgentSigningPort;
12use auths_sdk::signing::{KeychainPassphraseProvider, PassphraseProvider};
13use auths_telemetry::TelemetryShutdown;
14use auths_telemetry::config::{build_sinks_from_config, load_audit_config};
15use auths_telemetry::sinks::composite::CompositeSink;
16
17use crate::adapters::config_store::FileConfigStore;
18use crate::cli::AuthsCli;
19use crate::config::{CliConfig, OutputFormat};
20use crate::core::provider::{CliPassphraseProvider, PrefilledPassphraseProvider};
21
22/// Builds the full CLI configuration from parsed arguments.
23///
24/// Constructs the passphrase provider and output settings.
25/// This is the composition root — the only place where concrete adapter
26/// types are instantiated.
27///
28/// Args:
29/// * `cli`: The parsed CLI arguments.
30///
31/// Usage:
32/// ```ignore
33/// use auths_cli::factories::build_config;
34///
35/// let cli = AuthsCli::parse();
36/// let config = build_config(&cli)?;
37/// ```
38pub fn build_config(cli: &AuthsCli) -> Result<CliConfig> {
39    let output_format = if cli.json {
40        OutputFormat::Json
41    } else {
42        OutputFormat::Text
43    };
44
45    let env_config = EnvironmentConfig::from_env();
46
47    let passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync> =
48        if let Some(passphrase) = env_config.keychain.passphrase.clone() {
49            Arc::new(PrefilledPassphraseProvider::new(zeroize::Zeroizing::new(
50                passphrase,
51            )))
52        } else {
53            let config = load_config(&FileConfigStore);
54            let cache = get_passphrase_cache(config.passphrase.biometric);
55            let ttl_secs = config
56                .passphrase
57                .duration
58                .as_deref()
59                .and_then(parse_duration_str);
60            let inner = Arc::new(CliPassphraseProvider::new());
61            Arc::new(KeychainPassphraseProvider::new(
62                inner,
63                cache,
64                "default".to_string(),
65                config.passphrase.cache,
66                ttl_secs,
67            ))
68        };
69
70    let is_interactive = std::io::stdout().is_terminal();
71
72    // One storage root for every subcommand: --repo, then AUTHS_REPO (which a
73    // headless CI step exports from `init --profile ci`), then AUTHS_HOME as a
74    // deprecated alias — honored with a warning rather than silently ignored, so
75    // `verify`/`doctor` never read a different root than `init`/`sign` wrote.
76    let repo_path = resolve_repo_root(cli.repo.clone());
77
78    Ok(CliConfig {
79        repo_path,
80        output_format,
81        is_interactive,
82        passphrase_provider,
83        env_config,
84    })
85}
86
87/// Resolve the single storage-root override shared by every subcommand.
88///
89/// Precedence: an explicit `--repo`, then `AUTHS_REPO`, then `AUTHS_HOME` as a
90/// deprecated alias (warned, never silently dropped). Returns `None` when none is
91/// set, leaving callers on the default `~/.auths`.
92///
93/// Args:
94/// * `repo_flag`: the value of the global `--repo` flag, if the user passed one.
95///
96/// Usage:
97/// ```ignore
98/// let repo_path = resolve_repo_root(cli.repo.clone());
99/// ```
100fn resolve_repo_root(repo_flag: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
101    #[allow(clippy::disallowed_methods)] // CLI boundary: storage-root env resolution
102    let auths_repo = std::env::var("AUTHS_REPO").ok().filter(|s| !s.is_empty());
103    #[allow(clippy::disallowed_methods)] // CLI boundary: storage-root env resolution
104    let auths_home = std::env::var("AUTHS_HOME").ok().filter(|s| !s.is_empty());
105
106    let (root, via_home_alias) = pick_repo_root(repo_flag, auths_repo, auths_home);
107    if via_home_alias {
108        eprintln!(
109            "warning: AUTHS_HOME is deprecated; prefer AUTHS_REPO to override the storage root"
110        );
111    }
112    root
113}
114
115/// Apply the storage-root precedence: `--repo` > `AUTHS_REPO` > `AUTHS_HOME`.
116///
117/// Returns the resolved root (if any) and whether it came from the deprecated
118/// `AUTHS_HOME` alias, so the caller can warn without the env read living inside
119/// the tested logic.
120///
121/// Args:
122/// * `repo_flag`: the `--repo` value, if given.
123/// * `auths_repo`: a non-empty `AUTHS_REPO`, if set.
124/// * `auths_home`: a non-empty `AUTHS_HOME`, if set.
125///
126/// Usage:
127/// ```ignore
128/// let (root, via_home) = pick_repo_root(None, None, Some("/x".into()));
129/// ```
130fn pick_repo_root(
131    repo_flag: Option<std::path::PathBuf>,
132    auths_repo: Option<String>,
133    auths_home: Option<String>,
134) -> (Option<std::path::PathBuf>, bool) {
135    if let Some(flag) = repo_flag {
136        return (Some(flag), false);
137    }
138    if let Some(repo) = auths_repo {
139        return (Some(std::path::PathBuf::from(repo)), false);
140    }
141    match auths_home {
142        Some(home) => (Some(std::path::PathBuf::from(home)), true),
143        None => (None, false),
144    }
145}
146
147/// Loads audit sinks from `~/.auths/audit.toml` and initialises the global
148/// telemetry pipeline.
149///
150/// Returns `None` when no sinks are configured — zero overhead in that case.
151///
152/// Usage:
153/// ```ignore
154/// let _telemetry = auths_cli::factories::init_audit_sinks();
155/// ```
156pub fn init_audit_sinks() -> Option<TelemetryShutdown> {
157    let audit_path = match auths_home() {
158        Ok(h) => h.join("audit.toml"),
159        Err(_) => return None,
160    };
161    let config = load_audit_config(&audit_path);
162    #[allow(clippy::disallowed_methods)] // CLI boundary: audit config reads env vars
163    let sinks = build_sinks_from_config(&config, |name| std::env::var(name).ok());
164    if sinks.is_empty() {
165        return None;
166    }
167    let composite = Arc::new(CompositeSink::new(sinks));
168    Some(auths_telemetry::init_telemetry_with_sink(composite))
169}
170
171/// Build the platform-appropriate agent signing provider.
172///
173/// Returns `CliAgentAdapter` on Unix, `NoopAgentProvider` elsewhere.
174///
175/// Usage:
176/// ```ignore
177/// let agent = build_agent_provider();
178/// let ctx = CommitSigningContext { agent_signing: agent, .. };
179/// ```
180pub fn build_agent_provider() -> Arc<dyn AgentSigningPort + Send + Sync> {
181    #[cfg(unix)]
182    {
183        Arc::new(crate::adapters::agent::CliAgentAdapter)
184    }
185    #[cfg(not(unix))]
186    {
187        Arc::new(auths_sdk::ports::agent::NoopAgentProvider)
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::pick_repo_root;
194    use std::path::PathBuf;
195
196    #[test]
197    fn repo_flag_wins_over_both_env_vars() {
198        let (root, via_home) = pick_repo_root(
199            Some(PathBuf::from("/flag")),
200            Some("/repo".into()),
201            Some("/home".into()),
202        );
203        assert_eq!(root, Some(PathBuf::from("/flag")));
204        assert!(!via_home);
205    }
206
207    #[test]
208    fn auths_repo_wins_over_auths_home() {
209        let (root, via_home) = pick_repo_root(None, Some("/repo".into()), Some("/home".into()));
210        assert_eq!(root, Some(PathBuf::from("/repo")));
211        assert!(!via_home);
212    }
213
214    #[test]
215    fn auths_home_populates_repo_path_with_warning() {
216        // AUTHS_HOME must feed the shared storage root (with a deprecation warning),
217        // not be silently ignored — otherwise verify/doctor read a different root
218        // than init/sign wrote.
219        let (root, via_home) = pick_repo_root(None, None, Some("/home".into()));
220        assert_eq!(root, Some(PathBuf::from("/home")));
221        assert!(via_home, "AUTHS_HOME must trigger the deprecation warning");
222    }
223
224    #[test]
225    fn no_override_leaves_default_root() {
226        let (root, via_home) = pick_repo_root(None, None, None);
227        assert_eq!(root, None);
228        assert!(!via_home);
229    }
230}