1use std::collections::HashMap;
7use std::sync::{Mutex, OnceLock};
8
9use indexmap::IndexMap;
10
11pub type ProviderEnv = IndexMap<String, String>;
13
14enum Part {
15 Literal(String),
16 Env(String),
17}
18
19fn is_name_start(c: char) -> bool {
20 c.is_ascii_alphabetic() || c == '_'
21}
22
23fn is_name(c: char) -> bool {
24 c.is_ascii_alphanumeric() || c == '_'
25}
26
27fn push_literal(parts: &mut Vec<Part>, text: &str) {
28 if text.is_empty() {
29 return;
30 }
31 if let Some(Part::Literal(previous)) = parts.last_mut() {
32 previous.push_str(text);
33 } else {
34 parts.push(Part::Literal(text.to_owned()));
35 }
36}
37
38fn parse_template(config: &str) -> Vec<Part> {
39 let mut parts = Vec::new();
40 let mut rest = config;
41 while let Some(dollar) = rest.find('$') {
42 push_literal(&mut parts, &rest[..dollar]);
43 let after = &rest[dollar + 1..];
44 match after.chars().next() {
45 Some(c @ ('$' | '!')) => {
46 push_literal(&mut parts, &c.to_string());
47 rest = &after[1..];
48 }
49 Some('{') => match after.find('}') {
50 None => {
51 push_literal(&mut parts, "$");
52 rest = after;
53 }
54 Some(end) => {
55 let name = &after[1..end];
56 let valid =
57 name.chars().next().is_some_and(is_name_start) && name.chars().all(is_name);
58 if valid {
59 parts.push(Part::Env(name.to_owned()));
60 } else {
61 push_literal(&mut parts, &rest[dollar..dollar + end + 2]);
62 }
63 rest = &after[end + 1..];
64 }
65 },
66 Some(c) if is_name_start(c) => {
67 let length = after.find(|c: char| !is_name(c)).unwrap_or(after.len());
68 parts.push(Part::Env(after[..length].to_owned()));
69 rest = &after[length..];
70 }
71 _ => {
72 push_literal(&mut parts, "$");
73 rest = after;
74 }
75 }
76 }
77 push_literal(&mut parts, rest);
78 parts
79}
80
81pub fn provider_env_value(name: &str, env: Option<&ProviderEnv>) -> Option<String> {
84 env.and_then(|env| env.get(name))
85 .filter(|value| !value.is_empty())
86 .cloned()
87 .or_else(|| std::env::var(name).ok().filter(|value| !value.is_empty()))
88}
89
90pub fn is_command(config: &str) -> bool {
92 config.starts_with('!')
93}
94
95pub fn env_var_names(config: &str) -> Vec<String> {
97 if is_command(config) {
98 return Vec::new();
99 }
100 let mut names = Vec::new();
101 for part in parse_template(config) {
102 if let Part::Env(name) = part
103 && !names.contains(&name)
104 {
105 names.push(name);
106 }
107 }
108 names
109}
110
111pub fn missing_env_vars(config: &str, env: Option<&ProviderEnv>) -> Vec<String> {
113 env_var_names(config)
114 .into_iter()
115 .filter(|name| provider_env_value(name, env).is_none())
116 .collect()
117}
118
119pub fn unresolved_message(config: &str, description: &str, env: Option<&ProviderEnv>) -> String {
122 if let Some(command) = config.strip_prefix('!') {
123 return format!("Failed to resolve {description} from shell command: {command}");
124 }
125 match missing_env_vars(config, env).as_slice() {
126 [] => format!("Failed to resolve {description}"),
127 [name] => format!("Failed to resolve {description} from environment variable: {name}"),
128 names => format!(
129 "Failed to resolve {description} from environment variables: {}",
130 names.join(", ")
131 ),
132 }
133}
134
135pub fn is_configured(config: &str, env: Option<&ProviderEnv>) -> bool {
137 env_var_names(config)
138 .iter()
139 .all(|name| provider_env_value(name, env).is_some())
140}
141
142pub async fn resolve(config: &str, env: Option<&ProviderEnv>, cache: bool) -> Option<String> {
145 if let Some(command) = config.strip_prefix('!') {
146 return run_command(command, cache).await;
147 }
148 resolve_template(config, env)
149}
150
151pub fn resolve_template(config: &str, env: Option<&ProviderEnv>) -> Option<String> {
154 if is_command(config) {
155 return None;
156 }
157 let mut resolved = String::new();
158 for part in parse_template(config) {
159 match part {
160 Part::Literal(text) => resolved.push_str(&text),
161 Part::Env(name) => resolved.push_str(&provider_env_value(&name, env)?),
162 }
163 }
164 Some(resolved)
165}
166
167async fn run_command(command: &str, cache: bool) -> Option<String> {
168 static CACHE: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
169 let cache_map = CACHE.get_or_init(Default::default);
170 if cache
171 && let Some(value) = cache_map
172 .lock()
173 .ok()
174 .and_then(|map| map.get(command).cloned())
175 {
176 return value;
177 }
178 let output = tokio::time::timeout(
179 std::time::Duration::from_secs(10),
180 tokio::process::Command::new("sh")
181 .arg("-c")
182 .arg(command)
183 .stdin(std::process::Stdio::null())
184 .stderr(std::process::Stdio::null())
185 .kill_on_drop(true)
186 .output(),
187 )
188 .await;
189 let value = match output {
190 Ok(Ok(output)) if output.status.success() => {
191 let text = String::from_utf8_lossy(&output.stdout).trim().to_owned();
192 (!text.is_empty()).then_some(text)
193 }
194 _ => None,
195 };
196 if cache && let Ok(mut map) = cache_map.lock() {
197 map.insert(command.to_owned(), value.clone());
198 }
199 value
200}
201
202pub fn api_key_env_vars(provider: &str) -> &'static [&'static str] {
206 crate::providers::info(provider)
207 .and_then(|info| info.api_key)
208 .map_or(&[], |method| method.env)
209}
210
211pub const BEARER_TOKEN_ENV: &str = "ANTHROPIC_AUTH_TOKEN";
213
214pub const AMBIENT_CREDENTIALS: &str = "<authenticated>";
216
217fn has_vertex_adc_credentials(env: Option<&ProviderEnv>) -> bool {
220 match provider_env_value("GOOGLE_APPLICATION_CREDENTIALS", env) {
221 Some(path) => std::path::Path::new(&path).exists(),
222 None => std::env::var_os("HOME")
224 .map(std::path::PathBuf::from)
225 .or_else(std::env::home_dir)
226 .is_some_and(|home| {
227 home.join(".config/gcloud/application_default_credentials.json")
228 .exists()
229 }),
230 }
231}
232
233fn ambient_credentials(provider: &str, env: Option<&ProviderEnv>) -> Option<&'static str> {
238 let set = |name: &str| provider_env_value(name, env).is_some();
239 match provider {
240 "google-vertex" => (has_vertex_adc_credentials(env)
241 && (set("GOOGLE_CLOUD_PROJECT") || set("GCLOUD_PROJECT"))
242 && set("GOOGLE_CLOUD_LOCATION"))
243 .then_some("GOOGLE_CLOUD_LOCATION"),
244 "amazon-bedrock" => {
245 if set("AWS_PROFILE") {
246 Some("AWS_PROFILE")
247 } else if set("AWS_ACCESS_KEY_ID") && set("AWS_SECRET_ACCESS_KEY") {
248 Some("AWS_ACCESS_KEY_ID")
249 } else {
250 [
251 "AWS_BEARER_TOKEN_BEDROCK",
252 "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
253 "AWS_CONTAINER_CREDENTIALS_FULL_URI",
254 "AWS_WEB_IDENTITY_TOKEN_FILE",
255 ]
256 .into_iter()
257 .find(|name| set(name))
258 }
259 }
260 _ => None,
261 }
262}
263
264pub fn env_api_key(provider: &str, env: Option<&ProviderEnv>) -> Option<(&'static str, String)> {
268 api_key_env_vars(provider)
269 .iter()
270 .find_map(|name| provider_env_value(name, env).map(|value| (*name, value)))
271 .or_else(|| {
272 ambient_credentials(provider, env).map(|name| (name, AMBIENT_CREDENTIALS.to_owned()))
273 })
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[tokio::test]
281 async fn resolves_templates_and_commands() {
282 let mut env = ProviderEnv::new();
283 env.insert("YAPI_TEST_A".into(), "a".into());
284 assert_eq!(
285 resolve("x-$YAPI_TEST_A-${YAPI_TEST_A}$$!", Some(&env), false)
286 .await
287 .as_deref(),
288 Some("x-a-a$!")
289 );
290 assert_eq!(
291 resolve("$YAPI_TEST_UNSET_VALUE", Some(&env), false).await,
292 None
293 );
294 assert_eq!(
295 resolve("${not valid}", None, false).await.as_deref(),
296 Some("${not valid}")
297 );
298 assert_eq!(resolve("!echo hi", None, true).await.as_deref(), Some("hi"));
299 assert_eq!(resolve("!exit 1", None, false).await, None);
300 assert_eq!(env_var_names("$A and ${B} $A"), ["A", "B"]);
301 assert!(!is_configured("$YAPI_TEST_UNSET_VALUE", None));
302 let mut aws = ProviderEnv::new();
303 aws.insert("AWS_ACCESS_KEY_ID".into(), "id".into());
304 aws.insert("AWS_SECRET_ACCESS_KEY".into(), "secret".into());
305 assert_eq!(
306 env_api_key("amazon-bedrock", Some(&aws)).map(|(_, key)| key),
307 Some(AMBIENT_CREDENTIALS.to_owned())
308 );
309 }
310
311 #[cfg(unix)]
312 #[tokio::test(start_paused = true)]
313 async fn command_killed_on_timeout() {
314 let marker = std::env::temp_dir().join(format!("yapi-command-{}", std::process::id()));
315 let _ = std::fs::remove_file(&marker);
316 let command = format!("!sleep 1; touch '{}'", marker.display());
318 assert_eq!(resolve(&command, None, false).await, None);
319 std::thread::sleep(std::time::Duration::from_millis(1500));
320 assert!(!marker.exists());
321 }
322}