#![allow(clippy::result_large_err)]
use std::collections::BTreeMap;
use std::io::Write as _;
#[cfg(unix)]
use std::os::unix::fs::DirBuilderExt as _;
use std::path::{Path, PathBuf};
use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value};
use crate::style::StyleLevel;
const STARTER_INTERPRETERS: &[(&str, &str)] = &[
("js", "node"),
("mjs", "node"),
("cjs", "node"),
("py", "python3"),
("rb", "ruby"),
("sh", "sh"),
("pl", "perl"),
("php", "php"),
];
const INTERPRETERS_STARTER_COMMENT: &str = "\
# Extension -> interpreter mapping. shep applies one of these to a script
# when nothing more specific already named an interpreter: not this app's
# own Flockfile entry, and not --interpreter on the command line, both of
# which win over anything here. shep never guesses beyond what is written
# below, so edit freely: change an interpreter, add an extension, or
# delete an entry (or this whole table) to turn the mapping off for it.
";
pub struct ShepToml {
path: PathBuf,
doc: DocumentMut,
}
impl std::fmt::Debug for ShepToml {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ShepToml")
.field("path", &self.path)
.finish_non_exhaustive()
}
}
impl ShepToml {
pub fn edit<T>(path: &Path, f: impl FnOnce(&mut Self) -> T) -> Result<T, ShepTomlError> {
let (mut doc, _lock) = Self::open_locked(path)?;
let value = f(&mut doc);
doc.save()?;
Ok(value)
}
pub fn try_edit<T, E: From<ShepTomlError>>(
path: &Path,
f: impl FnOnce(&mut Self) -> Result<T, E>,
) -> Result<T, E> {
let (mut doc, _lock) = Self::open_locked(path)?;
let value = f(&mut doc)?;
doc.save()?;
Ok(value)
}
fn open_locked(path: &Path) -> Result<(Self, ConfigLock), ShepTomlError> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
create_home_dir(parent).map_err(|source| ShepTomlError::Io {
path: parent.to_path_buf(),
source,
})?;
let lock = ConfigLock::acquire(path).map_err(|source| ShepTomlError::Io {
path: path.to_path_buf(),
source,
})?;
let doc = Self::open(path)?;
Ok((doc, lock))
}
fn open(path: &Path) -> Result<Self, ShepTomlError> {
let doc = match std::fs::read_to_string(path) {
Ok(text) => text
.parse::<DocumentMut>()
.map_err(|source| ShepTomlError::Parse {
path: path.to_path_buf(),
source,
})?,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(source) => {
return Err(ShepTomlError::Io {
path: path.to_path_buf(),
source,
});
}
};
Ok(Self {
path: path.to_path_buf(),
doc,
})
}
pub fn read_only(path: &Path) -> Result<Self, ShepTomlError> {
Self::open(path)
}
#[must_use]
pub(crate) fn rendered(&self) -> String {
self.doc.to_string()
}
pub fn enable_dog(&mut self, name: &str) -> Result<(), ShepTomlError> {
self.push_daemon_name("enabled_dogs", name)
}
fn push_daemon_name(&mut self, key: &'static str, name: &str) -> Result<(), ShepTomlError> {
let path = self.path.clone();
let item = self
.daemon_table_mut()?
.entry(key)
.or_insert_with(|| Item::Value(Value::Array(Array::new())));
let found = item.type_name();
let names = item.as_array_mut().ok_or(ShepTomlError::WrongShape {
path,
key,
expected: "an array",
found,
})?;
if !names.iter().any(|v| v.as_str() == Some(name)) {
names.push(name);
}
Ok(())
}
pub fn disable_dog(&mut self, name: &str) {
if let Some(enabled_dogs) = self
.doc
.get_mut("daemon")
.and_then(Item::as_table_mut)
.and_then(|daemon| daemon.get_mut("enabled_dogs"))
.and_then(Item::as_array_mut)
{
enabled_dogs.retain(|v| v.as_str() != Some(name));
}
}
pub fn set_boot_first_dog(&mut self, name: &str, first: bool) -> Result<(), ShepTomlError> {
if first {
return self.push_daemon_name("boot_first_dogs", name);
}
self.forget_daemon_name("boot_first_dogs", name);
Ok(())
}
fn forget_daemon_name(&mut self, key: &'static str, name: &str) {
let Some(daemon) = self.doc.get_mut("daemon").and_then(Item::as_table_mut) else {
return;
};
let Some(names) = daemon.get_mut(key).and_then(Item::as_array_mut) else {
return;
};
names.retain(|v| v.as_str() != Some(name));
if names.is_empty() {
daemon.remove(key);
}
}
pub fn adopt_dog(
&mut self,
name: &str,
exec: &Path,
channel: bool,
) -> Result<(), ShepTomlError> {
let path = self.path.clone();
let item = self
.daemon_table_mut()?
.entry("adopted_dogs")
.or_insert_with(|| Item::Table(Table::new()));
let found = item.type_name();
let adopted_dogs = item.as_table_mut().ok_or(ShepTomlError::WrongShape {
path,
key: "adopted_dogs",
expected: "a table",
found,
})?;
let held = adopted_dogs
.get(name)
.and_then(|entry| entry.get("env"))
.and_then(Item::as_table_like)
.filter(|env| !env.is_empty())
.map(|env| {
env.iter()
.filter_map(|(key, value)| {
value.as_str().map(|text| (key.to_owned(), text.to_owned()))
})
.collect::<BTreeMap<String, String>>()
});
let entry = match held {
Some(env) => {
let mut table = Table::new();
table["path"] = Item::Value(exec.to_string_lossy().into_owned().into());
table["env"] = Item::Value(Value::InlineTable(inline_table(&env)));
Item::Table(table)
}
None => Item::Value(exec.to_string_lossy().into_owned().into()),
};
adopted_dogs.insert(name, entry);
if channel {
self.push_daemon_name("channel_dogs", name)?;
} else {
self.forget_channel_dog(name);
}
self.enable_dog(name)
}
fn forget_channel_dog(&mut self, name: &str) {
self.forget_daemon_name("channel_dogs", name);
}
#[must_use]
pub fn asked_for_channel(&self, name: &str) -> bool {
self.table("daemon")
.and_then(|daemon| daemon.get("channel_dogs"))
.and_then(Item::as_array)
.is_some_and(|names| names.iter().any(|v| v.as_str() == Some(name)))
}
pub fn take_dog_sections(&mut self) -> BTreeMap<String, Item> {
let Some(item) = self.doc.remove("dog") else {
return BTreeMap::new();
};
let Some(dog) = item.as_table_like() else {
return BTreeMap::new();
};
dog.iter()
.filter(|(_, value)| value.as_table_like().is_some())
.map(|(name, value)| (name.to_owned(), value.clone()))
.collect()
}
#[must_use]
pub fn adopted_dog_path(&self, name: &str) -> Option<PathBuf> {
let entry = self.adopted_dog_entry(name)?;
if let Some(text) = entry.as_str() {
return Some(PathBuf::from(text));
}
entry.get("path")?.as_str().map(PathBuf::from)
}
#[must_use]
pub fn adopted_dog_env(&self, name: &str) -> BTreeMap<String, String> {
let Some(env) = self
.adopted_dog_entry(name)
.and_then(|entry| entry.get("env"))
.and_then(Item::as_table_like)
else {
return BTreeMap::new();
};
env.iter()
.filter_map(|(key, value)| value.as_str().map(|text| (key.to_owned(), text.to_owned())))
.collect()
}
pub fn set_adopted_dog_env(
&mut self,
name: &str,
key: &str,
value: Option<&str>,
) -> Result<(), ShepTomlError> {
let path = self.path.clone();
let refusal = |found: &'static str| ShepTomlError::NoDogEntry {
path: path.clone(),
dog: name.to_owned(),
found,
};
let Some(entry) = self
.doc
.get_mut("daemon")
.and_then(Item::as_table_mut)
.and_then(|daemon| daemon.get_mut("adopted_dogs"))
.and_then(Item::as_table_mut)
.and_then(|adopted| adopted.get_mut(name))
else {
return Err(refusal("nothing"));
};
let (path_text, mut env) = match entry.as_table_like() {
Some(table) => (
table
.get("path")
.and_then(Item::as_str)
.unwrap_or_default()
.to_owned(),
table
.get("env")
.and_then(Item::as_table_like)
.map(|env| {
env.iter()
.filter_map(|(key, value)| {
value.as_str().map(|text| (key.to_owned(), text.to_owned()))
})
.collect::<BTreeMap<String, String>>()
})
.unwrap_or_default(),
),
None => match entry.as_str() {
Some(text) => (text.to_owned(), BTreeMap::new()),
None => return Err(refusal(entry.type_name())),
},
};
match value {
Some(value) => {
env.insert(key.to_owned(), value.to_owned());
}
None => {
env.remove(key);
}
}
if entry.is_inline_table() {
let mut table = InlineTable::new();
table.insert("path", Value::from(path_text));
if !env.is_empty() {
table.insert("env", Value::from(inline_table(&env)));
}
*entry = Item::Value(Value::InlineTable(table));
return Ok(());
}
let mut table = Table::new();
table["path"] = Item::Value(path_text.into());
if !env.is_empty() {
table["env"] = Item::Value(Value::InlineTable(inline_table(&env)));
}
*entry = Item::Table(table);
Ok(())
}
fn adopted_dog_entry(&self, name: &str) -> Option<&Item> {
self.doc
.get("daemon")?
.as_table()?
.get("adopted_dogs")?
.as_table()?
.get(name)
}
#[must_use]
pub fn adopted_dog_names(&self) -> Vec<String> {
self.doc
.get("daemon")
.and_then(Item::as_table)
.and_then(|daemon| daemon.get("adopted_dogs"))
.and_then(Item::as_table)
.map(|adopted| adopted.iter().map(|(name, _)| name.to_string()).collect())
.unwrap_or_default()
}
#[must_use]
pub fn enabled_dog_names(&self) -> Vec<String> {
self.daemon_names("enabled_dogs")
}
#[must_use]
pub fn boot_first_dog_names(&self) -> Vec<String> {
self.daemon_names("boot_first_dogs")
}
fn daemon_names(&self, key: &str) -> Vec<String> {
self.table("daemon")
.and_then(|daemon| daemon.get(key))
.and_then(Item::as_array)
.map(|names| {
names
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
pub fn add_host(&mut self, entry: &str) -> Result<bool, ShepTomlError> {
if self.host_names()?.iter().any(|name| name == entry) {
return Ok(false);
}
let item = self
.doc
.entry("hosts")
.or_insert_with(|| Item::Value(Value::Array(Array::new())));
if let Some(hosts) = item.as_array_mut() {
hosts.push(entry);
}
Ok(true)
}
pub fn remove_host(&mut self, entry: &str) -> Result<bool, ShepTomlError> {
if !self.host_names()?.iter().any(|name| name == entry) {
return Ok(false);
}
if let Some(hosts) = self.doc.get_mut("hosts").and_then(Item::as_array_mut) {
hosts.retain(|v| v.as_str() != Some(entry));
}
Ok(true)
}
pub fn host_names(&self) -> Result<Vec<String>, ShepTomlError> {
let Some(item) = self.doc.get("hosts") else {
return Ok(Vec::new());
};
let wrong_shape = |found| ShepTomlError::WrongShape {
path: self.path.clone(),
key: "hosts",
expected: "an array of strings",
found,
};
let hosts = item
.as_array()
.ok_or_else(|| wrong_shape(item.type_name()))?;
hosts
.iter()
.map(|v| {
v.as_str()
.map(String::from)
.ok_or_else(|| wrong_shape(v.type_name()))
})
.collect()
}
pub fn adopted_dog_path_readonly(
path: &Path,
name: &str,
) -> Result<Option<PathBuf>, ShepTomlError> {
Ok(Self::open(path)?.adopted_dog_path(name))
}
pub fn rehome_dog(&mut self, name: &str) {
self.disable_dog(name);
if let Some(adopted_dogs) = self
.doc
.get_mut("daemon")
.and_then(Item::as_table_mut)
.and_then(|daemon| daemon.get_mut("adopted_dogs"))
.and_then(Item::as_table_mut)
{
adopted_dogs.remove(name);
}
self.forget_channel_dog(name);
}
pub fn set_style_level(&mut self, level: StyleLevel) -> Result<(), ShepTomlError> {
let item = self
.doc
.entry("style")
.or_insert_with(|| Item::Table(Table::new()));
let Some(style) = item.as_table_mut() else {
return Err(ShepTomlError::WrongShape {
path: self.path.clone(),
key: "style",
expected: "a table",
found: item.type_name(),
});
};
style.insert("level", Item::Value(level.to_string().into()));
Ok(())
}
pub fn write_starter_interpreters(&mut self) {
if self.doc.contains_key("interpreters") {
return;
}
let mut table = Table::new();
for (extension, interpreter) in STARTER_INTERPRETERS {
table.insert(extension, Item::Value((*interpreter).into()));
}
table.decor_mut().set_prefix(INTERPRETERS_STARTER_COMMENT);
self.doc.insert("interpreters", Item::Table(table));
}
fn save(&self) -> Result<(), ShepTomlError> {
let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = create_config_file(parent).map_err(|source| self.io_error(source))?;
tmp.write_all(self.doc.to_string().as_bytes())
.map_err(|source| self.io_error(source))?;
shep_core::atomic_file::publish(tmp, &self.path).map_err(|source| self.io_error(source))
}
fn io_error(&self, source: std::io::Error) -> ShepTomlError {
ShepTomlError::Io {
path: self.path.clone(),
source,
}
}
fn table(&self, section: &str) -> Option<&Table> {
self.doc.get(section).and_then(Item::as_table)
}
fn section_table_mut(&mut self, section: &'static str) -> Result<&mut Table, ShepTomlError> {
let item = self
.doc
.entry(section)
.or_insert_with(|| Item::Table(Table::new()));
let found = item.type_name();
item.as_table_mut().ok_or(ShepTomlError::WrongShape {
path: self.path.clone(),
key: section,
expected: "a table",
found,
})
}
fn daemon_table_mut(&mut self) -> Result<&mut Table, ShepTomlError> {
self.section_table_mut("daemon")
}
}
fn create_home_dir(dir: &Path) -> std::io::Result<()> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
builder.mode(shep_daemon::boot::DIR_MODE);
builder.create(dir)
}
pub(super) use shep_core::config_lock::{ConfigLock, create_config_file};
pub enum ShepTomlError {
Io {
path: PathBuf,
source: std::io::Error,
},
Parse {
path: PathBuf,
source: toml_edit::TomlError,
},
WrongShape {
path: PathBuf,
key: &'static str,
expected: &'static str,
found: &'static str,
},
NoDogEntry {
path: PathBuf,
dog: String,
found: &'static str,
},
BadGrants {
path: PathBuf,
dog: String,
reason: String,
},
}
impl std::fmt::Debug for ShepTomlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io { path, source } => f
.debug_struct("Io")
.field("path", path)
.field("source", source)
.finish(),
Self::Parse { path, source } => f
.debug_struct("Parse")
.field("path", path)
.field("message", &source.message())
.finish(),
Self::WrongShape {
path,
key,
expected,
found,
} => f
.debug_struct("WrongShape")
.field("path", path)
.field("key", key)
.field("expected", expected)
.field("found", found)
.finish(),
Self::NoDogEntry { path, dog, found } => f
.debug_struct("NoDogEntry")
.field("path", path)
.field("dog", dog)
.field("found", found)
.finish(),
Self::BadGrants { path, dog, reason } => f
.debug_struct("BadGrants")
.field("path", path)
.field("dog", dog)
.field("reason", reason)
.finish(),
}
}
}
impl std::fmt::Display for ShepTomlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
Self::Parse { path, source } => write!(f, "{}: {source}", path.display()),
Self::WrongShape {
path,
key,
expected,
found,
} => write!(
f,
"{}: [{key}] must be {expected}, found a {found}",
path.display()
),
Self::NoDogEntry { path, dog, found } => write!(
f,
"{}: [daemon.adopted_dogs.{dog}] must be a path or a table, found {found}",
path.display()
),
Self::BadGrants { path, dog, reason } => write!(
f,
"{}: [daemon.dog_grants.{dog}] granted: {reason}",
path.display()
),
}
}
}
impl core::error::Error for ShepTomlError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Parse { source, .. } => Some(source),
Self::WrongShape { .. } | Self::NoDogEntry { .. } | Self::BadGrants { .. } => None,
}
}
}
fn inline_table(env: &BTreeMap<String, String>) -> InlineTable {
let mut table = InlineTable::new();
for (key, value) in env {
table.insert(key.as_str(), Value::from(value.clone()));
}
table
}
mod grants;
mod path;
pub(crate) use grants::GrantRecord;
pub(crate) use path::PathError;
#[cfg(all(test, unix))]
mod tests;