use cyberbrain_core::{Error, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Profiles {
#[serde(default, rename = "profile")]
pub profiles: Vec<Profile>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Profile {
pub name: String,
pub command: String,
}
pub fn path() -> Option<PathBuf> {
let base = if cfg!(windows) {
std::env::var_os("APPDATA").map(PathBuf::from)
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
}?;
Some(base.join("cyberbrain").join("terminals.toml"))
}
pub fn load() -> Result<Profiles> {
let Some(path) = path() else {
return Ok(Profiles::default());
};
match std::fs::read_to_string(&path) {
Ok(text) => toml::from_str(&text).map_err(|e| {
Error::Config(format!(
"{} does not parse: {e}",
cyberbrain_core::Slash(&path)
))
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Profiles::default()),
Err(e) => Err(Error::Io { path, source: e }),
}
}
pub fn save(profiles: &Profiles) -> Result<PathBuf> {
for p in &profiles.profiles {
if p.name.trim().is_empty() {
return Err(Error::Config("a saved command needs a name".into()));
}
match crate::serve::command::tokenise(&p.command) {
Err(why) => {
return Err(Error::Config(format!("{}: {why}", p.name)));
}
Ok(None) => {
return Err(Error::Config(format!(
"{}: there is no command in {:?}",
p.name, p.command
)));
}
Ok(Some(_)) => {}
}
}
let path = path().ok_or_else(|| {
Error::Config("this platform named no configuration directory to save into".into())
})?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| Error::Io {
path: dir.to_path_buf(),
source: e,
})?;
}
let text = toml::to_string_pretty(profiles)
.map_err(|e| Error::Config(format!("the list does not serialise: {e}")))?;
std::fs::write(&path, text).map_err(|e| Error::Io {
path: path.clone(),
source: e,
})?;
restrict(&path);
Ok(path)
}
fn restrict(path: &std::path::Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
let _ = path;
}
#[cfg(test)]
mod tests {
use super::*;
fn p(name: &str, command: &str) -> Profile {
Profile {
name: name.into(),
command: command.into(),
}
}
#[cfg(unix)]
#[test]
fn the_saved_list_is_not_readable_by_other_accounts() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("XDG_CONFIG_HOME", tmp.path()) };
unsafe { std::env::remove_var("APPDATA") };
let written = save(&Profiles {
profiles: vec![p("S2", "ssh root@example.com")],
})
.unwrap();
let mode = std::fs::metadata(&written).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode,
0o600,
"{} is readable by other accounts on this machine",
written.display()
);
}
#[test]
fn a_saved_command_has_to_be_runnable() {
let bad = Profiles {
profiles: vec![p("S2", "ssh \"unclosed")],
};
let why = save(&bad).unwrap_err().to_string();
assert!(why.contains("S2"), "{why}");
assert!(why.contains("quote"), "{why}");
}
#[test]
fn a_saved_command_has_to_be_a_command() {
let why = save(&Profiles {
profiles: vec![p("empty", " ")],
})
.unwrap_err()
.to_string();
assert!(why.contains("no command"), "{why}");
}
#[test]
fn a_button_needs_a_label() {
let why = save(&Profiles {
profiles: vec![p(" ", "ssh host")],
})
.unwrap_err()
.to_string();
assert!(why.contains("needs a name"), "{why}");
}
#[test]
fn the_file_is_the_shape_the_documentation_promises() {
let text =
"[[profile]]\nname = \"S2\"\ncommand = \"ssh -o ServerAliveInterval=60 root@x\"\n";
let got: Profiles = toml::from_str(text).unwrap();
assert_eq!(
got.profiles,
vec![p("S2", "ssh -o ServerAliveInterval=60 root@x")]
);
}
#[test]
fn a_file_that_does_not_parse_is_reported_rather_than_read_as_empty() {
let e = toml::from_str::<Profiles>("[[profile]]\nname = \n").unwrap_err();
assert!(!e.to_string().is_empty());
}
}