pub mod store;
use std::cell::RefCell;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use crate::Result;
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum EnvOp {
Set,
Prepend,
Append,
}
pub const PATH_SEP: &str = if cfg!(windows) { ";" } else { ":" };
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct EnvVar(String);
impl EnvVar {
pub fn as_str(&self) -> &str {
&self.0
}
fn canonical(&self) -> String {
if cfg!(windows) {
self.0.to_lowercase()
} else {
self.0.clone()
}
}
pub fn is_path(&self) -> bool {
if cfg!(windows) {
self.0.eq_ignore_ascii_case("PATH")
} else {
self.0 == "PATH"
}
}
}
impl From<String> for EnvVar {
fn from(value: String) -> Self {
EnvVar(value)
}
}
impl From<&str> for EnvVar {
fn from(value: &str) -> Self {
EnvVar(value.to_owned())
}
}
impl PartialEq for EnvVar {
fn eq(&self, other: &Self) -> bool {
self.canonical() == other.canonical()
}
}
impl Eq for EnvVar {}
impl Hash for EnvVar {
fn hash<H: Hasher>(&self, state: &mut H) {
self.canonical().hash(state);
}
}
impl std::fmt::Display for EnvVar {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EnvDelta {
pub name: EnvVar,
pub value: String,
pub op: EnvOp,
}
pub trait EnvStore: Send {
fn read(&self, name: &EnvVar) -> Result<Option<String>>;
fn write(&self, name: &EnvVar, value: &str) -> Result<()>;
fn remove(&self, name: &EnvVar) -> Result<()>;
}
#[derive(Clone, Debug)]
pub struct PathValue {
string: String,
}
impl PathValue {
pub fn new(value: String) -> Self {
PathValue { string: value }
}
pub fn separator() -> &'static str {
PATH_SEP
}
pub fn join<'a>(entries: impl IntoIterator<Item = &'a str>) -> String {
entries
.into_iter()
.filter(|s| !s.trim().is_empty())
.collect::<Vec<_>>()
.join(PATH_SEP)
}
fn canon(entry: &str) -> String {
let trimmed = entry.trim();
if cfg!(windows) {
trimmed.to_lowercase()
} else {
trimmed.to_owned()
}
}
fn contains(&self, entry: &str) -> bool {
let want = Self::canon(entry);
self.string
.split(PATH_SEP)
.any(|seg| Self::canon(seg) == want)
}
pub fn prepend(&self, entry: &str) -> String {
self.offer(entry, true)
}
pub fn append(&self, entry: &str) -> String {
self.offer(entry, false)
}
pub fn remove(&self, entry: &str) -> Option<String> {
let want = Self::canon(entry);
let kept: Vec<&str> = self
.string
.split(PATH_SEP)
.map(str::trim)
.filter(|seg| !seg.is_empty() && Self::canon(seg) != want)
.collect();
if kept.is_empty() {
None
} else {
Some(kept.join(PATH_SEP))
}
}
fn offer(&self, entry: &str, front: bool) -> String {
if self.contains(entry) {
self.string.clone()
} else if self.string.trim().is_empty() {
entry.to_string()
} else if front {
format!("{}{}{}", entry, PATH_SEP, self.string)
} else {
format!("{}{}{}", self.string, PATH_SEP, entry)
}
}
}
pub fn user_home() -> String {
std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_default()
}
pub fn add_to_path(entry: &str, store: &dyn EnvStore) -> Result<bool> {
let name = EnvVar::from("PATH");
let old = store.read(&name)?.unwrap_or_default();
let next = PathValue::new(old.clone()).prepend(entry);
if next == old {
Ok(false)
} else {
store.write(&name, &next)?;
Ok(true)
}
}
pub fn apply_deltas(
deltas: &[EnvDelta],
store: &dyn EnvStore,
) -> Result<Vec<(EnvVar, Option<String>)>> {
let mut snapshots = Vec::new();
for delta in deltas {
let old = store.read(&delta.name)?;
let next = merge(old.as_deref(), delta);
store.write(&delta.name, &next)?;
snapshots.push((delta.name.clone(), old));
}
Ok(snapshots)
}
pub fn reverse_value(
applied: &EnvDelta,
old: Option<&str>,
current: Option<&str>,
) -> Option<String> {
match applied.op {
EnvOp::Set => old.map(str::to_owned),
EnvOp::Prepend | EnvOp::Append => {
let current = current?.to_string();
PathValue::new(current).remove(&applied.value)
}
}
}
pub fn undo_delta(applied: &EnvDelta, old: Option<&str>, store: &dyn EnvStore) -> Result<()> {
let current = store.read(&applied.name)?;
match reverse_value(applied, old, current.as_deref()) {
Some(next) => store.write(&applied.name, &next),
None => store.remove(&applied.name),
}
}
fn merge(current: Option<&str>, delta: &EnvDelta) -> String {
let list = PathValue::new(current.unwrap_or("").to_string());
match delta.op {
EnvOp::Set => delta.value.clone(),
EnvOp::Prepend => list.prepend(&delta.value),
EnvOp::Append => list.append(&delta.value),
}
}
#[derive(Default, Debug)]
pub struct MemEnvStore {
values: RefCell<HashMap<EnvVar, String>>,
}
impl MemEnvStore {
pub fn new() -> Self {
Self::default()
}
#[cfg(test)]
pub fn snapshot(&self) -> HashMap<EnvVar, String> {
self.values.borrow().clone()
}
}
impl EnvStore for MemEnvStore {
fn read(&self, name: &EnvVar) -> Result<Option<String>> {
Ok(self.values.borrow().get(name).cloned())
}
fn write(&self, name: &EnvVar, value: &str) -> Result<()> {
self.values
.borrow_mut()
.insert(name.clone(), value.to_string());
Ok(())
}
fn remove(&self, name: &EnvVar) -> Result<()> {
self.values.borrow_mut().remove(name);
Ok(())
}
}
#[cfg(test)]
mod tests;