use serde::de::{self, Deserializer, MapAccess, Visitor};
use serde::ser::{SerializeStruct, Serializer};
use serde::{Deserialize, Serialize};
use crate::error::{ItemKind, MindError, Result};
use crate::paths::Paths;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LobeEntry {
pub path: String,
pub kinds: Option<Vec<ItemKind>>,
}
impl LobeEntry {
pub fn bare(path: impl Into<String>) -> Self {
LobeEntry {
path: path.into(),
kinds: None,
}
}
pub fn path(&self) -> &str {
&self.path
}
pub fn kinds(&self) -> Option<&[ItemKind]> {
self.kinds.as_deref()
}
}
impl Serialize for LobeEntry {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
match &self.kinds {
None => serializer.serialize_str(&self.path),
Some(kinds) => {
let mut st = serializer.serialize_struct("LobeEntry", 2)?;
st.serialize_field("path", &self.path)?;
let kind_strs: Vec<&str> = kinds.iter().map(|k| k.as_str()).collect();
st.serialize_field("kinds", &kind_strs)?;
st.end()
}
}
}
}
impl<'de> Deserialize<'de> for LobeEntry {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct LobeEntryVisitor;
impl<'de> Visitor<'de> for LobeEntryVisitor {
type Value = LobeEntry;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a lobe path string or a { path, kinds } table")
}
fn visit_str<E>(self, v: &str) -> std::result::Result<LobeEntry, E>
where
E: de::Error,
{
Ok(LobeEntry::bare(v.to_string()))
}
fn visit_string<E>(self, v: String) -> std::result::Result<LobeEntry, E>
where
E: de::Error,
{
Ok(LobeEntry::bare(v))
}
fn visit_map<A>(self, mut map: A) -> std::result::Result<LobeEntry, A::Error>
where
A: MapAccess<'de>,
{
let mut path: Option<String> = None;
let mut kinds: Option<Vec<String>> = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"path" => {
if path.is_some() {
return Err(de::Error::duplicate_field("path"));
}
path = Some(map.next_value()?);
}
"kinds" => {
if kinds.is_some() {
return Err(de::Error::duplicate_field("kinds"));
}
kinds = Some(map.next_value()?);
}
other => {
return Err(de::Error::unknown_field(other, &["path", "kinds"]));
}
}
}
let path = path.ok_or_else(|| de::Error::missing_field("path"))?;
let kinds = match kinds {
Some(strs) => Some(ItemKind::parse_kinds(&strs).map_err(de::Error::custom)?),
None => None,
};
Ok(LobeEntry { path, kinds })
}
}
deserializer.deserialize_any(LobeEntryVisitor)
}
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub lobes: Vec<LobeEntry>,
#[serde(default)]
pub ssh: bool,
#[serde(
rename = "absorb-to",
alias = "absorb_to",
default,
skip_serializing_if = "Option::is_none"
)]
pub absorb_to: Option<String>,
#[serde(
rename = "max-metadata-size",
alias = "max_metadata_size",
default,
skip_serializing_if = "Option::is_none"
)]
pub max_metadata_size: Option<String>,
#[serde(
rename = "hook-history",
alias = "hook_history",
default,
skip_serializing_if = "Option::is_none"
)]
pub hook_history: Option<usize>,
#[serde(
rename = "hook-log",
alias = "hook_log",
default,
skip_serializing_if = "Option::is_none"
)]
pub hook_log: Option<HookLogSetting>,
#[serde(
rename = "hook-log-keep",
alias = "hook_log_keep",
default,
skip_serializing_if = "Option::is_none"
)]
pub hook_log_keep: Option<crate::hook_log::LogKeep>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(untagged)]
pub enum HookLogSetting {
Enabled(bool),
Dir(String),
}
impl<'de> Deserialize<'de> for HookLogSetting {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
struct V;
impl Visitor<'_> for V {
type Value = HookLogSetting;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("true, false, or a directory path")
}
fn visit_bool<E: de::Error>(self, b: bool) -> std::result::Result<Self::Value, E> {
Ok(HookLogSetting::Enabled(b))
}
fn visit_str<E: de::Error>(self, s: &str) -> std::result::Result<Self::Value, E> {
Ok(HookLogSetting::Dir(s.to_string()))
}
}
d.deserialize_any(V)
}
}
pub const MAX_METADATA_SIZE_ENV: &str = "MIND_MAX_METADATA_SIZE";
#[cfg(test)]
pub fn resolve_metadata_limit(
flag: Option<&str>,
env: Option<&str>,
configured: Option<&str>,
) -> Result<u64> {
resolve_metadata_limit_detail(flag, env, configured).map(|(limit, _)| limit)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MetadataLimitOrigin {
Flag(String),
Env(String),
Config(String),
Default,
}
impl MetadataLimitOrigin {
pub fn label(&self) -> &'static str {
match self {
MetadataLimitOrigin::Flag(_) => "--max-metadata-size",
MetadataLimitOrigin::Env(_) => MAX_METADATA_SIZE_ENV,
MetadataLimitOrigin::Config(_) => "the 'max-metadata-size' config key",
MetadataLimitOrigin::Default => "the built-in default",
}
}
pub fn raw(&self) -> Option<&str> {
match self {
MetadataLimitOrigin::Flag(raw)
| MetadataLimitOrigin::Env(raw)
| MetadataLimitOrigin::Config(raw) => Some(raw),
MetadataLimitOrigin::Default => None,
}
}
}
pub fn resolve_metadata_limit_detail(
flag: Option<&str>,
env: Option<&str>,
configured: Option<&str>,
) -> Result<(u64, MetadataLimitOrigin)> {
let origin = match (flag, env, configured) {
(Some(raw), _, _) => MetadataLimitOrigin::Flag(raw.to_string()),
(None, Some(raw), _) => MetadataLimitOrigin::Env(raw.to_string()),
(None, None, Some(raw)) => MetadataLimitOrigin::Config(raw.to_string()),
(None, None, None) => {
return Ok((
crate::error::METADATA_SIZE_LIMIT,
MetadataLimitOrigin::Default,
));
}
};
let raw = origin.raw().unwrap_or_default();
let limit =
crate::error::parse_metadata_size(raw).map_err(|msg| MindError::BadMetadataSize {
origin: origin.label().to_string(),
msg,
})?;
Ok((limit, origin))
}
static METADATA_LIMIT_ORIGIN: std::sync::OnceLock<MetadataLimitOrigin> = std::sync::OnceLock::new();
pub fn set_metadata_limit_origin(origin: MetadataLimitOrigin) {
let _ = METADATA_LIMIT_ORIGIN.set(origin);
}
pub fn metadata_limit_origin() -> Option<&'static MetadataLimitOrigin> {
METADATA_LIMIT_ORIGIN.get()
}
pub fn zero_turns_cap_off(raw: &str) -> bool {
if crate::error::parse_metadata_size(raw) != Ok(u64::MAX) {
return false;
}
let cleaned: String = raw
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>()
.to_ascii_lowercase();
if matches!(cleaned.as_str(), "unlimited" | "none") {
return false;
}
let digits: &str = cleaned
.split(|c: char| !c.is_ascii_digit())
.next()
.unwrap_or_default();
!digits.is_empty() && digits.chars().all(|c| c == '0')
}
impl Config {
pub fn load(paths: &Paths) -> Result<Config> {
let file = paths.config_file();
match std::fs::read_to_string(&file) {
Ok(text) => toml::from_str(&text).map_err(|e| MindError::ConfigToml {
path: file.clone(),
msg: e.to_string(),
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
Err(e) => Err(MindError::io(&file, e)),
}
}
pub fn save(&self, paths: &Paths) -> Result<()> {
std::fs::create_dir_all(&paths.mind_home)
.map_err(|e| MindError::io(&paths.mind_home, e))?;
let file = paths.config_file();
let text = toml::to_string(self).map_err(|e| MindError::TomlWrite {
path: file.clone(),
source: e,
})?;
Paths::atomic_write(&file, text.as_bytes())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_string_lobe_parses_as_all_kinds() {
let cfg: Config = toml::from_str("lobes = [\"~/.claude\"]\n").unwrap();
assert_eq!(cfg.lobes.len(), 1);
assert_eq!(cfg.lobes[0].path(), "~/.claude");
assert_eq!(cfg.lobes[0].kinds(), None, "bare entry admits all kinds");
}
#[test]
fn metadata_limit_precedence_is_flag_then_env_then_config() {
let mib = 1024 * 1024;
assert_eq!(
resolve_metadata_limit(Some("1MiB"), Some("2MiB"), Some("3MiB")).unwrap(),
mib
);
assert_eq!(
resolve_metadata_limit(None, Some("2MiB"), Some("3MiB")).unwrap(),
2 * mib
);
assert_eq!(
resolve_metadata_limit(None, None, Some("3MiB")).unwrap(),
3 * mib
);
assert_eq!(
resolve_metadata_limit(None, None, None).unwrap(),
crate::error::METADATA_SIZE_LIMIT
);
}
#[test]
fn metadata_limit_detail_reports_the_winning_origin() {
let mib = 1024 * 1024;
assert_eq!(
resolve_metadata_limit_detail(Some("1MiB"), Some("2MiB"), Some("3MiB")).unwrap(),
(mib, MetadataLimitOrigin::Flag("1MiB".into()))
);
assert_eq!(
resolve_metadata_limit_detail(None, Some("2MiB"), Some("3MiB")).unwrap(),
(2 * mib, MetadataLimitOrigin::Env("2MiB".into()))
);
assert_eq!(
resolve_metadata_limit_detail(None, None, Some("3MiB")).unwrap(),
(3 * mib, MetadataLimitOrigin::Config("3MiB".into()))
);
assert_eq!(
resolve_metadata_limit_detail(None, None, None).unwrap(),
(
crate::error::METADATA_SIZE_LIMIT,
MetadataLimitOrigin::Default
)
);
let err = resolve_metadata_limit_detail(None, Some("nope"), None).unwrap_err();
assert!(err.to_string().contains(MAX_METADATA_SIZE_ENV), "{err}");
}
#[test]
fn zero_turns_cap_off_matches_only_zero_spellings() {
for raw in ["0", "0MiB", " 0 kb ", "000", "0b"] {
assert!(zero_turns_cap_off(raw), "{raw:?} is a zero spelling");
}
for raw in [
"unlimited",
"NONE",
" Unlimited ",
"8MiB",
"1",
"18446744073709551615",
"junk",
"",
] {
assert!(!zero_turns_cap_off(raw), "{raw:?} is not a zero spelling");
}
}
#[test]
fn a_bad_metadata_limit_fails_naming_where_it_came_from() {
for (flag, env, cfg, origin) in [
(
Some("1.5MiB"),
Some("2MiB"),
Some("3MiB"),
"--max-metadata-size",
),
(None, Some("nope"), Some("3MiB"), MAX_METADATA_SIZE_ENV),
(None, None, Some("huge"), "max-metadata-size"),
] {
let err = resolve_metadata_limit(flag, env, cfg)
.expect_err("an unparseable value must be a hard error");
assert_eq!(err.kind(), "bad-metadata-size");
let msg = err.to_string();
assert!(
msg.contains(origin),
"message must name the origin '{origin}': {msg}"
);
assert!(
msg.contains("unlimited") && msg.contains("32MiB"),
"message must show the accepted forms: {msg}"
);
}
}
#[test]
fn max_metadata_size_parses_under_both_spellings() {
let kebab: Config = toml::from_str("max-metadata-size = \"32MiB\"\n").unwrap();
assert_eq!(kebab.max_metadata_size.as_deref(), Some("32MiB"));
let snake: Config = toml::from_str("max_metadata_size = \"32MiB\"\n").unwrap();
assert_eq!(snake.max_metadata_size.as_deref(), Some("32MiB"));
let absent: Config = toml::from_str("ssh = true\n").unwrap();
assert_eq!(absent.max_metadata_size, None);
}
#[test]
fn table_lobe_parses_kinds() {
let cfg: Config =
toml::from_str("lobes = [{ path = \"~/.gemini\", kinds = [\"skill\", \"agent\"] }]\n")
.unwrap();
assert_eq!(cfg.lobes.len(), 1);
assert_eq!(cfg.lobes[0].path(), "~/.gemini");
assert_eq!(
cfg.lobes[0].kinds(),
Some([ItemKind::Skill, ItemKind::Agent].as_slice())
);
}
#[test]
fn lobe_entries_round_trip_by_shape() {
let cfg = Config {
lobes: vec![
LobeEntry::bare("~/.claude"),
LobeEntry {
path: "~/.gemini".to_string(),
kinds: Some(vec![ItemKind::Skill, ItemKind::Agent]),
},
],
..Default::default()
};
let text = toml::to_string(&cfg).unwrap();
assert!(
text.contains("\"~/.claude\""),
"bare lobe must serialize as a string: {text}"
);
assert!(
text.contains("kinds"),
"filtered lobe must serialize with its kinds: {text}"
);
let reparsed: Config = toml::from_str(&text).unwrap();
assert_eq!(reparsed.lobes, cfg.lobes);
assert_eq!(reparsed.lobes[0].kinds(), None);
assert_eq!(
reparsed.lobes[1].kinds(),
Some([ItemKind::Skill, ItemKind::Agent].as_slice())
);
}
#[test]
fn table_lobe_rejects_unknown_key() {
let err = toml::from_str::<Config>(
"lobes = [{ path = \"~/.gemini\", kinds = [\"skill\"], bogus = true }]\n",
)
.unwrap_err();
assert!(
err.to_string().contains("bogus") || err.to_string().contains("unknown"),
"unknown table key must be rejected: {err}"
);
}
#[test]
fn table_lobe_rejects_invalid_kind() {
let err = toml::from_str::<Config>("lobes = [{ path = \"~/.x\", kinds = [\"wizard\"] }]\n")
.unwrap_err();
assert!(
err.to_string().contains("wizard") || err.to_string().contains("valid item kind"),
"invalid kind must be rejected: {err}"
);
}
#[test]
fn malformed_config_toml_yields_config_toml_variant() {
use crate::paths::Paths;
use std::path::PathBuf;
let tmp = std::env::temp_dir().join(format!("mind-config-test-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let config_path = tmp.join("config.toml");
std::fs::write(&config_path, b"[[[not valid toml").unwrap();
let paths = Paths {
mind_home: tmp.clone(),
claude_home: PathBuf::from("/tmp/claude-unused"),
};
let err = Config::load(&paths).unwrap_err();
assert!(
matches!(err, MindError::ConfigToml { .. }),
"expected ConfigToml, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("config"),
"display must mention 'config': {msg}"
);
assert!(
!msg.contains("mind.toml"),
"display must not name the wrong file 'mind.toml': {msg}"
);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn unknown_key_in_config_toml_yields_config_toml_variant() {
use crate::paths::Paths;
use std::path::PathBuf;
let tmp = std::env::temp_dir().join(format!("mind-config-test-uk-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let config_path = tmp.join("config.toml");
std::fs::write(&config_path, b"no_such_field = true\n").unwrap();
let paths = Paths {
mind_home: tmp.clone(),
claude_home: PathBuf::from("/tmp/claude-unused"),
};
let err = Config::load(&paths).unwrap_err();
assert!(
matches!(err, MindError::ConfigToml { .. }),
"expected ConfigToml for unknown key, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("config"),
"display must mention 'config': {msg}"
);
assert!(
!msg.contains("mind.toml"),
"display must not name the wrong file: {msg}"
);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn hook_log_takes_a_bool_or_a_path() {
let cfg: Config = toml::from_str("hook-log = true\n").unwrap();
assert_eq!(cfg.hook_log, Some(HookLogSetting::Enabled(true)));
let cfg: Config = toml::from_str("hook-log = \"logs\"\n").unwrap();
assert_eq!(cfg.hook_log, Some(HookLogSetting::Dir("logs".into())));
let err = toml::from_str::<Config>("hook-log = 1\n").unwrap_err();
assert!(
err.to_string().contains("true, false, or a directory path"),
"{err}"
);
}
#[test]
fn absorb_to_kebab_key_is_accepted() {
let cfg: Config = toml::from_str("\"absorb-to\" = \"/some/path\"\n").unwrap();
assert_eq!(cfg.absorb_to.as_deref(), Some("/some/path"));
}
#[test]
fn absorb_to_snake_key_is_alias() {
let cfg: Config = toml::from_str("absorb_to = \"/legacy/path\"\n").unwrap();
assert_eq!(cfg.absorb_to.as_deref(), Some("/legacy/path"));
}
#[test]
fn absorb_to_serializes_as_kebab() {
let cfg = Config {
absorb_to: Some("/out/path".to_string()),
..Config::default()
};
let s = toml::to_string(&cfg).unwrap();
assert!(
s.contains("absorb-to"),
"serialized config must use kebab key: {s}"
);
assert!(
!s.contains("absorb_to"),
"serialized config must not use snake key: {s}"
);
}
}