Files
openfang/crates/openfang-cli/src/dotenv.rs
iven 92e5def702
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
初始化提交
2026-03-01 16:24:24 +08:00

242 lines
6.8 KiB
Rust

//! Minimal `.env` file loader/saver for `~/.openfang/.env`.
//!
//! No external crate needed — hand-rolled for simplicity.
//! Format: `KEY=VALUE` lines, `#` comments, optional quotes.
use std::collections::BTreeMap;
use std::path::PathBuf;
/// Return the path to `~/.openfang/.env`.
pub fn env_file_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".openfang").join(".env"))
}
/// Load `~/.openfang/.env` and `~/.openfang/secrets.env` into `std::env`.
///
/// System env vars take priority — existing vars are NOT overridden.
/// `secrets.env` is loaded second so `.env` values take priority over secrets
/// (but both yield to system env vars).
/// Silently does nothing if the files don't exist.
pub fn load_dotenv() {
load_env_file(env_file_path());
// Also load secrets.env (written by dashboard "Set API Key" button)
load_env_file(secrets_env_path());
}
/// Return the path to `~/.openfang/secrets.env`.
pub fn secrets_env_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".openfang").join("secrets.env"))
}
fn load_env_file(path: Option<PathBuf>) {
let path = match path {
Some(p) => p,
None => return,
};
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return,
};
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some((key, value)) = parse_env_line(trimmed) {
if std::env::var(&key).is_err() {
std::env::set_var(&key, &value);
}
}
}
}
/// Upsert a key in `~/.openfang/.env`.
///
/// Creates the file if missing. Sets 0600 permissions on Unix.
/// Also sets the key in the current process environment.
pub fn save_env_key(key: &str, value: &str) -> Result<(), String> {
let path = env_file_path().ok_or("Could not determine home directory")?;
// Ensure parent directory exists
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
}
let mut entries = read_env_file(&path);
entries.insert(key.to_string(), value.to_string());
write_env_file(&path, &entries)?;
// Also set in current process
std::env::set_var(key, value);
Ok(())
}
/// Remove a key from `~/.openfang/.env`.
///
/// Also removes it from the current process environment.
pub fn remove_env_key(key: &str) -> Result<(), String> {
let path = env_file_path().ok_or("Could not determine home directory")?;
let mut entries = read_env_file(&path);
entries.remove(key);
write_env_file(&path, &entries)?;
std::env::remove_var(key);
Ok(())
}
/// List key names (without values) from `~/.openfang/.env`.
#[allow(dead_code)]
pub fn list_env_keys() -> Vec<String> {
let path = match env_file_path() {
Some(p) => p,
None => return Vec::new(),
};
read_env_file(&path).into_keys().collect()
}
/// Check if the `.env` file exists.
#[allow(dead_code)]
pub fn env_file_exists() -> bool {
env_file_path().map(|p| p.exists()).unwrap_or(false)
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Parse a single `KEY=VALUE` line. Handles optional quotes.
fn parse_env_line(line: &str) -> Option<(String, String)> {
let eq_pos = line.find('=')?;
let key = line[..eq_pos].trim().to_string();
let mut value = line[eq_pos + 1..].trim().to_string();
if key.is_empty() {
return None;
}
// Strip matching quotes
if ((value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\'')))
&& value.len() >= 2
{
value = value[1..value.len() - 1].to_string();
}
Some((key, value))
}
/// Read all key-value pairs from the .env file.
fn read_env_file(path: &PathBuf) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return map,
};
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some((key, value)) = parse_env_line(trimmed) {
map.insert(key, value);
}
}
map
}
/// Write key-value pairs back to the .env file with a header comment.
fn write_env_file(path: &PathBuf, entries: &BTreeMap<String, String>) -> Result<(), String> {
let mut content =
String::from("# OpenFang environment — managed by `openfang config set-key`\n");
content.push_str("# Do not edit while the daemon is running.\n\n");
for (key, value) in entries {
// Quote values that contain spaces or special characters
if value.contains(' ') || value.contains('#') || value.contains('"') {
content.push_str(&format!("{key}=\"{}\"\n", value.replace('"', "\\\"")));
} else {
content.push_str(&format!("{key}={value}\n"));
}
}
std::fs::write(path, &content).map_err(|e| format!("Failed to write .env file: {e}"))?;
// Set 0600 permissions on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_env_line_simple() {
let (k, v) = parse_env_line("FOO=bar").unwrap();
assert_eq!(k, "FOO");
assert_eq!(v, "bar");
}
#[test]
fn test_parse_env_line_quoted() {
let (k, v) = parse_env_line("KEY=\"hello world\"").unwrap();
assert_eq!(k, "KEY");
assert_eq!(v, "hello world");
}
#[test]
fn test_parse_env_line_single_quoted() {
let (k, v) = parse_env_line("KEY='value'").unwrap();
assert_eq!(k, "KEY");
assert_eq!(v, "value");
}
#[test]
fn test_parse_env_line_spaces() {
let (k, v) = parse_env_line(" KEY = value ").unwrap();
assert_eq!(k, "KEY");
assert_eq!(v, "value");
}
#[test]
fn test_parse_env_line_no_value() {
let (k, v) = parse_env_line("KEY=").unwrap();
assert_eq!(k, "KEY");
assert_eq!(v, "");
}
#[test]
fn test_parse_env_line_comment() {
assert!(
parse_env_line("# comment").is_none()
|| parse_env_line("# comment").unwrap().0.starts_with('#')
);
// Comments are filtered before reaching parse_env_line in production code
}
#[test]
fn test_parse_env_line_no_equals() {
assert!(parse_env_line("NOEQUALS").is_none());
}
#[test]
fn test_parse_env_line_empty_key() {
assert!(parse_env_line("=value").is_none());
}
}