use std::path::{Path, PathBuf};
pub(crate) fn write_secret_file(path: &Path, contents: &[u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)?;
f.write_all(contents)?;
f.flush()?;
Ok(())
}
#[cfg(not(unix))]
{
std::fs::write(path, contents)
}
}
fn secrets_dir() -> PathBuf {
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".claudette").join("secrets")
}
pub fn read_secret(name: &str) -> Result<String, String> {
let upper = name.to_uppercase();
let fq_var = format!("CLAUDETTE_{upper}_TOKEN");
if let Ok(val) = std::env::var(&fq_var) {
let trimmed = val.trim().to_string();
if !trimmed.is_empty() {
return Ok(trimmed);
}
}
let short_var = format!("{upper}_TOKEN");
if let Ok(val) = std::env::var(&short_var) {
let trimmed = val.trim().to_string();
if !trimmed.is_empty() {
return Ok(trimmed);
}
}
let file_path = secrets_dir().join(format!("{}.token", name.to_lowercase()));
if file_path.exists() {
match std::fs::read_to_string(&file_path) {
Ok(contents) => {
let trimmed = contents.trim().to_string();
if !trimmed.is_empty() {
return Ok(trimmed);
}
}
Err(e) => {
return Err(format!(
"{name}: could not read {}: {e}",
file_path.display()
));
}
}
}
Err(format!(
"{name}: token not found. Set {fq_var} or {short_var} env var, \
or save it to {}",
file_path.display()
))
}
#[must_use]
pub fn secret_file_path(name: &str) -> PathBuf {
secrets_dir().join(format!("{}.token", name.to_lowercase()))
}
fn chat_id_path() -> PathBuf {
secrets_dir().join("telegram_chat.id")
}
#[must_use]
pub fn load_chat_ids() -> Vec<i64> {
let path = chat_id_path();
let Ok(contents) = std::fs::read_to_string(&path) else {
return Vec::new();
};
contents
.lines()
.filter_map(|line| line.trim().parse::<i64>().ok())
.collect()
}
pub fn save_chat_id(id: i64) {
let existing = load_chat_ids();
if existing.contains(&id) {
return;
}
let path = chat_id_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut contents = existing
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
if !contents.is_empty() {
contents.push('\n');
}
contents.push_str(&id.to_string());
contents.push('\n');
if let Err(e) = write_secret_file(&path, contents.as_bytes()) {
eprintln!(
"{} {}",
crate::theme::warn(crate::theme::WARN_GLYPH),
crate::theme::warn(&format!(
"save_chat_id: failed to persist {}: {e}",
path.display()
))
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secrets_dir_is_under_claudette() {
let dir = secrets_dir();
assert!(
dir.ends_with("secrets"),
"expected path ending in 'secrets', got {}",
dir.display()
);
let parent = dir.parent().unwrap();
assert!(
parent.ends_with(".claudette"),
"expected parent '.claudette', got {}",
parent.display()
);
}
#[test]
fn secret_file_path_formats_correctly() {
let path = secret_file_path("github");
assert!(path.ends_with("github.token"));
let path = secret_file_path("TELEGRAM");
assert!(path.ends_with("telegram.token"));
}
#[test]
fn read_secret_error_mentions_all_paths() {
let err = read_secret("zzz_test_nonexistent_abc").unwrap_err();
assert!(err.contains("CLAUDETTE_ZZZ_TEST_NONEXISTENT_ABC_TOKEN"));
assert!(err.contains("ZZZ_TEST_NONEXISTENT_ABC_TOKEN"));
assert!(err.contains("zzz_test_nonexistent_abc.token"));
}
#[test]
fn read_secret_picks_up_env_var() {
let var_name = "ZZZTESTUNIQUE42_TOKEN";
std::env::set_var(var_name, "test-token-value");
let result = read_secret("zzztestunique42");
std::env::remove_var(var_name);
assert_eq!(result.unwrap(), "test-token-value");
}
#[test]
fn read_secret_trims_whitespace() {
let var_name = "ZZZTESTTRIM99_TOKEN";
std::env::set_var(var_name, " spaced-token \n");
let result = read_secret("zzztesttrim99");
std::env::remove_var(var_name);
assert_eq!(result.unwrap(), "spaced-token");
}
#[test]
fn read_secret_rejects_empty_env_var() {
let var_name = "ZZZTESTEMPTY77_TOKEN";
std::env::set_var(var_name, " ");
let result = read_secret("zzztestempty77");
std::env::remove_var(var_name);
assert!(result.is_err(), "empty/whitespace token should fail");
}
#[test]
fn read_secret_file_fallback() {
let dir = secrets_dir();
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("zzztestfile88.token");
std::fs::write(&path, "file-based-token\n").unwrap();
let result = read_secret("zzztestfile88");
let _ = std::fs::remove_file(&path);
assert_eq!(result.unwrap(), "file-based-token");
}
#[test]
fn chat_id_path_under_secrets() {
let path = chat_id_path();
assert!(path.ends_with("telegram_chat.id"));
}
#[test]
fn load_chat_ids_empty_when_no_file() {
let ids = load_chat_ids();
assert!(ids.len() < 1000);
}
#[test]
fn save_and_load_chat_id_roundtrip() {
let path = chat_id_path();
let _ = std::fs::create_dir_all(path.parent().unwrap());
let backup = std::fs::read_to_string(&path).ok();
let _ = std::fs::write(&path, "");
save_chat_id(9999999);
let ids = load_chat_ids();
assert!(ids.contains(&9999999), "got: {ids:?}");
save_chat_id(9999999);
let ids2 = load_chat_ids();
assert_eq!(
ids2.iter().filter(|&&id| id == 9999999).count(),
1,
"duplicate ID should not be saved twice"
);
if let Some(b) = backup {
let _ = std::fs::write(&path, b);
} else {
let _ = std::fs::remove_file(&path);
}
}
}