use alloc::{
collections::BTreeSet,
string::{String, ToString},
vec::Vec,
};
use std::path::{Path, PathBuf};
use config::{
Config as SourceConfig, ConfigError, FileFormat, Format, Map, Source, Value, ValueKind,
};
use crate::{Error, Result, settings::ShepherdConfig, types::Harness};
type ConfigResult<T = ()> = core::result::Result<T, ConfigError>;
#[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>,
pub explicit_keys: BTreeSet<String>,
}
#[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 default_path = ordered
.first()
.map_or_else(|| Path::new("<defaults>"), |layer| layer.0);
let mut explicit_keys: BTreeSet<String> = BTreeSet::new();
let mut builder = SourceConfig::builder();
for (path, contents) in ordered.iter().rev() {
let table =
parse_layer(path, contents, mode).map_err(|error| Error::config(error.to_string()))?;
collect_dotted_keys(&table, "", &mut explicit_keys);
builder = builder.add_source(LayerSource::new(table));
}
let merged = builder
.build()
.map_err(|error| Error::config(error.to_string()))?;
let config = deserialize_merged(default_path, merged)?;
Ok(LoadedConfig {
config,
sources: ordered
.into_iter()
.map(|(path, _)| ConfigSource {
path: path.to_path_buf(),
})
.collect(),
explicit_keys,
})
}
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 {
load_with_mode([(path, contents)], LoadMode::Strict).map(|_| ())
}
fn parse_layer(path: &Path, contents: &str, mode: LoadMode) -> ConfigResult<Map<String, Value>> {
let origin = path.display().to_string();
let mut table: Map<String, Value> = FileFormat::Toml
.parse(Some(&origin), contents)
.map_err(|error| sanitize_parse_error(&origin, error.as_ref()))?;
if mode == LoadMode::LayoutV5Migration {
strip_retired_layout_v5(path, &mut table)?;
}
validate_gate_entries(path, &table)?;
validate_open_bool_map(path, &table, "mcp")?;
validate_open_bool_map(path, &table, "cli")?;
Ok(table)
}
fn collect_dotted_keys(table: &Map<String, Value>, prefix: &str, out: &mut BTreeSet<String>) {
for (key, value) in table {
let dotted = if prefix.is_empty() {
key.clone()
} else {
alloc::format!("{prefix}.{key}")
};
match &value.kind {
ValueKind::Table(nested) => collect_dotted_keys(nested, &dotted, out),
_ => {
out.insert(dotted);
}
}
}
}
#[derive(Clone, Debug)]
struct LayerSource {
table: Map<String, Value>,
}
impl LayerSource {
fn new(table: Map<String, Value>) -> Self {
Self { table }
}
}
impl Source for LayerSource {
fn clone_into_box(&self) -> Box<dyn Source + Send + Sync> {
Box::new(self.clone())
}
fn collect(&self) -> ConfigResult<Map<String, Value>> {
Ok(self.table.clone())
}
}
fn as_table(value: &Value) -> Option<&Map<String, Value>> {
match &value.kind {
ValueKind::Table(table) => Some(table),
_ => None,
}
}
fn as_table_mut(value: &mut Value) -> Option<&mut Map<String, Value>> {
match &mut value.kind {
ValueKind::Table(table) => Some(table),
_ => None,
}
}
fn sanitize_parse_error(
origin: &str,
error: &(dyn std::error::Error + Send + Sync),
) -> ConfigError {
let rendered = error.to_string();
let sanitized = rendered.lines().next().unwrap_or(&rendered).trim();
ConfigError::Message(alloc::format!("{origin}: {sanitized}"))
}
fn config_error(path: &Path, key: &str, message: &str) -> ConfigError {
ConfigError::Message(alloc::format!("{}: {key}: {message}", path.display()))
}
fn strip_retired_layout_v5(path: &Path, root: &mut Map<String, Value>) -> ConfigResult {
if let Some(paths) = root.get_mut("paths").and_then(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(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: &Value) -> ConfigResult {
let Some(memory) = as_table(memory) 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).map(|value| &value.kind) {
Some(ValueKind::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 Map<String, Value>,
section: &str,
field: &str,
) -> ConfigResult {
let Some(value) = table.remove(field) else {
return Ok(());
};
if matches!(value.kind, ValueKind::String(_)) {
Ok(())
} else {
Err(config_error(
path,
&alloc::format!("{section}.{field}"),
"expected a string",
))
}
}
fn remove_legacy_bool(
path: &Path,
table: &mut Map<String, Value>,
section: &str,
field: &str,
) -> ConfigResult {
let Some(value) = table.remove(field) else {
return Ok(());
};
if matches!(value.kind, ValueKind::Boolean(_)) {
Ok(())
} else {
Err(config_error(
path,
&alloc::format!("{section}.{field}"),
"expected a boolean",
))
}
}
fn validate_gate_entries(path: &Path, root: &Map<String, Value>) -> ConfigResult {
let Some(extra) = root
.get("gates")
.and_then(as_table)
.and_then(|gates| gates.get("extra"))
else {
return Ok(());
};
if let ValueKind::Table(map) = &extra.kind {
for (name, command) in map {
if !matches!(command.kind, ValueKind::String(_)) {
return Err(config_error(
path,
&alloc::format!("gates.extra.{name}"),
"expected a string",
));
}
}
return Ok(());
}
let ValueKind::Array(entries) = &extra.kind else {
return Ok(());
};
for entry in entries {
let ValueKind::Table(table) = &entry.kind else {
continue;
};
for field in ["name", "cmd"] {
let Some(value) = table.get(field) else {
return Err(config_error(
path,
&alloc::format!("gates.extra.{field}"),
"required field is missing",
));
};
if !matches!(value.kind, ValueKind::String(_)) {
return Err(config_error(
path,
&alloc::format!("gates.extra.{field}"),
"expected a string",
));
}
}
for field in table.keys() {
if field != "name" && field != "cmd" {
return Err(config_error(
path,
&alloc::format!("gates.extra.{field}"),
"unknown key",
));
}
}
}
Ok(())
}
fn validate_open_bool_map(path: &Path, root: &Map<String, Value>, section: &str) -> ConfigResult {
let Some(entries) = root.get(section).and_then(as_table) else {
return Ok(());
};
for (name, value) in entries {
if !matches!(value.kind, ValueKind::Boolean(_)) {
return Err(config_error(
path,
&alloc::format!("{section}.{name}"),
"expected a boolean",
));
}
}
Ok(())
}
fn deserialize_merged(path: &Path, value: SourceConfig) -> Result<ShepherdConfig> {
let config = value
.try_deserialize::<ShepherdConfig>()
.map_err(|error| deserialize_error(path, &error))?;
validate_config(path, config)
}
fn validate_config(path: &Path, config: ShepherdConfig) -> Result<ShepherdConfig> {
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 deserialize_error(path: &Path, error: &ConfigError) -> Error {
let (key, origin, message) = describe_config_error(error);
let origin = origin.unwrap_or_else(|| path.display().to_string());
match key {
Some(key) => Error::config(alloc::format!("{origin}: {key}: {message}")),
None => Error::config(alloc::format!("{origin}: {message}")),
}
}
fn describe_config_error(error: &ConfigError) -> (Option<String>, Option<String>, String) {
match error {
ConfigError::Type {
origin,
unexpected,
expected,
key,
} => (
key.clone(),
origin.clone(),
alloc::format!("invalid type: {unexpected}, expected {expected}"),
),
ConfigError::At { error, origin, key } => {
let (inner_key, inner_origin, message) = describe_config_error(error);
let combined_key = match (key.as_deref(), inner_key) {
(Some(outer), Some(inner)) => Some(alloc::format!("{outer}.{inner}")),
(Some(outer), None) => Some(outer.to_string()),
(None, inner) => inner,
};
(combined_key, origin.clone().or(inner_origin), message)
}
ConfigError::Message(message) => {
let field = message
.strip_prefix("unknown field `")
.and_then(|rest| rest.split_once('`'))
.map(|(field, _)| field.to_string());
(field, None, message.clone())
}
ConfigError::NotFound(key) => (
Some(key.clone()),
None,
"missing configuration field".to_string(),
),
other => (None, None, other.to_string()),
}
}
#[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()))
}