use std::collections::BTreeSet;
use serde_json::{Map, Value as Json};
use shep_client::Client;
use shep_core::dogs::manifest::Serves;
use shep_core::paths::ShepPaths;
use toml_edit::{DocumentMut, Item, Key};
use crate::commands::copy::address;
use crate::commands::settings_write::dog_standard;
use crate::commands::settings_write::value::table_json;
use crate::dog_settings::standard::Standard;
use crate::exit::ExitCode;
use crate::output::Streams;
use super::{DOGS, read_dog_section};
#[derive(Debug)]
pub(super) struct Layering {
pub(super) config: Json,
pub(super) inherited: BTreeSet<Vec<String>>,
pub(super) stray: Vec<String>,
}
pub(super) async fn layer(
client: &Client,
streams: &mut Streams<'_>,
paths: &ShepPaths,
dog: &str,
config: &Json,
) -> Result<Option<Layering>, ExitCode> {
let Some((_, standard)) = dog_standard(dog, paths) else {
return Ok(None);
};
if !serves(&standard, config, dog) {
return Ok(None);
}
let text = read_dog_section(client, streams, dog).await?;
let Ok(doc) = text.parse::<DocumentMut>() else {
return Err(streams.fail(
ExitCode::InvalidConfig,
&format!(
"`{dog}`'s section of `dogs.toml` does not parse: the shepherd read it, so this \
binary cannot say what it holds",
),
));
};
Ok(layer_config(
&standard,
config,
dog,
&table_json(doc.as_table()),
))
}
fn serves(standard: &Standard, config: &Json, dog: &str) -> bool {
standard.serves == Serves::EverySheep || address::dog_table(config, dog).is_some()
}
fn layer_config(
standard: &Standard,
config: &Json,
dog: &str,
section: &Map<String, Json>,
) -> Option<Layering> {
if !serves(standard, config, dog) {
return None;
}
let own = address::dog_table(config, dog)
.and_then(Json::as_object)
.cloned()
.unwrap_or_default();
let stray: Vec<String> = own
.keys()
.filter(|key| standard.is_dog_wide(key))
.cloned()
.collect();
if !stray.is_empty() {
return Some(Layering {
config: config.clone(),
inherited: BTreeSet::new(),
stray,
});
}
let layered = standard.layer(section, &own);
let mut config = config.clone();
let dogs = config.as_object_mut()?;
let dogs = dogs
.entry(DOGS)
.or_insert_with(|| Json::Object(Map::new()))
.as_object_mut()?;
dogs.insert(dog.to_string(), Json::Object(layered.table));
Some(Layering {
config,
inherited: layered.inherited,
stray,
})
}
pub(super) fn stray_warning(sheep: &str, dog: &str, stray: &[String]) -> String {
format!(
"`{sheep}`'s table for `{dog}` sets {}, which {dog} keeps for every sheep, so {dog} reads \
no settings for `{sheep}` until they are removed. The table below is what `{sheep}` \
holds, not what {dog} reads",
stray
.iter()
.map(|key| format!("`{key}`"))
.collect::<Vec<_>>()
.join(", ")
)
}
pub(super) fn covered(steps: &[String], inherited: &BTreeSet<Vec<String>>) -> Vec<Vec<String>> {
let Some(address) = steps.get(2..) else {
return Vec::new();
};
let mut paths: Vec<Vec<String>> = Vec::new();
for path in inherited {
let found = if path.starts_with(address) {
path.clone()
} else if address.starts_with(path) {
address.to_vec()
} else {
continue;
};
if !paths.contains(&found) {
paths.push(found);
}
}
paths
}
pub(super) fn dotted(path: &[String]) -> String {
path.iter()
.map(|segment| Key::new(segment.as_str()).to_string())
.collect::<Vec<_>>()
.join(".")
}
pub(super) fn mark(toml: &str, steps: &[String], dog: &str, inherited: &[Vec<String>]) -> String {
if inherited.is_empty() {
return toml.to_string();
}
let Ok(mut doc) = toml.parse::<DocumentMut>() else {
return toml.to_string();
};
let note = format!(" # inherited from [{dog}]");
let dog_depth = 2;
for path in inherited {
let steps = steps.iter().take(dog_depth).chain(path);
if let Some(item) = locate(doc.as_item_mut(), steps) {
annotate(item, ¬e);
}
}
doc.to_string()
}
fn locate<'a, 'b>(
root: &'a mut Item,
steps: impl Iterator<Item = &'b String>,
) -> Option<&'a mut Item> {
let mut item = root;
for step in steps {
if matches!(item, Item::Value(_)) {
break;
}
item = item.as_table_like_mut()?.get_mut(step.as_str())?;
}
Some(item)
}
fn annotate(item: &mut Item, note: &str) {
match item {
Item::Value(value) => value.decor_mut().set_suffix(note),
Item::Table(table) => {
table.set_implicit(false);
table.decor_mut().set_suffix(note);
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn jobs(serves: Serves) -> Standard {
let root = json!({ "properties": {
"poll": { "x-shep-dog-wide": true },
"model": {},
"limit": {},
} });
let mut standard = Standard::recognise(&root, Serves::EverySheep).expect("standard");
standard.serves = serves;
standard
}
fn map(value: Json) -> Map<String, Json> {
value.as_object().cloned().expect("a table")
}
fn api() -> Json {
json!({ "name": "api", "dogs": { "jobs": { "limit": 9 } } })
}
fn section() -> Map<String, Json> {
map(json!({ "poll": "5m", "model": "sonnet", "limit": 3 }))
}
#[test]
fn the_layered_table_replaces_the_sheeps_and_names_what_it_inherits() {
let layering = layer_config(&jobs(Serves::OptedIn), &api(), "jobs", §ion())
.expect("a sheep with a table is served");
assert_eq!(
layering.config["dogs"]["jobs"],
json!({ "model": "sonnet", "limit": 9 })
);
assert_eq!(layering.inherited, BTreeSet::from([path(&["model"])]));
}
#[test]
fn a_table_holding_a_dog_wide_key_is_not_layered_and_names_the_key() {
let own = json!({ "name": "api", "dogs": { "jobs": { "poll": "1m", "limit": 9 } } });
let layering = layer_config(&jobs(Serves::OptedIn), &own, "jobs", §ion())
.expect("a sheep with a table is served");
assert_eq!(layering.config, own);
assert!(layering.inherited.is_empty());
assert_eq!(layering.stray, ["poll"]);
let warning = stray_warning("api", "jobs", &layering.stray);
assert!(warning.contains("`poll`") && warning.contains("reads no settings"));
}
#[test]
fn an_opted_in_dog_does_not_layer_a_sheep_with_no_table() {
let bare = json!({ "name": "web", "dogs": {} });
assert!(layer_config(&jobs(Serves::OptedIn), &bare, "jobs", §ion()).is_none());
let every = layer_config(&jobs(Serves::EverySheep), &bare, "jobs", §ion())
.expect("every sheep is served");
assert_eq!(
every.config["dogs"]["jobs"],
json!({ "model": "sonnet", "limit": 3 })
);
}
fn path(steps: &[&str]) -> Vec<String> {
steps.iter().map(|step| (*step).to_string()).collect()
}
#[test]
fn the_whole_table_marks_each_inherited_key() {
let toml = "[dogs.jobs]\nlimit = 9\nmodel = \"sonnet\"\n";
let steps = vec!["dogs".to_string(), "jobs".to_string()];
assert_eq!(
mark(toml, &steps, "jobs", &[path(&["model"])]),
"[dogs.jobs]\nlimit = 9\nmodel = \"sonnet\" # inherited from [jobs]\n"
);
}
#[test]
fn a_key_left_inside_a_table_the_sheep_sets_part_of_is_marked() {
let toml = "[dogs.jobs.db]\nhost = \"b\"\nport = 1\n";
let steps = path(&["dogs", "jobs"]);
assert_eq!(
mark(toml, &steps, "jobs", &[path(&["db", "port"])]),
"[dogs.jobs.db]\nhost = \"b\"\nport = 1 # inherited from [jobs]\n"
);
}
#[test]
fn a_path_the_document_lacks_marks_nothing() {
let toml = "[dogs.jobs]\nlimit = 9\n";
let steps = path(&["dogs", "jobs"]);
assert_eq!(mark(toml, &steps, "jobs", &[path(&["db", "port"])]), toml);
}
#[test]
fn one_key_covers_only_itself() {
let inherited = BTreeSet::from([path(&["model"])]);
let key = |name: &str| path(&["dogs", "jobs", name]);
assert_eq!(covered(&key("model"), &inherited), [path(&["model"])]);
assert!(covered(&key("limit"), &inherited).is_empty());
}
#[test]
fn a_table_covers_what_inside_it_is_inherited_and_a_key_inside_one_is_covered_whole() {
let inherited = BTreeSet::from([path(&["db", "port"]), path(&["model"])]);
assert_eq!(
covered(&path(&["dogs", "jobs", "db"]), &inherited),
[path(&["db", "port"])]
);
assert_eq!(
covered(&path(&["dogs", "jobs", "db", "port", "x"]), &inherited),
[path(&["db", "port", "x"])]
);
assert!(covered(&path(&["dogs", "jobs", "db", "host"]), &inherited).is_empty());
}
#[test]
fn a_path_is_dotted_with_a_segment_that_needs_it_quoted() {
assert_eq!(dotted(&path(&["db", "a.b"])), "db.\"a.b\"");
}
}