use std::collections::BTreeSet;
use serde_json::{Map, Value};
use shep_core::config::{AppConfig, ApplyGroup, apply_group};
use super::CopyRefusal;
const DOGS: &str = "dogs";
#[derive(Debug, Clone, Default)]
pub(crate) struct FlockNames {
pub(crate) sheep: BTreeSet<String>,
pub(crate) dogs: BTreeSet<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Address {
pub(crate) sheep: String,
pub(crate) path: ConfigPath,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ConfigPath {
Field {
name: String,
},
Dog {
dog: String,
keys: Vec<String>,
},
}
impl ConfigPath {
pub(crate) fn dotted(&self) -> String {
match self {
Self::Field { name } => name.clone(),
Self::Dog { dog, keys } => {
let mut out = format!("{DOGS}.{dog}");
for key in keys {
out.push('.');
out.push_str(key);
}
out
}
}
}
}
pub(crate) fn written(raw: &str, flock: &FlockNames) -> Result<Written, CopyRefusal> {
let segments: Vec<&str> = raw.split('.').collect();
if segments.iter().any(|part| part.is_empty()) {
return Err(CopyRefusal::Usage(format!(
"`{raw}` has an empty part: a sheep name and a path segment are each at least one \
character"
)));
}
match longest_sheep(&segments, flock) {
Some(name) if name.len() == raw.len() => Err(CopyRefusal::Usage(format!(
"`{raw}` names the sheep `{name}` and no setting: write `{name}.restart_delay`, \
`{name}.{DOGS}.<dog>`, or `{name}.{DOGS}.<dog>.<key>`"
))),
Some(name) => {
let depth = name.split('.').count();
Ok(Written {
sheep: name.clone(),
segments: segments[depth..]
.iter()
.map(|part| (*part).to_string())
.collect(),
})
}
None => Err(unknown_sheep(&segments, flock)),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Written {
pub(crate) sheep: String,
pub(crate) segments: Vec<String>,
}
#[derive(Debug)]
pub(crate) struct DogNames {
tables: BTreeSet<String>,
flock: BTreeSet<String>,
}
impl DogNames {
#[must_use]
pub(crate) fn new(flock: &FlockNames, source: &Value, target: &Value) -> Self {
let mut tables = BTreeSet::new();
for config in [source, target] {
if let Some(dogs) = dogs_of(config) {
tables.extend(dogs.keys().cloned());
}
}
Self {
tables,
flock: flock.dogs.clone(),
}
}
#[must_use]
pub(crate) fn longest(&self, rest: &[String]) -> String {
longest_in(rest, &self.tables)
.or_else(|| longest_in(rest, &self.flock))
.unwrap_or_else(|| rest[0].clone())
}
}
fn longest_in(rest: &[String], names: &BTreeSet<String>) -> Option<String> {
names
.iter()
.filter(|name| {
let parts: Vec<&str> = name.split('.').collect();
parts.len() <= rest.len() && parts == rest[..parts.len()]
})
.max_by_key(|name| name.split('.').count())
.cloned()
}
pub(crate) fn dogs_of(config: &Value) -> Option<&Map<String, Value>> {
config.get(DOGS).and_then(Value::as_object)
}
pub(crate) fn dog_table<'a>(config: &'a Value, dog: &str) -> Option<&'a Value> {
dogs_of(config).and_then(|dogs| dogs.get(dog))
}
pub(crate) fn path_of(
written: &Written,
known_dogs: &DogNames,
source: &Value,
target: &Value,
) -> Result<ConfigPath, CopyRefusal> {
let sheep = written.sheep.as_str();
let segments = written.segments.as_slice();
let Some(first) = segments.first() else {
return Err(CopyRefusal::Usage(format!(
"`{sheep}` names a sheep and no setting"
)));
};
if *first == DOGS {
if segments.len() == 1 {
return Err(CopyRefusal::Refused(format!(
"the whole `dogs` map is not one thing to copy: name a dog, as \
`{sheep}.{DOGS}.<dog>`"
)));
}
let rest = &segments[1..];
let dog = known_dogs.longest(rest);
let depth = dog.split('.').count();
let keys = resolve_keys(
&rest[depth..],
dog_table(source, &dog),
dog_table(target, &dog),
);
return Ok(ConfigPath::Dog { dog, keys });
}
if segments.len() > 1 {
return Err(CopyRefusal::Usage(format!(
"a path is one field, `{DOGS}.<dog>`, or `{DOGS}.<dog>.<key>`; `{sheep}.{}` is none \
of those",
segments.join(".")
)));
}
let field = first.as_str();
if field == "env" {
return Err(CopyRefusal::Refused(
"`env` is not copied: a client is never sent a sheep's env values, so there is \
nothing here to read. `shep import env` writes a `.env` into a sheep's env, and \
`shep secret` holds the values a Flockfile refers to"
.to_string(),
));
}
if apply_group(field) == ApplyGroup::Structural {
return Err(CopyRefusal::Refused(format!(
"`{field}` is not a config write: a name change makes a different sheep, and \
`shep stock {sheep} <count>` moves an instance count"
)));
}
if !app_fields().any(|name| name == field) {
return Err(CopyRefusal::Usage(format!(
"`{field}` is not a field a sheep's config has; `shep describe {sheep}` shows the \
config a copy reads"
)));
}
Ok(ConfigPath::Field {
name: field.to_string(),
})
}
pub(crate) fn resolve_keys(
rest: &[String],
source: Option<&Value>,
target: Option<&Value>,
) -> Vec<String> {
let mut keys = Vec::new();
let mut src = source;
let mut tgt = target;
let mut next = 0;
while next < rest.len() {
let taken = longest_key(&rest[next..], src, tgt);
keys.push(taken.join("."));
for part in &taken {
src = src.and_then(|value| value.as_object().and_then(|map| map.get(part)));
tgt = tgt.and_then(|value| value.as_object().and_then(|map| map.get(part)));
}
next += taken.len();
}
keys
}
fn longest_key(rest: &[String], source: Option<&Value>, target: Option<&Value>) -> Vec<String> {
for len in (1..=rest.len()).rev() {
let name = rest[..len].join(".");
if holds(source, &name) || holds(target, &name) {
return rest[..len].to_vec();
}
}
rest[..1].to_vec()
}
fn holds(table: Option<&Value>, key: &str) -> bool {
table.is_some_and(|value| value.as_object().is_some_and(|map| map.contains_key(key)))
}
fn longest_sheep<'a>(segments: &[&'a str], flock: &'a FlockNames) -> Option<&'a String> {
flock
.sheep
.iter()
.filter(|name| {
let parts: Vec<&str> = name.split('.').collect();
parts.len() <= segments.len() && parts == segments[..parts.len()]
})
.max_by_key(|name| name.split('.').count())
}
fn unknown_sheep(segments: &[&str], flock: &FlockNames) -> CopyRefusal {
let head = segments[0];
if flock.dogs.contains(head) {
return CopyRefusal::UnknownSheep(format!(
"no sheep named `{}`: `{head}` is a dog, and a copy writes a sheep's config",
segments.join(".")
));
}
let depth = segments
.iter()
.skip(1)
.position(is_path_root)
.map_or(segments.len(), |i| i + 1);
CopyRefusal::UnknownSheep(format!(
"no sheep named `{}`; `shep flock` lists the flock",
segments[..depth].join(".")
))
}
fn is_path_root(segment: &&str) -> bool {
*segment == DOGS || app_fields().any(|name| name == *segment)
}
fn app_fields() -> impl Iterator<Item = &'static str> {
STATIC_FIELDS.iter().map(String::as_str)
}
static STATIC_FIELDS: std::sync::LazyLock<Vec<String>> = std::sync::LazyLock::new(|| {
serde_json::to_value(AppConfig::default())
.ok()
.and_then(|value| value.as_object().cloned())
.map(|object| object.keys().cloned().collect())
.unwrap_or_default()
});
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn flock() -> FlockNames {
FlockNames {
sheep: ["web", "api", "we.b", "web.watch"]
.iter()
.map(|name| (*name).to_string())
.collect(),
dogs: ["metrics", "kelpie"]
.iter()
.map(|name| (*name).to_string())
.collect(),
}
}
fn parse(raw: &str, flock: &FlockNames) -> Result<Address, CopyRefusal> {
parse_against(raw, flock, &Value::Null, &Value::Null)
}
fn parse_against(
raw: &str,
flock: &FlockNames,
source: &Value,
target: &Value,
) -> Result<Address, CopyRefusal> {
let written = written(raw, flock)?;
let dogs = DogNames::new(flock, source, target);
Ok(Address {
sheep: written.sheep.clone(),
path: path_of(&written, &dogs, source, target)?,
})
}
#[test]
fn a_field_a_dog_table_and_a_dog_key_each_parse() {
let cases = [
(
"web.restart_delay",
ConfigPath::Field {
name: "restart_delay".into(),
},
),
(
"web.dogs.kelpie",
ConfigPath::Dog {
dog: "kelpie".into(),
keys: vec![],
},
),
(
"web.dogs.kelpie.rulings",
ConfigPath::Dog {
dog: "kelpie".into(),
keys: vec!["rulings".into()],
},
),
(
"web.dogs.kelpie.rulings.panel",
ConfigPath::Dog {
dog: "kelpie".into(),
keys: vec!["rulings".into(), "panel".into()],
},
),
];
for (raw, expected) in cases {
let address = parse(raw, &flock()).unwrap_or_else(|err| panic!("{raw}: {err}"));
assert_eq!(address.sheep, "web");
assert_eq!(address.path, expected, "{raw}");
assert_eq!(address.path.dotted(), raw["web.".len()..], "{raw}");
}
}
#[test]
fn a_sheep_name_with_a_dot_resolves_against_the_flock() {
let address = parse("we.b.dogs.kelpie", &flock()).unwrap();
assert_eq!(address.sheep, "we.b");
assert_eq!(
address.path,
ConfigPath::Dog {
dog: "kelpie".into(),
keys: vec![]
}
);
}
#[test]
fn the_longest_flock_name_wins_over_a_field_name() {
let address = parse("web.watch.restart_delay", &flock()).unwrap();
assert_eq!(address.sheep, "web.watch");
assert_eq!(
address.path,
ConfigPath::Field {
name: "restart_delay".into()
}
);
}
#[test]
fn an_unknown_sheep_is_named_whole() {
let err = parse("we.c.dogs.kelpie", &flock()).unwrap_err();
assert_eq!(
err,
CopyRefusal::UnknownSheep(
"no sheep named `we.c`; `shep flock` lists the flock".to_string()
)
);
}
#[test]
fn a_dog_name_is_refused_as_a_dog() {
let err = parse("metrics.dogs.kelpie", &flock()).unwrap_err();
assert_eq!(
err,
CopyRefusal::UnknownSheep(
"no sheep named `metrics.dogs.kelpie`: `metrics` is a dog, and a copy writes a \
sheep's config"
.to_string()
)
);
}
#[test]
fn an_address_with_no_path_names_the_remedies() {
let err = parse("web", &flock()).unwrap_err();
let CopyRefusal::Usage(message) = err else {
panic!("expected a usage refusal, got {err:?}");
};
assert!(message.contains("web.restart_delay"), "{message}");
assert!(message.contains("web.dogs.<dog>"), "{message}");
}
#[test]
fn an_empty_part_is_refused() {
assert!(matches!(
parse("web.dogs..kelpie", &flock()),
Err(CopyRefusal::Usage(_))
));
}
#[test]
fn env_name_instances_and_the_whole_dogs_map_each_say_why() {
let env = parse("web.env", &flock()).unwrap_err();
assert!(env.message().contains("shep import env"), "{env}");
assert!(env.message().contains("shep secret"), "{env}");
let instances = parse("web.instances", &flock()).unwrap_err();
assert!(
instances.message().contains("shep stock web"),
"{instances}"
);
let name = parse("web.name", &flock()).unwrap_err();
assert!(name.message().contains("different sheep"), "{name}");
let dogs = parse("web.dogs", &flock()).unwrap_err();
assert!(dogs.message().contains("web.dogs.<dog>"), "{dogs}");
}
#[test]
fn a_field_the_config_type_has_no_such_field_is_refused_by_name() {
let err = parse("web.watchdog", &flock()).unwrap_err();
assert_eq!(
err,
CopyRefusal::Usage(
"`watchdog` is not a field a sheep's config has; `shep describe web` shows the \
config a copy reads"
.to_string()
)
);
}
#[test]
fn a_path_that_is_neither_a_field_nor_a_dog_is_refused() {
let err = parse("web.restart_delay.extra", &flock()).unwrap_err();
assert_eq!(
err,
CopyRefusal::Usage(
"a path is one field, `dogs.<dog>`, or `dogs.<dog>.<key>`; `web.restart_delay.extra` \
is none of those"
.to_string()
)
);
}
#[test]
fn a_dog_key_may_run_deep() {
let address = parse("web.dogs.kelpie.rulings.panel.extra", &flock()).unwrap();
assert_eq!(
address.path,
ConfigPath::Dog {
dog: "kelpie".into(),
keys: vec!["rulings".into(), "panel".into(), "extra".into()],
}
);
}
#[test]
fn a_dog_name_with_a_dot_resolves_against_the_tables() {
let source = json!({ "dogs": { "foo.bar": { "enabled": true } } });
let target = json!({ "dogs": { "foo.bar": {} } });
let whole = parse_against("web.dogs.foo.bar", &flock(), &source, &target).unwrap();
assert_eq!(
whole.path,
ConfigPath::Dog {
dog: "foo.bar".into(),
keys: vec![]
}
);
let one = parse_against("web.dogs.foo.bar.enabled", &flock(), &source, &target).unwrap();
assert_eq!(
one.path,
ConfigPath::Dog {
dog: "foo.bar".into(),
keys: vec!["enabled".into()]
}
);
assert_eq!(
one.path.dotted(),
"dogs.foo.bar.enabled",
"it prints back as it was typed"
);
}
#[test]
fn a_dotted_dog_name_falls_back_to_a_key_when_no_table_holds_it() {
let source = json!({ "dogs": { "foo": { "bar": { "enabled": true } } } });
let address = parse_against("web.dogs.foo.bar", &flock(), &source, &Value::Null).unwrap();
assert_eq!(
address.path,
ConfigPath::Dog {
dog: "foo".into(),
keys: vec!["bar".into()]
}
);
}
#[test]
fn a_key_with_a_dot_resolves_against_the_table() {
let source = json!({ "dogs": { "kelpie": { "api.token": "abc", "api": { "limit": 5 } } } });
let dotted =
parse_against("web.dogs.kelpie.api.token", &flock(), &source, &Value::Null).unwrap();
assert_eq!(
dotted.path,
ConfigPath::Dog {
dog: "kelpie".into(),
keys: vec!["api.token".into()]
},
"the longest key the table actually holds wins"
);
let nested =
parse_against("web.dogs.kelpie.api.limit", &flock(), &source, &Value::Null).unwrap();
assert_eq!(
nested.path,
ConfigPath::Dog {
dog: "kelpie".into(),
keys: vec!["api".into(), "limit".into()]
}
);
}
#[test]
fn a_table_on_either_side_makes_the_name() {
let target = json!({ "dogs": { "foo.bar": { "enabled": false } } });
let address = parse_against("api.dogs.foo.bar", &flock(), &Value::Null, &target).unwrap();
assert_eq!(
address.path,
ConfigPath::Dog {
dog: "foo.bar".into(),
keys: vec![]
}
);
}
#[test]
fn a_flock_dog_name_yields_to_a_table_the_sheep_holds() {
let flock = FlockNames {
sheep: ["web", "api"]
.iter()
.map(|name| (*name).to_string())
.collect(),
dogs: ["foo.bar"].iter().map(|name| (*name).to_string()).collect(),
};
let source = json!({ "dogs": { "foo": { "bar": { "enabled": true } } } });
let target = json!({ "dogs": { "foo": {} } });
let address = parse_against("web.dogs.foo.bar", &flock, &source, &target).unwrap();
assert_eq!(
address.path,
ConfigPath::Dog {
dog: "foo".into(),
keys: vec!["bar".into()]
},
"the reading with something to copy is the reading the address takes"
);
}
#[test]
fn a_flock_dog_name_stands_when_no_table_holds_it() {
let flock = FlockNames {
sheep: ["web", "api"]
.iter()
.map(|name| (*name).to_string())
.collect(),
dogs: ["foo.bar"].iter().map(|name| (*name).to_string()).collect(),
};
let address =
parse_against("web.dogs.foo.bar", &flock, &Value::Null, &Value::Null).unwrap();
assert_eq!(
address.path,
ConfigPath::Dog {
dog: "foo.bar".into(),
keys: vec![]
}
);
}
}