use std::path::PathBuf;
use indexmap::IndexMap;
use super::AwsEnv;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Profiles {
pub profiles: IndexMap<String, IndexMap<String, String>>,
pub sso_sessions: IndexMap<String, IndexMap<String, String>>,
}
fn parse(text: &str) -> IndexMap<String, IndexMap<String, String>> {
let mut sections: IndexMap<String, IndexMap<String, String>> = IndexMap::new();
let mut current: Option<String> = None;
let mut parent: Option<String> = None;
for line in text.lines() {
let trimmed = without_comment(line).trim();
if trimmed.is_empty() {
continue;
}
if let Some(header) = trimmed
.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
{
let name = header.trim().to_owned();
sections.entry(name.clone()).or_default();
current = Some(name);
parent = None;
continue;
}
let Some(section) = ¤t else {
continue;
};
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
let (key, value) = (key.trim(), value.trim());
let indented = line.starts_with([' ', '\t']);
let entries = sections.entry(section.clone()).or_default();
if indented && let Some(parent) = &parent {
entries.insert(format!("{parent}.{key}"), value.to_owned());
} else if value.is_empty() {
parent = Some(key.to_owned());
} else {
parent = None;
entries.insert(key.to_owned(), value.to_owned());
}
}
sections
}
fn without_comment(line: &str) -> &str {
let mut after_space = true;
for (index, c) in line.char_indices() {
if after_space && matches!(c, '#' | ';') {
return &line[..index];
}
after_space = c.is_whitespace();
}
line
}
fn expand(path: &str, env: &AwsEnv) -> PathBuf {
match path
.strip_prefix("~/")
.or_else(|| (path == "~").then_some(""))
{
Some(rest) => home(env).join(rest),
None => PathBuf::from(path),
}
}
pub fn home(env: &AwsEnv) -> PathBuf {
env.get("HOME")
.or_else(|| env.get("USERPROFILE"))
.map_or_else(PathBuf::new, PathBuf::from)
}
pub fn config_path(env: &AwsEnv) -> PathBuf {
expand(env.get("AWS_CONFIG_FILE").unwrap_or("~/.aws/config"), env)
}
pub fn credentials_path(env: &AwsEnv) -> PathBuf {
expand(
env.get("AWS_SHARED_CREDENTIALS_FILE")
.unwrap_or("~/.aws/credentials"),
env,
)
}
pub async fn load(env: &AwsEnv) -> Profiles {
let read = |path: PathBuf| async move {
tokio::fs::read_to_string(path)
.await
.map(|text| parse(&text))
.unwrap_or_default()
};
let config = read(config_path(env)).await;
let credentials = read(credentials_path(env)).await;
merge(config, credentials)
}
fn merge(
config: IndexMap<String, IndexMap<String, String>>,
credentials: IndexMap<String, IndexMap<String, String>>,
) -> Profiles {
let mut profiles = Profiles::default();
for (header, values) in config {
let header = header.split_whitespace().collect::<Vec<_>>();
match header.as_slice() {
["default"] => {
profiles
.profiles
.entry("default".into())
.or_default()
.extend(values);
}
["profile", name] => {
profiles
.profiles
.entry((*name).to_owned())
.or_default()
.extend(values);
}
["sso-session", name] => {
profiles.sso_sessions.insert((*name).to_owned(), values);
}
_ => {}
}
}
for (name, values) in credentials {
profiles
.profiles
.entry(name.trim().to_owned())
.or_default()
.extend(values);
}
profiles
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merges_config_and_credentials() {
let config = parse(
"[default]\nregion = us-west-2\n\n[profile dev]\nrole_arn = arn:aws:iam::1:role/r\nsource_profile = default\ns3 =\n max_concurrent_requests = 10\n[sso-session corp]\nsso_region = eu-west-1\n[ignored]\nx = 1\n",
);
let credentials = parse(
"# keys\n[default]\naws_access_key_id = AKID\naws_secret_access_key = secret\nregion = eu-central-1\n[prod] # production account\nregion = eu-west-1 # Ireland\nsecret = a#b;c\n ; indented\n",
);
let profiles = merge(config, credentials);
let default = &profiles.profiles["default"];
assert_eq!(default["region"], "eu-central-1");
assert_eq!(default["aws_access_key_id"], "AKID");
assert_eq!(profiles.profiles["dev"]["source_profile"], "default");
assert_eq!(profiles.profiles["dev"]["s3.max_concurrent_requests"], "10");
assert_eq!(profiles.sso_sessions["corp"]["sso_region"], "eu-west-1");
assert_eq!(profiles.profiles["prod"]["region"], "eu-west-1");
assert_eq!(profiles.profiles["prod"]["secret"], "a#b;c");
assert!(!profiles.profiles.contains_key("ignored"));
}
}