use std::{fs, path::PathBuf};
use crate::{
error::{Error, Result},
store,
};
#[derive(Debug, Clone, Default)]
pub struct VarStore {
vars: Vec<(String, String)>,
}
impl VarStore {
pub fn load() -> Result<Self> {
let path = store::vars_path()?;
if !path.exists() {
return Ok(Self::default());
}
let content = fs::read_to_string(&path).map_err(|e| Error::io(&path, "read vars", e))?;
Ok(Self::parse(&content))
}
pub fn save(&self) -> Result<()> {
let path = store::vars_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| Error::io(parent, "create state directory", e))?;
}
let content = self.serialize();
crate::fs::atomic_write(&path, content.as_bytes())
}
pub fn get(&self, key: &str) -> Option<&str> {
self.vars
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
pub fn set(&mut self, key: &str, value: &str) {
if let Some(entry) = self.vars.iter_mut().find(|(k, _)| k == key) {
entry.1 = value.to_string();
} else {
self.vars.push((key.to_string(), value.to_string()));
}
}
pub fn remove(&mut self, key: &str) -> bool {
let before = self.vars.len();
self.vars.retain(|(k, _)| k != key);
self.vars.len() < before
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.vars.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
pub fn as_pairs(&self) -> Vec<(String, String)> {
self.vars.clone()
}
pub fn is_empty(&self) -> bool {
self.vars.is_empty()
}
pub fn len(&self) -> usize {
self.vars.len()
}
pub fn path() -> Result<PathBuf> {
store::vars_path()
}
}
impl VarStore {
fn parse(content: &str) -> Self {
let mut vars = Vec::new();
let mut in_vars_section = false;
for raw_line in content.lines() {
let line = raw_line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
let section = line[1..line.len() - 1].trim();
in_vars_section = section == "vars";
continue;
}
if !in_vars_section {
continue;
}
if let Some((key, rest)) = line.split_once('=') {
let key = key.trim().to_string();
let raw_val = rest.trim();
let value = if (raw_val.starts_with('"') && raw_val.ends_with('"'))
|| (raw_val.starts_with('\'') && raw_val.ends_with('\''))
{
unescape_str(&raw_val[1..raw_val.len() - 1])
} else {
raw_val.to_string()
};
if !key.is_empty() {
vars.push((key, value));
}
}
}
Self { vars }
}
fn serialize(&self) -> String {
use std::fmt::Write;
let mut out = String::new();
let _ = writeln!(
out,
"# ~/.dotling/vars.toml — machine-local variables, NOT committed to git"
);
let _ = writeln!(out);
let _ = writeln!(out, "[vars]");
for (key, value) in &self.vars {
let escaped = escape_str(value);
let _ = writeln!(out, "{key} = \"{escaped}\"");
}
out
}
}
fn unescape_str(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => result.push('\n'),
Some('t') => result.push('\t'),
Some(ch @ ('\\' | '"' | '\'')) => result.push(ch),
Some(other) => {
result.push('\\');
result.push(other);
}
None => result.push('\\'),
}
} else {
result.push(c);
}
}
result
}
fn escape_str(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\t', "\\t")
}
pub fn import_from_file(store: &mut VarStore, path: &std::path::Path) -> Result<usize> {
let content = fs::read_to_string(path).map_err(|e| Error::io(path, "read import file", e))?;
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let pairs: Vec<(String, String)> =
if ext == "env" || path.file_name().and_then(|n| n.to_str()) == Some(".env") {
parse_env_file(&content)
} else {
VarStore::parse(&content).vars
};
let count = pairs.len();
for (k, v) in pairs {
store.set(&k, &v);
}
Ok(count)
}
fn parse_env_file(content: &str) -> Vec<(String, String)> {
let mut pairs = Vec::new();
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, val)) = line.split_once('=') {
let key = key.trim().to_string();
let val = val.trim().trim_matches('"').trim_matches('\'').to_string();
if !key.is_empty() {
pairs.push((key, val));
}
}
}
pairs
}
pub fn looks_like_real_value(key: &str, value: &str, local_store: &VarStore) -> Option<String> {
if let Some(local_val) = local_store.get(key) {
if local_val == value {
return Some(format!(
"`{key} = \"{value}\"` matches your local vars.toml — use a placeholder instead"
));
}
}
if value.contains('@') && value.contains('.') {
return Some(format!(
"`{key}` value looks like an email address — move to vars.toml"
));
}
if value.len() > 40 {
return Some(format!(
"`{key}` value is very long ({} chars) — may be a secret, move to vars.toml",
value.len()
));
}
if let Ok(username) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
if !username.is_empty() && value == username {
return Some(format!(
"`{key} = \"{value}\"` matches current username — use a placeholder like \"user\""
));
}
}
None
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
#[test]
fn vars_roundtrip() {
let mut store = VarStore::default();
store.set("foo", "bar");
store.set("baz", "qux");
let serialized = store.serialize();
let loaded = VarStore::parse(&serialized);
assert_eq!(loaded.get("foo"), Some("bar"));
assert_eq!(loaded.get("baz"), Some("qux"));
}
#[test]
fn vars_missing_file_ok() {
let store = VarStore::default();
assert!(store.is_empty());
}
#[test]
fn vars_set_overwrites() {
let mut store = VarStore::default();
store.set("key", "old");
store.set("key", "new");
assert_eq!(store.get("key"), Some("new"));
assert_eq!(store.len(), 1);
}
#[test]
fn vars_remove() {
let mut store = VarStore::default();
store.set("a", "1");
store.set("b", "2");
assert!(store.remove("a"));
assert!(!store.remove("a")); assert_eq!(store.get("a"), None);
assert_eq!(store.get("b"), Some("2"));
}
#[test]
fn vars_iter_order() {
let mut store = VarStore::default();
store.set("c", "3");
store.set("a", "1");
store.set("b", "2");
let keys: Vec<&str> = store.iter().map(|(k, _)| k).collect();
assert_eq!(keys, vec!["c", "a", "b"]);
}
#[test]
fn parse_env_file_basic() {
let content = "FOO=bar\nBAZ=\"qux\"\n# comment\n\nEMPTY=";
let pairs = parse_env_file(content);
let map: HashMap<_, _> = pairs.into_iter().collect();
assert_eq!(map.get("FOO"), Some(&"bar".to_string()));
assert_eq!(map.get("BAZ"), Some(&"qux".to_string()));
assert_eq!(map.get("EMPTY"), Some(&String::new()));
assert!(!map.contains_key("comment"));
}
#[test]
fn parse_vars_toml_ignores_other_sections() {
let content = "[other]\nfoo = \"should_ignore\"\n[vars]\nbar = \"keep\"";
let store = VarStore::parse(content);
assert_eq!(store.get("bar"), Some("keep"));
assert_eq!(store.get("foo"), None);
}
}