use std::{
collections::BTreeMap,
fs, io,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionEntry {
pub cmd: String,
pub group: Option<String>,
}
pub type SessionConfig = BTreeMap<String, Vec<SessionEntry>>;
const DISALLOWED: &[char] = &['*', '"', '/', '\\', '<', '>', ':', '|', '?', '.'];
pub fn sanitize(name: &str) -> String {
name.trim()
.chars()
.map(|c| {
if c.is_control() || DISALLOWED.contains(&c) {
'_'
} else {
c
}
})
.take(255)
.collect()
}
pub fn sessions_dir() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("FLEETCOM_CONFIG_DIR") {
return Some(PathBuf::from(dir).join("sessions"));
}
dirs::config_dir().map(|c| c.join("fleetcom").join("sessions"))
}
fn to_json(cfg: &SessionConfig) -> String {
let mut obj = jzon::JsonValue::new_object();
for (dir, entries) in cfg {
let mut arr = jzon::JsonValue::new_array();
for e in entries {
let member = match &e.group {
None => jzon::JsonValue::from(e.cmd.as_str()),
Some(g) => {
let mut m = jzon::JsonValue::new_object();
let _ = m.insert("cmd", e.cmd.as_str());
let _ = m.insert("group", g.as_str());
m
}
};
let _ = arr.push(member);
}
let _ = obj.insert(dir, arr);
}
obj.pretty(2)
}
fn from_json(text: &str) -> io::Result<SessionConfig> {
let parsed = jzon::parse(text).map_err(|e| io::Error::other(e.to_string()))?;
let mut cfg = SessionConfig::new();
for (dir, val) in parsed.entries() {
let entries = val
.members()
.filter_map(|m| {
if let Some(cmd) = m.as_str() {
return Some(SessionEntry {
cmd: cmd.to_string(),
group: None,
});
}
let cmd = m["cmd"].as_str()?.to_string();
let group = match &m["group"] {
g if g.is_null() => None,
g => Some(g.as_str()?.to_string()),
};
Some(SessionEntry { cmd, group })
})
.collect();
cfg.insert(dir.to_string(), entries);
}
Ok(cfg)
}
pub fn save_in(dir: &Path, name: &str, cfg: &SessionConfig) -> io::Result<PathBuf> {
fs::create_dir_all(dir)?;
let file = dir.join(format!("{}.json", sanitize(name)));
fs::write(&file, to_json(cfg))?;
Ok(file)
}
pub fn load_in(dir: &Path, name: &str) -> io::Result<SessionConfig> {
let file = dir.join(format!("{}.json", sanitize(name)));
from_json(&fs::read_to_string(file)?)
}
pub fn list_in(dir: &Path) -> Vec<String> {
let mut names = Vec::new();
if let Ok(entries) = fs::read_dir(dir) {
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) == Some("json")
&& let Some(stem) = p.file_stem().and_then(|s| s.to_str())
{
names.push(stem.to_string());
}
}
}
names.sort();
names
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("fleetcom_session_test_{tag}"));
let _ = fs::remove_dir_all(&d);
d
}
fn e(cmd: &str) -> SessionEntry {
SessionEntry {
cmd: cmd.into(),
group: None,
}
}
fn ge(cmd: &str, group: &str) -> SessionEntry {
SessionEntry {
cmd: cmd.into(),
group: Some(group.into()),
}
}
#[test]
fn round_trips_dirs_and_commands() {
let dir = temp("roundtrip");
let mut cfg = SessionConfig::new();
cfg.insert("~/proj".into(), vec![e("cargo test"), e("vim")]);
cfg.insert("/tmp".into(), vec![e("top")]);
save_in(&dir, "work", &cfg).unwrap();
assert_eq!(load_in(&dir, "work").unwrap(), cfg);
assert_eq!(list_in(&dir), vec!["work".to_string()]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn round_trips_mixed_grouped_and_ungrouped_entries() {
let dir = temp("mixed");
let mut cfg = SessionConfig::new();
cfg.insert(
"~/proj".into(),
vec![ge("cargo test", "ci"), e("vim"), ge("top", "ops")],
);
save_in(&dir, "mixed", &cfg).unwrap();
assert_eq!(load_in(&dir, "mixed").unwrap(), cfg);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn parses_the_pre_group_string_only_format() {
let cfg = from_json(r#"{"~/proj": ["cargo test", "vim"]}"#).unwrap();
assert_eq!(cfg["~/proj"], vec![e("cargo test"), e("vim")]);
}
#[test]
fn group_free_config_writes_the_pre_group_bytes() {
let mut cfg = SessionConfig::new();
cfg.insert("~/proj".into(), vec![e("cargo test"), e("vim")]);
cfg.insert("/tmp".into(), vec![e("top")]);
let expected = "{\n \"/tmp\": [\n \"top\"\n ],\n \"~/proj\": [\n \"cargo test\",\n \"vim\"\n ]\n}";
assert_eq!(to_json(&cfg), expected);
}
#[test]
fn malformed_object_members_drop_without_error() {
let cfg = from_json(
r#"{"d": [
{"group": "g"},
{"cmd": 3},
{"cmd": "x", "group": 5},
42,
{"cmd": "bare"},
{"cmd": "n", "group": null},
{"cmd": "ok", "group": "api"},
"plain"
]}"#,
)
.unwrap();
assert_eq!(
cfg["d"],
vec![e("bare"), e("n"), ge("ok", "api"), e("plain")]
);
}
#[test]
fn sanitizes_names() {
assert_eq!(sanitize("my/session"), "my_session");
assert_eq!(sanitize(" a.b "), "a_b");
}
}