use alloc::{
string::{String, ToString},
vec::Vec,
};
use std::path::{Path, PathBuf};
use crate::{Error, Result, settings::ShepherdConfig, types::Harness};
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub enum ConfigTier {
Project,
User,
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ConfigCandidate {
pub path: PathBuf,
pub tier: ConfigTier,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct ConfigContext {
pub primary_root: PathBuf,
pub user_home: Option<PathBuf>,
pub harness: Option<Harness>,
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ConfigSource {
pub path: PathBuf,
}
#[derive(Clone, Debug, PartialEq)]
pub struct LoadedConfig {
pub config: ShepherdConfig,
pub sources: Vec<ConfigSource>,
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum LoadMode {
#[default]
Strict,
LayoutV5Migration,
}
pub fn candidates(cx: &ConfigContext) -> Vec<ConfigCandidate> {
let namespace = cx.primary_root.join(".shepherd");
let capacity = if cx.harness.is_some() { 6 } else { 4 };
let mut out = Vec::with_capacity(capacity);
push_tier(&mut out, &namespace, ConfigTier::Project, cx.harness);
if let Some(user_home) = &cx.user_home
&& user_home != &namespace
{
push_tier(&mut out, user_home, ConfigTier::User, cx.harness);
}
out
}
fn push_tier(
out: &mut Vec<ConfigCandidate>,
root: &Path,
tier: ConfigTier,
harness: Option<Harness>,
) {
out.push(ConfigCandidate {
path: root.join("shepherd.local.toml"),
tier,
});
if let Some(harness) = harness {
out.push(ConfigCandidate {
path: root.join(alloc::format!("shepherd.{harness}.toml")),
tier,
});
}
out.push(ConfigCandidate {
path: root.join("shepherd.toml"),
tier,
});
}
pub fn load<'a, I>(layers: I) -> Result<LoadedConfig>
where
I: IntoIterator<Item = (&'a Path, &'a str)>,
{
load_with_mode(layers, LoadMode::Strict)
}
pub fn load_for_layout_v5_migration<'a, I>(layers: I) -> Result<LoadedConfig>
where
I: IntoIterator<Item = (&'a Path, &'a str)>,
{
load_with_mode(layers, LoadMode::LayoutV5Migration)
}
pub fn load_with_mode<'a, I>(layers: I, mode: LoadMode) -> Result<LoadedConfig>
where
I: IntoIterator<Item = (&'a Path, &'a str)>,
{
let ordered: Vec<(&Path, &str)> = layers.into_iter().collect();
let mut parsed = Vec::with_capacity(ordered.len());
for (path, contents) in &ordered {
parsed.push(parse_layer(path, contents, mode)?);
}
let mut merged = toml::Value::Table(toml::Table::new());
for value in parsed.into_iter().rev() {
merge_value(&mut merged, value);
}
let config = deserialize(
ordered.first().map_or(Path::new("<defaults>"), |v| v.0),
merged,
)?;
Ok(LoadedConfig {
config,
sources: ordered
.into_iter()
.map(|(path, _)| ConfigSource {
path: path.to_path_buf(),
})
.collect(),
})
}
pub fn layer<'a, I>(layers: I) -> Result<ShepherdConfig>
where
I: IntoIterator<Item = (&'a Path, &'a str)>,
{
load(layers).map(|loaded| loaded.config)
}
pub fn validate(path: &Path, contents: &str) -> Result {
parse_layer(path, contents, LoadMode::Strict).map(|_| ())
}
fn parse_layer(path: &Path, contents: &str, mode: LoadMode) -> Result<toml::Value> {
let mut value: toml::Value = toml::from_str(contents).map_err(|error| {
Error::config(alloc::format!("{}: {}", path.display(), error.message()))
})?;
if mode == LoadMode::LayoutV5Migration {
strip_retired_layout_v5(path, &mut value)?;
}
validate_gate_entries(path, &value)?;
deserialize(path, value.clone())?;
Ok(value)
}
fn strip_retired_layout_v5(path: &Path, value: &mut toml::Value) -> Result {
let Some(root) = value.as_table_mut() else {
return Ok(());
};
if let Some(paths) = root.get_mut("paths").and_then(toml::Value::as_table_mut) {
remove_legacy_string(path, paths, "paths", "plans")?;
remove_legacy_string(path, paths, "paths", "reports")?;
}
if let Some(memory) = root.remove("memory") {
validate_retired_memory(path, memory)?;
}
if let Some(context) = root.get_mut("context").and_then(toml::Value::as_table_mut) {
remove_legacy_bool(path, context, "context", "enabled")?;
for field in ["db_path", "lock_path", "project_id_path"] {
remove_legacy_string(path, context, "context", field)?;
}
remove_legacy_string(path, context, "context", "announce_shctx_path")?;
}
Ok(())
}
fn validate_retired_memory(path: &Path, memory: toml::Value) -> Result {
let Some(memory) = memory.as_table() else {
return Err(config_error(path, "memory", "expected a table"));
};
for field in memory.keys() {
if field != "project_memory" && field != "project_doctrines" {
return Err(config_error(
path,
&alloc::format!("memory.{field}"),
"unknown legacy key",
));
}
}
for field in ["project_memory", "project_doctrines"] {
match memory.get(field) {
Some(toml::Value::String(_)) => {}
Some(_) => {
return Err(config_error(
path,
&alloc::format!("memory.{field}"),
"expected a string",
));
}
None => {
return Err(config_error(
path,
&alloc::format!("memory.{field}"),
"required legacy key is missing",
));
}
}
}
Ok(())
}
fn remove_legacy_string(
path: &Path,
table: &mut toml::Table,
section: &str,
field: &str,
) -> Result {
let Some(value) = table.remove(field) else {
return Ok(());
};
if value.is_str() {
Ok(())
} else {
Err(config_error(
path,
&alloc::format!("{section}.{field}"),
"expected a string",
))
}
}
fn remove_legacy_bool(path: &Path, table: &mut toml::Table, section: &str, field: &str) -> Result {
let Some(value) = table.remove(field) else {
return Ok(());
};
if value.is_bool() {
Ok(())
} else {
Err(config_error(
path,
&alloc::format!("{section}.{field}"),
"expected a boolean",
))
}
}
fn config_error(path: &Path, key: &str, message: &str) -> Error {
Error::config(alloc::format!("{}: {key}: {message}", path.display()))
}
fn validate_gate_entries(path: &Path, value: &toml::Value) -> Result {
let Some(extra) = value.get("gates").and_then(|gates| gates.get("extra")) else {
return Ok(());
};
if let Some(map) = extra.as_table() {
for (name, command) in map {
if !command.is_str() {
return Err(Error::config(alloc::format!(
"{}: gates.extra.{name}: expected a string",
path.display()
)));
}
}
return Ok(());
}
let Some(entries) = extra.as_array() else {
return Ok(());
};
for entry in entries {
let Some(table) = entry.as_table() else {
continue;
};
for field in ["name", "cmd"] {
if !table.contains_key(field) {
return Err(Error::config(alloc::format!(
"{}: gates.extra.{field}: required field is missing",
path.display()
)));
}
if !table.get(field).is_some_and(toml::Value::is_str) {
return Err(Error::config(alloc::format!(
"{}: gates.extra.{field}: expected a string",
path.display()
)));
}
}
for field in table.keys() {
if field != "name" && field != "cmd" {
return Err(Error::config(alloc::format!(
"{}: gates.extra.{field}: unknown key",
path.display()
)));
}
}
}
Ok(())
}
fn deserialize(path: &Path, value: toml::Value) -> Result<ShepherdConfig> {
let config: ShepherdConfig = value.try_into().map_err(|error: toml::de::Error| {
Error::config(alloc::format!("{}: {}", path.display(), diagnostic(&error)))
})?;
config.validate().map_err(|error| {
let message = match error {
Error::Config(message) => message,
other => other.to_string(),
};
Error::config(alloc::format!("{}: {message}", path.display()))
})?;
Ok(config)
}
fn diagnostic(error: &toml::de::Error) -> String {
let rendered = error.to_string();
let path = rendered.lines().find_map(|line| {
line.strip_prefix("in `")
.and_then(|line| line.strip_suffix('`'))
});
let message = error.message();
let unknown = message
.strip_prefix("unknown field `")
.and_then(|rest| rest.split_once('`').map(|(field, _)| field));
match (path, unknown) {
(Some(parent), Some(field)) if !parent.is_empty() => {
alloc::format!("{parent}.{field}: {message}")
}
(_, Some(field)) => alloc::format!("{field}: {message}"),
(Some(path), None) if !path.is_empty() => alloc::format!("{path}: {message}"),
_ => message.to_string(),
}
}
fn merge_value(base: &mut toml::Value, overlay: toml::Value) {
match (base, overlay) {
(toml::Value::Table(base), toml::Value::Table(overlay)) => {
for (key, value) in overlay {
if let Some(existing) = base.get_mut(&key) {
merge_value(existing, value);
} else {
base.insert(key, value);
}
}
}
(base, overlay) => *base = overlay,
}
}
#[cfg(all(feature = "schema", feature = "json"))]
pub fn schema_json() -> Result<String> {
serde_json::to_string(&schemars::schema_for!(ShepherdConfig))
.map_err(|error| Error::Serialization(error.to_string()))
}