use std::path::PathBuf;
use std::time::Duration;
use serde_json::{Map, Value};
use shep_core::config::DogsConfig;
use shep_core::dogs::SHEEP_SCHEMA_KEY;
use shep_core::dogs::manifest::Serves;
use shep_core::paths::ShepPaths;
use super::pane::{ListedDog, SheepDogEntry, StandardSection};
use crate::commands::settings::dog_candidates;
use crate::commands::settings_write::{asked_schema_serving, builtin_schema_serving};
use crate::commands::shep_toml::ShepToml;
use crate::dog_settings::standard::Standard;
pub(super) fn list_sheep_dogs(paths: &ShepPaths) -> Vec<ListedDog> {
let Ok(doc) = ShepToml::read_only(&paths.daemon_config) else {
return Vec::new();
};
dog_candidates(&doc)
.into_iter()
.map(|dog| match builtin_schema_serving(&dog.name) {
Some(answer) => ListedDog {
entry: entry_of(paths, dog.name, dog.adopted_path, Some(answer)),
asked: true,
},
None => ListedDog {
asked: dog.adopted_path.is_none(),
entry: entry_of(paths, dog.name, dog.adopted_path, None),
},
})
.collect()
}
pub(super) fn probe_sheep_dog(
paths: &ShepPaths,
dog: &str,
adopted_path: Option<PathBuf>,
budget: Duration,
) -> SheepDogEntry {
let answer = builtin_schema_serving(dog).or_else(|| {
adopted_path
.as_deref()
.and_then(|path| asked_schema_serving(path, &paths.home, dog, budget))
});
entry_of(paths, dog.to_owned(), adopted_path, answer)
}
fn entry_of(
paths: &ShepPaths,
name: String,
adopted_path: Option<PathBuf>,
answer: Option<(Value, Serves)>,
) -> SheepDogEntry {
let Some((root, serves)) = answer else {
return SheepDogEntry {
name,
adopted_path,
schema: None,
standard: None,
};
};
match Standard::recognise(&root, serves) {
Some(standard) => SheepDogEntry {
schema: Some(standard.sheep_form(&root)),
standard: Some(StandardSection {
serves,
shared: standard.shared(§ion_of(paths, &name)),
}),
name,
adopted_path,
},
None => SheepDogEntry {
schema: sheep_schema(&root),
standard: None,
name,
adopted_path,
},
}
}
fn section_of(paths: &ShepPaths, name: &str) -> Map<String, Value> {
let source = std::fs::read_to_string(&paths.dogs_config).ok();
DogsConfig::load(source.as_deref())
.ok()
.and_then(|config| config.dog.get(name).cloned())
.and_then(|table| serde_json::to_value(table).ok())
.and_then(|value| value.as_object().cloned())
.unwrap_or_default()
}
pub(crate) fn sheep_schema(root: &Value) -> Option<Value> {
let mut sheep = root.get(SHEEP_SCHEMA_KEY)?.as_object()?.clone();
if let Some(root_defs) = root.get("$defs").and_then(Value::as_object) {
let defs = sheep
.entry("$defs")
.or_insert_with(|| Value::Object(Map::new()));
if let Value::Object(defs) = defs {
for (name, def) in root_defs {
defs.entry(name.clone()).or_insert_with(|| def.clone());
}
}
}
Some(Value::Object(sheep))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn the_sheep_schema_carries_the_roots_defs() {
let root = json!({
"properties": { "poll": { "type": "string" } },
"x-shep-sheep": { "$ref": "#/$defs/ProjectSettings" },
"$defs": { "ProjectSettings": { "type": "object" } },
});
assert_eq!(
sheep_schema(&root),
Some(json!({
"$ref": "#/$defs/ProjectSettings",
"$defs": { "ProjectSettings": { "type": "object" } },
}))
);
}
#[test]
fn a_schema_without_the_key_or_with_a_bare_value_has_none() {
assert_eq!(sheep_schema(&json!({ "properties": {} })), None);
assert_eq!(sheep_schema(&json!({ "x-shep-sheep": true })), None);
}
#[test]
fn the_sheep_schemas_own_definition_wins() {
let root = json!({
"x-shep-sheep": { "$defs": { "Hours": { "type": "string" } } },
"$defs": { "Hours": { "type": "object" }, "Other": {} },
});
let sheep = sheep_schema(&root).expect("a sheep schema");
assert_eq!(sheep["$defs"]["Hours"], json!({ "type": "string" }));
assert_eq!(sheep["$defs"]["Other"], json!({}));
}
#[test]
fn a_shep_toml_that_does_not_parse_lists_no_dog() {
let dir = tempfile::tempdir().unwrap();
let config = dir.path().join("shep.toml");
std::fs::write(&config, "[daemon\n").unwrap();
assert!(list_sheep_dogs(&ShepPaths::at(dir.path().to_path_buf())).is_empty());
}
#[cfg(unix)]
#[test]
fn listing_names_an_adopted_dog_and_never_runs_it() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("ran");
let script = dir.path().join("shep-jobs");
std::fs::write(&script, format!("#!/bin/sh\ntouch {}\n", marker.display())).unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let config = dir.path().join("shep.toml");
std::fs::write(
&config,
format!(
"[daemon.adopted_dogs]\njobs = {:?}\n",
script.display().to_string()
),
)
.unwrap();
let dogs = list_sheep_dogs(&ShepPaths::at(dir.path().to_path_buf()));
let jobs = dogs
.iter()
.find(|dog| dog.entry.name == "jobs")
.expect("jobs");
assert!(!jobs.asked);
assert_eq!(jobs.entry.adopted_path.as_deref(), Some(script.as_path()));
assert_eq!(jobs.entry.schema, None);
let bark = dogs
.iter()
.find(|dog| dog.entry.name == "bark")
.expect("a built-in");
assert!(bark.asked);
assert_eq!(bark.entry.schema, None);
assert!(!marker.exists(), "listing ran the adopted binary");
}
#[cfg(unix)]
#[test]
fn an_adopted_dog_is_asked_and_a_silent_one_has_no_schema() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("shep-jobs");
let schema = json!({
"properties": {},
"x-shep-sheep": { "$ref": "#/$defs/Settings" },
"$defs": { "Settings": { "type": "object" } },
});
std::fs::write(
&script,
format!("#!/bin/sh\n[ \"$1\" = --schema ] && echo '{schema}'\nexit 0\n"),
)
.unwrap();
let quiet = dir.path().join("shep-quiet");
std::fs::write(&quiet, "#!/bin/sh\nexit 0\n").unwrap();
let broken = dir.path().join("shep-broken");
std::fs::write(&broken, "#!/bin/sh\necho '{not json'\nexit 0\n").unwrap();
for bin in [&script, &quiet, &broken] {
std::fs::set_permissions(bin, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let budget = Duration::from_secs(30);
let jobs = probe_sheep_dog(
&ShepPaths::at(dir.path().to_path_buf()),
"jobs",
Some(script.clone()),
budget,
);
assert_eq!(jobs.adopted_path.as_deref(), Some(script.as_path()));
assert_eq!(
jobs.schema,
Some(json!({
"$ref": "#/$defs/Settings",
"$defs": { "Settings": { "type": "object" } },
}))
);
for (name, bin) in [("quiet", quiet), ("broken", broken)] {
let dog = probe_sheep_dog(
&ShepPaths::at(dir.path().to_path_buf()),
name,
Some(bin),
budget,
);
assert_eq!(dog.schema, None, "{name} answers no schema");
}
}
#[cfg(unix)]
#[test]
fn a_standard_dog_is_probed_into_its_sheep_form_and_its_shared_section() {
let dir = tempfile::tempdir().unwrap();
crate::commands::standard_dog_testing::adopt_jobs(dir.path(), Serves::OptedIn);
std::fs::write(
dir.path().join("dogs.toml"),
"[jobs]\npoll = \"5m\"\nmodel = \"sonnet\"\n[other]\nmodel = \"x\"\n",
)
.unwrap();
let paths = ShepPaths::at(dir.path().to_path_buf());
let script = dir.path().join("shep-jobs");
let jobs = probe_sheep_dog(&paths, "jobs", Some(script), Duration::from_secs(30));
let schema = jobs.schema.expect("a schema");
assert!(schema["properties"].get("poll").is_none(), "{schema}");
assert!(schema["properties"].get("model").is_some(), "{schema}");
let section = jobs.standard.expect("a standard dog");
assert_eq!(section.serves, Serves::OptedIn);
assert_eq!(Value::Object(section.shared), json!({ "model": "sonnet" }));
}
#[cfg(unix)]
#[test]
fn a_dog_with_a_second_sheep_schema_is_probed_as_before() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("shep-jobs");
let schema = json!({
"properties": { "poll": { "x-shep-dog-wide": true } },
"x-shep-sheep": { "type": "object" },
});
std::fs::write(
&script,
format!("#!/bin/sh\n[ \"$1\" = --schema ] && echo '{schema}'\nexit 0\n"),
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let paths = ShepPaths::at(dir.path().to_path_buf());
let jobs = probe_sheep_dog(&paths, "jobs", Some(script), Duration::from_secs(30));
assert_eq!(jobs.schema, Some(json!({ "type": "object" })));
assert_eq!(jobs.standard, None);
}
#[cfg(unix)]
#[test]
fn a_dog_whose_manifest_lacks_the_field_is_probed_as_before() {
let dir = tempfile::tempdir().unwrap();
crate::commands::standard_dog_testing::adopt_jobs_without_the_standard(dir.path(), true);
let paths = ShepPaths::at(dir.path().to_path_buf());
let script = dir.path().join("shep-jobs");
let jobs = probe_sheep_dog(&paths, "jobs", Some(script), Duration::from_secs(30));
assert_eq!(jobs.standard, None);
assert_eq!(
jobs.schema,
Some(json!({
"type": "object",
"properties": { "model": { "type": "string" }, "limit": { "type": "integer" } },
}))
);
}
#[cfg(unix)]
#[test]
fn a_dog_with_plain_settings_and_no_field_has_no_sheep_schema() {
let dir = tempfile::tempdir().unwrap();
crate::commands::standard_dog_testing::adopt_jobs_without_the_standard(dir.path(), false);
let paths = ShepPaths::at(dir.path().to_path_buf());
let script = dir.path().join("shep-jobs");
let jobs = probe_sheep_dog(&paths, "jobs", Some(script), Duration::from_secs(30));
assert_eq!(jobs.standard, None);
assert_eq!(jobs.schema, None);
}
}