use anyhow::Result;
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
pub const KNOWN_KEYS: &[(&str, &str)] = &[
("GEMINI_API_KEY", "Gemini API key — images and Veo video"),
("BFL_API_KEY", "Black Forest Labs API key (hosted FLUX)"),
("STABILITY_API_KEY", "Stability AI developer platform key"),
("OPENAI_API_KEY", "OpenAI API key"),
("RUNWAY_API_KEY", "Runway API key (Gen-4 video)"),
("KLINGAI_API_KEY", "Kling API key (video) — the single-key scheme, not AK/SK"),
("LUCIDA_COMFYUI_URL", "Where ComfyUI is listening"),
("LUCIDA_COMFYUI_AUTH", "ComfyUI credentials, if it is fenced"),
("LUCIDA_COMFYUI_CA", "PEM certificate for a private CA"),
(
"LUCIDA_IMAGE_PROVIDERS",
"Ordered image providers to default to, comma-separated (e.g. bfl,google)",
),
(
"LUCIDA_VIDEO_PROVIDERS",
"Ordered video providers to default to, comma-separated (e.g. runway,google)",
),
(
"LUCIDA_NO_UPDATE_CHECK",
"Set to silence the daily \"a newer release exists\" notice",
),
(
"LUCIDA_NO_LEDGER",
"Set to stop recording renders (the ledger stores your prompts)",
),
(
"LUCIDA_BUDGET",
"Dollars of estimated spend allowed per rolling 24 hours",
),
];
pub const RETIRED_KEYS: &[(&str, &str)] = &[("GOOGLE_API_KEY", "GEMINI_API_KEY")];
pub fn replacement_for(name: &str) -> Option<&'static str> {
RETIRED_KEYS
.iter()
.find(|(old, _)| *old == name)
.map(|(_, new)| *new)
}
pub fn retired_in_use() -> Vec<(&'static str, &'static str)> {
RETIRED_KEYS
.iter()
.filter(|(old, new)| origin(old).is_some() && var(new).is_none())
.copied()
.collect()
}
pub fn keys_in_file() -> Vec<String> {
let mut names: Vec<String> = loaded().values.keys().cloned().collect();
names.sort();
names
}
struct Loaded {
path: Option<PathBuf>,
values: HashMap<String, String>,
}
static LOADED: OnceLock<Loaded> = OnceLock::new();
pub fn var(name: &str) -> Option<String> {
if let Some(value) = loaded().values.get(name) {
return Some(value.clone());
}
std::env::var(name).ok().filter(|v| !v.trim().is_empty())
}
#[derive(Debug, PartialEq, Eq)]
pub enum Origin {
File,
Environment,
FileOverridingEnvironment,
}
pub fn origin(name: &str) -> Option<Origin> {
origin_of(
loaded().values.contains_key(name),
std::env::var(name).is_ok_and(|v| !v.trim().is_empty()),
)
}
fn origin_of(in_file: bool, in_env: bool) -> Option<Origin> {
match (in_file, in_env) {
(true, true) => Some(Origin::FileOverridingEnvironment),
(true, false) => Some(Origin::File),
(false, true) => Some(Origin::Environment),
(false, false) => None,
}
}
fn loaded() -> &'static Loaded {
LOADED.get_or_init(|| {
for path in search_paths() {
if path.is_file() {
let values = match std::fs::read_to_string(&path) {
Ok(text) => parse(&text),
Err(e) => {
eprintln!("warning: could not read {}: {e}", path.display());
continue;
}
};
warn_if_readable_by_others(&path);
return Loaded {
path: Some(path),
values,
};
}
}
Loaded {
path: None,
values: HashMap::new(),
}
})
}
pub fn source() -> Option<&'static Path> {
loaded().path.as_deref()
}
pub fn search_paths() -> Vec<PathBuf> {
if let Some(explicit) = std::env::var("LUCIDA_CONFIG")
.ok()
.filter(|p| !p.trim().is_empty())
{
return vec![PathBuf::from(explicit)];
}
let mut paths = Vec::new();
if let Some(base) = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.or_else(|| home().map(|home| home.join(".config")))
{
paths.push(base.join("lucida").join("config.env"));
}
#[cfg(target_os = "macos")]
if let Some(home) = home() {
paths.push(
home.join("Library")
.join("Application Support")
.join("lucida")
.join("config.env"),
);
}
#[cfg(target_os = "windows")]
if let Some(appdata) = std::env::var_os("APPDATA")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
{
paths.push(appdata.join("lucida").join("config.env"));
}
paths
}
pub fn preferred_path() -> Option<PathBuf> {
search_paths().into_iter().next()
}
fn home() -> Option<PathBuf> {
home_from(std::env::var_os("HOME"), std::env::var_os("USERPROFILE"))
}
fn home_from(home: Option<OsString>, user_profile: Option<OsString>) -> Option<PathBuf> {
[home, user_profile]
.into_iter()
.flatten()
.map(PathBuf::from)
.find(|p| !p.as_os_str().is_empty())
}
fn parse(text: &str) -> HashMap<String, String> {
let mut values = HashMap::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line).trim_start();
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
if key.is_empty() {
continue;
}
let value = value.trim();
let value = value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
.unwrap_or(value);
if !value.is_empty() {
values.insert(key.to_string(), value.to_string());
}
}
values
}
#[cfg(unix)]
fn warn_if_readable_by_others(path: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = std::fs::metadata(path) {
let mode = metadata.permissions().mode();
if mode & 0o077 != 0 {
eprintln!(
"warning: {} is readable by other users (mode {:o}). It may hold an \
API key — consider `chmod 600 {}`.",
path.display(),
mode & 0o777,
path.display()
);
}
}
}
#[cfg(not(unix))]
fn warn_if_readable_by_others(_path: &Path) {}
pub fn write_replacing(path: &Path, body: &str, private: bool) -> Result<()> {
crate::write_atomically(path, body.as_bytes(), private)
}
#[cfg(unix)]
pub fn restrict_to_owner(path: &Path) -> Result<()> {
use anyhow::Context;
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("restricting permissions on {}", path.display()))
}
#[cfg(not(unix))]
pub fn restrict_to_owner(_path: &Path) -> Result<()> {
Ok(())
}
pub fn template() -> String {
let mut text = String::from(
"# Lucida settings.\n\
#\n\
# A value here takes precedence over the same name in the environment,\n\
# so this is where a key scoped to Lucida goes when your shell already\n\
# exports a broader one. It is also the only place a GUI-launched MCP\n\
# client can find a key at all, since it inherits no shell.\n\
#\n\
# `lucida config` reports which source each setting is coming from.\n\
#\n\
# Keep this file private: chmod 600\n\n",
);
for (key, purpose) in KNOWN_KEYS {
text.push_str(&format!("# {purpose}\n#{key}=\n\n"));
}
text
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_forms_a_shell_profile_produces() {
let values = parse(
"# a comment\n\
\n\
GOOGLE_API_KEY=plain\n\
export LUCIDA_COMFYUI_URL=\"https://host:8188\"\n\
LUCIDA_COMFYUI_AUTH = 'bob:hunter2' \n\
MALFORMED\n\
EMPTY=\n",
);
assert_eq!(values.get("GOOGLE_API_KEY").unwrap(), "plain");
assert_eq!(values.get("LUCIDA_COMFYUI_URL").unwrap(), "https://host:8188");
assert_eq!(values.get("LUCIDA_COMFYUI_AUTH").unwrap(), "bob:hunter2");
assert!(!values.contains_key("MALFORMED"));
assert!(!values.contains_key("EMPTY"));
}
#[test]
fn a_retired_name_reports_its_replacement() {
assert_eq!(replacement_for("GOOGLE_API_KEY"), Some("GEMINI_API_KEY"));
assert_eq!(replacement_for("GEMINI_API_KEY"), None);
assert_eq!(replacement_for("BFL_API_KEY"), None);
for (retired, _) in RETIRED_KEYS {
assert!(
!KNOWN_KEYS.iter().any(|(known, _)| known == retired),
"{retired} is both retired and current"
);
}
}
#[test]
fn the_readme_lists_every_setting() {
let readme = std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md"),
)
.expect("README.md must exist");
let marker = "<!-- SETTINGS TABLE:";
let start = readme.find(marker).expect(
"the settings table has lost its marker comment, so this test is \
no longer checking anything",
);
let table: String = readme[start..]
.lines()
.skip_while(|line| !line.starts_with('|'))
.take_while(|line| line.starts_with('|'))
.collect::<Vec<_>>()
.join("\n");
for (key, _) in KNOWN_KEYS {
assert!(
table.contains(&format!("`{key}`")),
"{key} is a real setting and the README's table does not list it"
);
}
let rows = table
.lines()
.skip_while(|line| !line.starts_with("|---"))
.skip(1);
for line in rows.filter(|l| l.starts_with("| `")) {
let name = line
.trim_start_matches("| `")
.split('`')
.next()
.unwrap_or_default();
assert!(
KNOWN_KEYS.iter().any(|(known, _)| *known == name),
"the README's settings table lists `{name}`, which Lucida does not read"
);
}
}
#[test]
fn every_setting_read_is_a_known_key() {
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut checked: Vec<String> = Vec::new();
for entry in std::fs::read_dir(&src).expect("src/ must be readable") {
let path = entry.unwrap().path();
if path.extension().is_none_or(|e| e != "rs") {
continue;
}
let body = std::fs::read_to_string(&path).unwrap();
let call = concat!("config::", "var(\"");
for (offset, _) in body.match_indices(call) {
let rest = &body[offset + call.len()..];
let name = &rest[..rest.find('"').expect("an unterminated string literal")];
checked.push(name.to_string());
assert!(
KNOWN_KEYS.iter().any(|(known, _)| *known == name),
"{}: `{name}` is read but missing from KNOWN_KEYS, so `lucida config` \
reports it as ignored while Lucida acts on it",
path.file_name().unwrap().to_string_lossy()
);
}
}
for expected in ["GEMINI_API_KEY", "LUCIDA_BUDGET"] {
assert!(
checked.iter().any(|name| name == expected),
"the scan found {} call sites and none of them was {expected} — \
it has stopped matching how settings are read",
checked.len()
);
}
}
#[test]
fn the_file_outranks_the_environment() {
assert_eq!(
origin_of(true, true),
Some(Origin::FileOverridingEnvironment)
);
assert_eq!(origin_of(true, false), Some(Origin::File));
assert_eq!(origin_of(false, true), Some(Origin::Environment));
assert_eq!(origin_of(false, false), None);
}
#[test]
fn a_value_containing_equals_survives() {
let values = parse("LUCIDA_COMFYUI_AUTH=Basic dXNlcjpwdw==\n");
assert_eq!(
values.get("LUCIDA_COMFYUI_AUTH").unwrap(),
"Basic dXNlcjpwdw=="
);
}
#[test]
fn comments_and_blank_lines_are_ignored() {
assert!(parse("# GOOGLE_API_KEY=nope\n\n \n").is_empty());
}
#[test]
fn the_template_only_offers_the_canonical_key_name() {
let text = template();
assert!(text.contains("#GEMINI_API_KEY="));
for (retired, _) in RETIRED_KEYS {
assert!(
!text.contains(&format!("#{retired}=")),
"the template offers the retired name {retired}"
);
}
for line in text.lines().filter(|l| l.contains('=')) {
assert!(
line.trim_start().starts_with('#'),
"template line is live: {line}"
);
}
}
#[test]
fn the_template_parses_to_nothing() {
assert!(parse(&template()).is_empty());
}
#[test]
fn a_replacement_is_staged_and_leaves_nothing_behind() {
let dir = std::env::temp_dir().join(format!("lucida-write-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.env");
std::fs::write(&path, "GEMINI_API_KEY=old\n").unwrap();
write_replacing(&path, "GEMINI_API_KEY=new\n", true).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"GEMINI_API_KEY=new\n"
);
let left: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
.collect();
assert_eq!(left, vec!["config.env"], "a staging file survived: {left:?}");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "mode {:o}", mode & 0o777);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_home_directory_falls_back_to_the_windows_spelling() {
let home = || Some(OsString::from("/home/someone"));
let profile = || Some(OsString::from(r"C:\Users\someone"));
assert_eq!(
home_from(home(), None),
Some(PathBuf::from("/home/someone"))
);
assert_eq!(
home_from(None, profile()),
Some(PathBuf::from(r"C:\Users\someone"))
);
assert_eq!(
home_from(home(), profile()),
Some(PathBuf::from("/home/someone"))
);
assert_eq!(
home_from(Some(OsString::new()), profile()),
Some(PathBuf::from(r"C:\Users\someone"))
);
assert_eq!(home_from(None, None), None);
assert_eq!(home_from(Some(OsString::new()), None), None);
}
#[test]
fn some_config_location_is_always_offered() {
assert!(
preferred_path().is_some(),
"no config location on {}: search_paths() is empty",
std::env::consts::OS
);
}
#[test]
fn an_explicit_config_path_wins_outright() {
unsafe { std::env::set_var("LUCIDA_CONFIG", "/tmp/lucida-test-config.env") };
let paths = search_paths();
unsafe { std::env::remove_var("LUCIDA_CONFIG") };
assert_eq!(paths, vec![PathBuf::from("/tmp/lucida-test-config.env")]);
}
}