use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use shep_core::config::{DaemonConfig, DaemonConfigError, parse_daemon_bool};
use crate::commands::shep_toml::{GrantRecord, PathError, ShepToml, ShepTomlError};
use crate::dog::BUILT_IN_DOGS;
use crate::lookout::field::{Field, FieldKind, segment, shep_toml_shape};
use crate::style::{StyleLevel, StyleSource};
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScalarView {
pub value: String,
pub source: StyleSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shape {
Bool,
Integer,
Number,
Text,
List,
}
impl Shape {
#[must_use]
pub fn of(kind: &FieldKind) -> Option<Self> {
match kind {
FieldKind::Bool => Some(Self::Bool),
FieldKind::Integer => Some(Self::Integer),
FieldKind::Number => Some(Self::Number),
FieldKind::Text | FieldKind::Choice(_) | FieldKind::Suggested(_) => Some(Self::Text),
FieldKind::List(_) => Some(Self::List),
FieldKind::Map | FieldKind::Entries(_) | FieldKind::Union(_) | FieldKind::Opaque => {
None
}
}
}
fn parse(self, text: &str) -> Result<toml_edit::Value, SettingError> {
let invalid = |what: &str| SettingError::Invalid(format!("{text} is not {what}"));
Ok(match self {
Self::Bool => parse_daemon_bool(text)
.ok_or_else(|| invalid("a valid boolean"))?
.into(),
Self::Integer => text
.trim()
.parse::<i64>()
.map_err(|_| invalid("a whole number"))?
.into(),
Self::Number => text
.trim()
.parse::<f64>()
.map_err(|_| invalid("a number"))?
.into(),
Self::Text => text.into(),
Self::List => text
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.collect::<toml_edit::Array>()
.into(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SettingEdit {
Set {
path: Vec<String>,
shape: Shape,
value: String,
},
Unset {
path: Vec<String>,
},
}
impl SettingEdit {
#[must_use]
pub fn path(&self) -> &[String] {
match self {
Self::Set { path, .. } | Self::Unset { path } => path,
}
}
#[must_use]
pub fn key(&self) -> String {
dotted(self.path())
}
}
pub(crate) fn dotted(path: &[String]) -> String {
path.iter()
.map(|key| segment(key))
.collect::<Vec<_>>()
.join(".")
}
#[derive(Debug, Clone, PartialEq)]
pub struct SettingsSnapshot {
pub document: Map<String, Value>,
pub socket_default: PathBuf,
pub style: (StyleLevel, StyleSource),
pub dogs: Vec<DogView>,
}
impl SettingsSnapshot {
#[must_use]
pub fn file_value(&self, key: &str) -> Option<&Value> {
let path = shep_toml_shape().paths.get(key)?;
let (first, rest) = path.split_first()?;
let mut value = self.document.get(first)?;
for segment in rest {
value = value.as_object()?.get(segment)?;
}
(!value.is_null()).then_some(value)
}
#[must_use]
pub fn entries(&self, key: &str) -> Vec<(String, String)> {
self.file_value(key)
.and_then(Value::as_object)
.map(|map| {
map.iter()
.map(|(name, value)| (name.clone(), render_value(value)))
.collect()
})
.unwrap_or_default()
}
#[must_use]
pub fn view(&self, field: &Field) -> ScalarView {
if field.key == STYLE_LEVEL_KEY {
return ScalarView {
value: self.style.0.to_string(),
source: self.style.1,
};
}
if let Some(value) = self.file_value(&field.key) {
return ScalarView {
value: render_value(value),
source: StyleSource::Config,
};
}
let value = if field.key == SOCKET_KEY {
self.socket_default.display().to_string()
} else {
field
.default_value
.as_ref()
.map_or_else(|| "not set".to_owned(), render_value)
};
ScalarView {
value,
source: StyleSource::Default,
}
}
#[must_use]
pub fn seed(&self, field: &Field) -> String {
if let Some(value) = self.file_value(&field.key) {
return typed_text(value);
}
if field.key == SOCKET_KEY {
return self.socket_default.display().to_string();
}
field
.default_value
.as_ref()
.map(typed_text)
.unwrap_or_default()
}
}
fn typed_text(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
Value::Array(items) => items
.iter()
.map(|item| {
item.as_str()
.map_or_else(|| item.to_string(), str::to_owned)
})
.collect::<Vec<_>>()
.join(", "),
other => other.to_string(),
}
}
pub(crate) const STYLE_LEVEL_KEY: &str = "style.level";
pub(crate) const SOCKET_KEY: &str = "daemon.socket";
fn render_value(value: &Value) -> String {
match value {
Value::Null => "not set".to_owned(),
Value::String(text) => text.clone(),
Value::Array(items) if items.is_empty() => "none".to_owned(),
Value::Array(_) => typed_text(value),
Value::Object(map) if map.is_empty() => "none".to_owned(),
Value::Object(map) if map.len() == 1 => "1 entry".to_owned(),
Value::Object(map) => format!("{} entries", map.len()),
other => other.to_string(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DogView {
pub name: String,
pub enabled: bool,
pub adopted_path: Option<PathBuf>,
pub boot_first: bool,
pub channel: bool,
pub grants: DogGrants,
pub offered: Vec<String>,
}
impl DogView {
#[cfg_attr(
not(test),
allow(
dead_code,
reason = "the dog fixtures in `lookout::view::fixtures` and `lookout::frames::fixtures` \
are the only callers; `load_settings` builds every live row field by field"
)
)]
#[must_use]
pub fn built_in(name: &str, enabled: bool) -> Self {
Self {
name: name.to_owned(),
enabled,
adopted_path: None,
boot_first: false,
channel: false,
grants: DogGrants::Undeclared,
offered: Vec::new(),
}
}
#[cfg_attr(
not(test),
allow(
dead_code,
reason = "the dog fixtures in `lookout::view::fixtures` and `lookout::frames::fixtures` are the only callers; `load_settings` builds every live row field by field"
)
)]
#[must_use]
pub fn adopted(name: &str, enabled: bool, path: &Path) -> Self {
Self {
adopted_path: Some(path.to_path_buf()),
..Self::built_in(name, enabled)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DogGrants {
Undeclared,
Recorded(GrantRecord),
Unreadable(String),
}
impl DogGrants {
#[must_use]
pub fn permissions(&self) -> Vec<String> {
let Self::Recorded(record) = self else {
return Vec::new();
};
let mut names: BTreeSet<&str> = BTreeSet::new();
names.extend(record.granted.names());
names.extend(record.waiting.names());
names.extend(record.revoked.names());
names.into_iter().map(str::to_owned).collect()
}
#[must_use]
pub fn grants(&self, permission: &str) -> bool {
let Self::Recorded(record) = self else {
return false;
};
record.granted.names().any(|held| held == permission)
}
}
#[derive(Debug)]
pub enum SettingError {
Config(ShepTomlError),
Invalid(String),
}
impl std::fmt::Display for SettingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Config(err) => write!(f, "{err}"),
Self::Invalid(message) => write!(f, "{message}"),
}
}
}
impl core::error::Error for SettingError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Config(err) => Some(err),
Self::Invalid(_) => None,
}
}
}
impl From<ShepTomlError> for SettingError {
fn from(err: ShepTomlError) -> Self {
Self::Config(err)
}
}
impl From<PathError> for SettingError {
fn from(err: PathError) -> Self {
Self::Invalid(err.to_string())
}
}
pub fn load_settings(
path: &Path,
socket_default: &Path,
style: (StyleLevel, StyleSource),
) -> Result<SettingsSnapshot, ShepTomlError> {
let doc = ShepToml::read_only(path)?;
Ok(snapshot_of(&doc, socket_default, style))
}
pub(crate) fn snapshot_of(
doc: &ShepToml,
socket_default: &Path,
style: (StyleLevel, StyleSource),
) -> SettingsSnapshot {
SettingsSnapshot {
document: doc.to_json(),
socket_default: socket_default.to_path_buf(),
style,
dogs: dog_candidates(doc),
}
}
pub(crate) fn dog_candidates(doc: &ShepToml) -> Vec<DogView> {
let enabled = doc.enabled_dog_names();
let boot_first = doc.boot_first_dog_names();
let mut names: BTreeSet<String> = BUILT_IN_DOGS
.iter()
.map(|name| (*name).to_string())
.collect();
names.extend(doc.adopted_dog_names());
names
.into_iter()
.map(|name| {
let adopted_path = doc.adopted_dog_path(&name);
let is_enabled = enabled.contains(&name);
let is_first = boot_first.contains(&name);
let channel = doc.asked_for_channel(&name);
let grants = match doc.grant_record(&name) {
Ok(None) => DogGrants::Undeclared,
Ok(Some(record)) => DogGrants::Recorded(record),
Err(err) => DogGrants::Unreadable(err.to_string()),
};
let offered = offered(&name, adopted_path.is_none(), &grants);
DogView {
name,
enabled: is_enabled,
adopted_path,
boot_first: is_first,
channel,
grants,
offered,
}
})
.collect()
}
fn offered(name: &str, built_in: bool, grants: &DogGrants) -> Vec<String> {
if !matches!(grants, DogGrants::Recorded(_)) {
return Vec::new();
}
let recorded = grants.permissions();
if !built_in {
return recorded;
}
crate::dog::builtin_manifest(name).map_or(recorded, |manifest| {
manifest
.permissions
.into_iter()
.map(|declared| declared.name)
.collect()
})
}
pub fn apply_setting(path: &Path, edit: &SettingEdit) -> Result<(), SettingError> {
check_closed_set(edit)?;
ShepToml::try_edit(path, |doc| -> Result<(), SettingError> {
match edit {
SettingEdit::Set { path, shape, value } => doc.set_path(path, shape.parse(value)?)?,
SettingEdit::Unset { path } => doc.unset_path(path),
}
let rendered = doc.rendered();
DaemonConfig::load(Some(&rendered), &|_| None)
.map_err(|err: DaemonConfigError| SettingError::Invalid(err.to_string()))?;
Ok(())
})
}
fn check_closed_set(edit: &SettingEdit) -> Result<(), SettingError> {
let SettingEdit::Set { value, .. } = edit else {
return Ok(());
};
let Some(Field {
kind: FieldKind::Choice(names),
..
}) = shep_toml_shape().fields.by_key(&edit.key())
else {
return Ok(());
};
if names.contains(value) {
Ok(())
} else {
Err(SettingError::Invalid(format!(
"{value} is not one of {}",
names.join(", ")
)))
}
}