use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use serde::Deserialize;
const DEFAULT_MAX_RUNNING: u32 = 4;
const DEFAULT_KEEP_DAYS: u32 = 14;
const DEFAULT_MAX_LOG_BYTES: u64 = 100 * 1024 * 1024;
const DEFAULT_MAX_JOB_SECS: u64 = 0;
const DEFAULT_TMUX_SOCKET: &str = "coop";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Host {
pub name: String,
pub target: String,
pub socket: PathBuf,
pub tmux_socket: String,
pub max_running: u32,
pub default_cwd: Option<String>,
pub keep_days: u32,
pub max_log_bytes: u64,
pub max_job_secs: u64,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawHost {
target: Option<String>,
socket: Option<String>,
tmux_socket: Option<String>,
max_running: Option<u32>,
default_cwd: Option<String>,
keep_days: Option<u32>,
max_log_bytes: Option<u64>,
max_job_secs: Option<u64>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawConfig {
#[serde(default)]
hosts: BTreeMap<String, RawHost>,
}
#[derive(Debug, Clone)]
pub struct Config {
hosts: Vec<Host>,
}
fn is_filename_component(value: &str) -> bool {
if value.is_empty() || value == "." || value == ".." {
return false;
}
value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
}
fn expand_tilde(raw: &str) -> Result<PathBuf> {
let Some(rest) = raw.strip_prefix('~') else {
return Ok(PathBuf::from(raw));
};
if !(rest.is_empty() || rest.starts_with('/')) {
bail!("cannot expand {raw:?}: only a leading `~/` is supported, not `~user`");
}
let home = directories::BaseDirs::new()
.ok_or_else(|| anyhow!("cannot locate the home directory to expand {raw:?}"))?
.home_dir()
.to_path_buf();
Ok(home.join(rest.trim_start_matches('/')))
}
pub const TEMPLATE: &str = "\
# coop hosts. Uncomment and edit -- the section name is what you pass to --host.
#
# One block per host. `target` is the only key worth setting by hand; every
# other line below shows its default and can stay commented out.
#
# [hosts.build]
# target = \"build\" # ssh target (default: section name)
# socket = \"~/.ssh/coop/build.sock\" # coop's own ControlPath
# tmux_socket = \"coop\" # private tmux server
# max_running = 4 # warn past this; not a queue
# default_cwd = \"~/work\" # where `run` starts, unless --cwd
# keep_days = 14 # prune finished jobs older than this
# max_log_bytes = 104857600 # 100MB; longer logs are truncated
# max_job_secs = 0 # remote runtime cap; 0 is unbounded
#
# Then open the control master, once per ControlPersist window. This may ask
# you to touch a hardware key; coop cannot do it for you:
#
# ssh -MNf -S ~/.ssh/coop/build.sock -o ControlPersist=8h build
";
pub fn seed(path: &Path) -> Result<bool> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
{
Ok(mut file) => {
use std::io::Write;
file.write_all(TEMPLATE.as_bytes())
.with_context(|| format!("writing {}", path.display()))?;
Ok(true)
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
Err(e) => Err(e).with_context(|| format!("creating {}", path.display())),
}
}
pub fn default_path() -> Result<PathBuf> {
let dirs =
directories::BaseDirs::new().ok_or_else(|| anyhow!("cannot locate a home directory"))?;
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| dirs.home_dir().join(".config"));
Ok(base.join("coop").join("config.toml"))
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading config {}", path.display()))?;
Self::parse(&text).with_context(|| format!("in config {}", path.display()))
}
pub fn parse(text: &str) -> Result<Self> {
let raw: RawConfig = toml::from_str(text)?;
if raw.hosts.is_empty() {
bail!(
"no hosts configured\n \
uncomment a block, or add:\n\n \
[hosts.dev]\n target = \"dev\""
);
}
let hosts = raw
.hosts
.into_iter()
.map(|(name, h)| {
if !is_filename_component(&name) {
bail!(
"invalid host name {name:?}; use letters, digits, dot, \
dash or underscore, and not `.` or `..`"
);
}
let tmux_socket = h
.tmux_socket
.unwrap_or_else(|| DEFAULT_TMUX_SOCKET.to_string());
if !is_filename_component(&tmux_socket) {
bail!(
"host {name:?}: invalid tmux_socket {tmux_socket:?}; \
use letters, digits, dot, dash or underscore"
);
}
let socket = match h.socket {
Some(s) => expand_tilde(&s)?,
None => expand_tilde(&format!("~/.ssh/coop/{name}.sock"))?,
};
Ok(Host {
target: h.target.unwrap_or_else(|| name.clone()),
socket,
tmux_socket,
max_running: h.max_running.unwrap_or(DEFAULT_MAX_RUNNING),
default_cwd: h.default_cwd,
keep_days: h.keep_days.unwrap_or(DEFAULT_KEEP_DAYS),
max_log_bytes: h.max_log_bytes.unwrap_or(DEFAULT_MAX_LOG_BYTES),
max_job_secs: h.max_job_secs.unwrap_or(DEFAULT_MAX_JOB_SECS),
name,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(Self { hosts })
}
pub fn hosts(&self) -> &[Host] {
&self.hosts
}
pub fn host(&self, name: Option<&str>) -> Result<&Host> {
let names = || {
self.hosts
.iter()
.map(|h| h.name.as_str())
.collect::<Vec<_>>()
.join(", ")
};
match name {
Some(n) => self
.hosts
.iter()
.find(|h| h.name == n)
.ok_or_else(|| anyhow!("unknown host {n:?}; configured: {}", names())),
None if self.hosts.len() == 1 => Ok(&self.hosts[0]),
None => Err(anyhow!(
"several hosts configured; pass --host <name>: {}",
names()
)),
}
}
}