use std::collections::BTreeMap;
use std::path::PathBuf;
use anyhow::{Result, bail};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Host {
pub name: String,
pub base: Option<String>,
pub enabled: bool,
}
#[derive(Debug, Default, Clone)]
pub struct Set {
hosts: BTreeMap<String, Host>,
}
impl Set {
pub fn new(configured: Vec<Host>) -> Self {
let mut hosts: BTreeMap<String, Host> = configured
.into_iter()
.map(|h| (h.name.clone(), h))
.collect();
for (name, enabled) in remembered() {
if let Some(host) = hosts.get_mut(&name) {
host.enabled = enabled;
} else if enabled {
hosts.insert(
name.clone(),
Host {
name,
base: None,
enabled: true,
},
);
}
}
Self { hosts }
}
pub fn get(&self, name: &str) -> Option<&Host> {
self.hosts.get(name).filter(|h| h.enabled)
}
pub fn enabled(&self) -> impl Iterator<Item = &Host> {
self.hosts.values().filter(|h| h.enabled)
}
pub fn set(&mut self, name: &str, enabled: bool, base: Option<String>) {
self.hosts
.entry(name.to_string())
.and_modify(|h| h.enabled = enabled)
.or_insert_with(|| Host {
name: name.to_string(),
base,
enabled,
});
}
fn to_text(&self) -> String {
let mut out = String::new();
for host in self.hosts.values() {
out.push(if host.enabled { '+' } else { '-' });
out.push_str(&host.name);
out.push('\n');
}
out
}
}
fn stored_path() -> Option<PathBuf> {
Some(crate::control::state_dir()?.join("enabled"))
}
fn remembered() -> Vec<(String, bool)> {
let Some(path) = stored_path() else {
return Vec::new();
};
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
text.lines().filter_map(parse_line).collect()
}
fn parse_line(line: &str) -> Option<(String, bool)> {
let line = line.trim();
let enabled = match line.chars().next()? {
'+' => true,
'-' => false,
_ => return None,
};
let name = line[1..].trim();
(!name.is_empty()).then(|| (name.to_string(), enabled))
}
pub fn remember(set: &Set) -> Result<PathBuf> {
let path = match stored_path() {
Some(path) => path,
None => bail!("no directory to remember which hosts are reachable in"),
};
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, set.to_text())?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_disabled_host_reads_exactly_like_one_that_is_not_there() {
let mut set = Set::default();
set.set("off", false, None);
assert!(set.get("off").is_none());
assert!(set.get("never-configured").is_none());
}
#[test]
fn off_survives_a_round_trip_through_the_file_format() {
let mut set = Set::default();
set.set("on", true, None);
set.set("off", false, None);
let lines: Vec<(String, bool)> = set.to_text().lines().filter_map(parse_line).collect();
assert_eq!(
lines,
vec![("off".to_string(), false), ("on".to_string(), true)]
);
}
#[test]
fn a_line_in_neither_form_is_skipped_rather_than_guessed_at() {
assert_eq!(parse_line("+yes"), Some(("yes".to_string(), true)));
assert_eq!(parse_line("-no"), Some(("no".to_string(), false)));
assert_eq!(parse_line("bare"), None);
assert_eq!(parse_line("+"), None);
assert_eq!(parse_line(""), None);
}
#[test]
fn turning_one_off_and_on_keeps_the_base_the_file_gave_it() {
let mut set = Set::new(vec![Host {
name: "panza".to_string(),
base: Some("~/work".to_string()),
enabled: true,
}]);
set.set("panza", false, None);
set.set("panza", true, None);
assert_eq!(
set.get("panza").and_then(|h| h.base.as_deref()),
Some("~/work")
);
}
}