use std::collections::BTreeSet;
use serde_json::Value as Json;
use shep_core::config::DogTable;
use toml_edit::DocumentMut;
use super::edit::{document_of, dotted, set_toml};
use super::value::table_json;
use super::{Plan, Write, WriteRefusal};
use crate::commands::copy::address;
use crate::commands::copy::plan::{Change, ChangeKind, collect_changes, set_path, walk};
use crate::dog_settings::table::{table_refusal, touched_refusal};
pub(super) fn plan_dog_table(
sheep: &str,
dog: &str,
keys: &[String],
config: &Json,
value: &Json,
) -> Result<Plan, WriteRefusal> {
let before = Json::Object(
address::dog_table(config, dog)
.and_then(Json::as_object)
.cloned()
.unwrap_or_default(),
);
let mut after = before.clone();
if !keys.is_empty() && !value.is_object() && walk(&before, keys).is_some_and(Json::is_object) {
let at = if keys.is_empty() {
format!("{sheep}.dogs.{dog}")
} else {
format!("{sheep}.dogs.{dog}.{}", keys.join("."))
};
return Err(WriteRefusal::Refused(format!(
"`{at}` holds a table, and `shep set` does not replace a table with a value that \
is not one"
)));
}
set_path(&mut after, keys, value.clone()).map_err(|held| {
WriteRefusal::Refused(format!(
"`{sheep}.dogs.{dog}{}` holds a value that is not a table, and `shep set` cannot \
write `{}` through it",
dotted(&held),
keys.join(".")
))
})?;
let Some(table) = after.as_object().cloned() else {
return Err(WriteRefusal::Refused(format!(
"`{sheep}.dogs.{dog}` names a whole dog table: the value has to be one, as \
`{{ keep = 1 }}`"
)));
};
let mut changes = Vec::new();
collect_changes(
walk(&before, keys),
walk(&Json::Object(table.clone()), keys),
"",
&mut changes,
);
name_the_path(&mut changes, keys);
Ok(Plan {
changes,
write: Write::SheepDogTable {
dog: dog.to_string(),
table: DogTable::from(table.clone()),
before,
},
table,
schema: None,
touched: touched_paths(keys, value),
})
}
pub(super) fn plan_section(
doc: DocumentMut,
name: &str,
keys: &[String],
value: &Json,
) -> Result<Plan, WriteRefusal> {
let mut doc = doc;
let answered = doc.to_string();
let before = table_json(doc.as_table());
if keys.is_empty() {
let Json::Object(map) = value else {
return Err(WriteRefusal::Usage(
"a whole section takes a whole table: the value has to read as `{ keep = 1 }`, or \
name a key inside the section, as `kelpie.worker.timeout`"
.to_string(),
));
};
doc = document_of(map).map_err(|held| held.refusal(name, keys))?;
} else {
set_toml(doc.as_table_mut(), keys, "", value).map_err(|held| held.refusal(name, keys))?;
}
let toml = doc.to_string();
let after = table_json(doc.as_table());
let mut changes = Vec::new();
collect_changes(
walk(&Json::Object(before), keys),
walk(&Json::Object(after.clone()), keys),
"",
&mut changes,
);
name_the_path(&mut changes, keys);
Ok(Plan {
changes,
write: Write::DogSection {
toml,
before: answered,
},
table: after,
schema: None,
touched: touched_paths(keys, value),
})
}
pub(super) fn field_changes(field: &str, config: &Json, value: &Json) -> Vec<Change> {
let old = config.get(field).cloned();
if old.as_ref() == Some(value) {
return Vec::new();
}
let kind = match old {
Some(_) => ChangeKind::Changed,
None => ChangeKind::Added,
};
vec![Change {
key: field.to_string(),
kind,
old,
new: Some(value.clone()),
}]
}
pub(super) fn name_the_path(changes: &mut [Change], keys: &[String]) {
let Some(last) = keys.last() else {
return;
};
for change in changes {
if change.key.is_empty() {
change.key = last.clone();
}
}
}
pub(super) fn touched_paths(keys: &[String], value: &Json) -> BTreeSet<Vec<String>> {
let mut touched = BTreeSet::new();
if !keys.is_empty() {
leaves(keys.to_vec(), value, &mut touched);
}
touched
}
fn leaves(path: Vec<String>, value: &Json, out: &mut BTreeSet<Vec<String>>) {
let Json::Object(map) = value else {
out.insert(path);
return;
};
if map.is_empty() {
out.insert(path);
return;
}
for (key, nested) in map {
let mut step = path.clone();
step.push(key.clone());
leaves(step, nested, out);
}
}
pub(super) fn schema_refusal(plan: &Plan, name: &str, path: &str) -> Option<WriteRefusal> {
let schema = plan.schema.as_ref()?;
let table = &plan.table;
let refused = if plan.touched.is_empty() {
table_refusal(schema, table)
} else {
touched_refusal(schema, table, &plan.touched)
}?;
Some(WriteRefusal::Invalid(format!(
"nothing was written: `{name}.{path}` is not a value the flock's schema takes. {}",
refused.text
)))
}