use std::path::{Path, PathBuf};
use crate::errors::{CoreError, CoreResult};
use ito_config::ConfigContext;
use ito_config::load_cascading_project_config;
use ito_config::types::{
ArchiveMainIntegrationMode, IntegrationMode, MemoryConfig, MemoryOpConfig,
RepositoryPersistenceMode, WorktreeStrategy,
};
pub fn read_json_config(path: &Path) -> CoreResult<serde_json::Value> {
let Ok(contents) = std::fs::read_to_string(path) else {
return Ok(serde_json::Value::Object(serde_json::Map::new()));
};
let v: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
CoreError::serde(format!("Invalid JSON in {}", path.display()), e.to_string())
})?;
match v {
serde_json::Value::Object(_) => Ok(v),
_ => Err(CoreError::serde(
format!("Expected JSON object in {}", path.display()),
"root value is not an object",
)),
}
}
pub fn write_json_config(path: &Path, value: &serde_json::Value) -> CoreResult<()> {
let mut bytes = serde_json::to_vec_pretty(value)
.map_err(|e| CoreError::serde("Failed to serialize JSON config", e.to_string()))?;
bytes.push(b'\n');
ito_common::io::write_atomic_std(path, bytes)
.map_err(|e| CoreError::io(format!("Failed to write config to {}", path.display()), e))?;
Ok(())
}
pub fn parse_json_value_arg(raw: &str, force_string: bool) -> serde_json::Value {
if force_string {
return serde_json::Value::String(raw.to_string());
}
match serde_json::from_str::<serde_json::Value>(raw) {
Ok(v) => v,
Err(_) => serde_json::Value::String(raw.to_string()),
}
}
pub fn json_split_path(key: &str) -> Vec<&str> {
let mut out: Vec<&str> = Vec::new();
for part in key.split('.') {
let part = part.trim();
if part.is_empty() {
continue;
}
out.push(part);
}
out
}
pub fn json_get_path<'a>(
root: &'a serde_json::Value,
parts: &[&str],
) -> Option<&'a serde_json::Value> {
let mut cur = root;
for p in parts {
let serde_json::Value::Object(map) = cur else {
return None;
};
let next = map.get(*p)?;
cur = next;
}
Some(cur)
}
#[allow(clippy::match_like_matches_macro)]
pub fn json_set_path(
root: &mut serde_json::Value,
parts: &[&str],
value: serde_json::Value,
) -> CoreResult<()> {
if parts.is_empty() {
return Err(CoreError::validation("Invalid empty path"));
}
let mut cur = root;
for (i, key) in parts.iter().enumerate() {
let is_last = i + 1 == parts.len();
let is_object = match cur {
serde_json::Value::Object(_) => true,
_ => false,
};
if !is_object {
*cur = serde_json::Value::Object(serde_json::Map::new());
}
let serde_json::Value::Object(map) = cur else {
return Err(CoreError::validation("Failed to set path"));
};
if is_last {
map.insert((*key).to_string(), value);
return Ok(());
}
let needs_object = match map.get(*key) {
Some(serde_json::Value::Object(_)) => false,
Some(_) => true,
None => true,
};
if needs_object {
map.insert(
(*key).to_string(),
serde_json::Value::Object(serde_json::Map::new()),
);
}
let Some(next) = map.get_mut(*key) else {
return Err(CoreError::validation("Failed to set path"));
};
cur = next;
}
Ok(())
}
pub fn validate_config_value(parts: &[&str], value: &serde_json::Value) -> CoreResult<()> {
let path = parts.join(".");
match path.as_str() {
"worktrees.strategy" => {
let Some(s) = value.as_str() else {
return Err(CoreError::validation(format!(
"Key '{}' requires a string value. Valid values: {}",
path,
WorktreeStrategy::ALL.join(", ")
)));
};
if WorktreeStrategy::parse_value(s).is_none() {
return Err(CoreError::validation(format!(
"Invalid value '{}' for key '{}'. Valid values: {}",
s,
path,
WorktreeStrategy::ALL.join(", ")
)));
}
}
"worktrees.apply.integration_mode" => {
let Some(s) = value.as_str() else {
return Err(CoreError::validation(format!(
"Key '{}' requires a string value. Valid values: {}",
path,
IntegrationMode::ALL.join(", ")
)));
};
if IntegrationMode::parse_value(s).is_none() {
return Err(CoreError::validation(format!(
"Invalid value '{}' for key '{}'. Valid values: {}",
s,
path,
IntegrationMode::ALL.join(", ")
)));
}
}
"repository.mode" => {
let Some(s) = value.as_str() else {
return Err(CoreError::validation(format!(
"Key '{}' requires a string value. Valid values: {}",
path,
RepositoryPersistenceMode::ALL.join(", ")
)));
};
if RepositoryPersistenceMode::parse_value(s).is_none() {
return Err(CoreError::validation(format!(
"Invalid value '{}' for key '{}'. Valid values: {}",
s,
path,
RepositoryPersistenceMode::ALL.join(", ")
)));
}
}
"changes.coordination_branch.name" => {
let Some(s) = value.as_str() else {
return Err(CoreError::validation(format!(
"Key '{}' requires a string value.",
path,
)));
};
if !is_valid_branch_name(s) {
return Err(CoreError::validation(format!(
"Invalid value '{}' for key '{}'. Provide a valid git branch name.",
s, path,
)));
}
}
"changes.coordination_branch.sync_interval_seconds" => {
let Some(n) = value.as_u64() else {
return Err(CoreError::validation(format!(
"Key '{}' requires a positive integer value in seconds.",
path,
)));
};
if n == 0 {
return Err(CoreError::validation(format!(
"Invalid value '{}' for key '{}'. Provide a positive integer number of seconds.",
n, path,
)));
}
}
"changes.archive.main_integration_mode" => {
let Some(s) = value.as_str() else {
return Err(CoreError::validation(format!(
"Key '{}' requires a string value. Valid values: {}",
path,
ArchiveMainIntegrationMode::ALL.join(", ")
)));
};
if ArchiveMainIntegrationMode::parse_value(s).is_none() {
return Err(CoreError::validation(format!(
"Invalid value '{}' for key '{}'. Valid values: {}",
s,
path,
ArchiveMainIntegrationMode::ALL.join(", ")
)));
}
}
"audit.mirror.branch" => {
let Some(s) = value.as_str() else {
return Err(CoreError::validation(format!(
"Key '{}' requires a string value.",
path,
)));
};
if !is_valid_branch_name(s) {
return Err(CoreError::validation(format!(
"Invalid value '{}' for key '{}'. Provide a valid git branch name.",
s, path,
)));
}
}
path if matches!(
parts,
["memory", op, "kind"]
if matches!(*op, "capture" | "search" | "query")
) =>
{
let Some(s) = value.as_str() else {
return Err(CoreError::validation(format!(
"Key '{}' requires a string value. Valid values: skill, command",
path,
)));
};
if !matches!(s, "skill" | "command") {
return Err(CoreError::validation(format!(
"Invalid value '{}' for key '{}'. Valid values: skill, command",
s, path,
)));
}
}
path if matches!(
parts,
["memory", op, "skill"]
if matches!(*op, "capture" | "search" | "query")
) =>
{
let Some(s) = value.as_str() else {
return Err(CoreError::validation(format!(
"Key '{}' requires a non-empty string skill id.",
path,
)));
};
if s.trim().is_empty() {
return Err(CoreError::validation(format!(
"Invalid value for key '{}'. Provide a non-empty skill id.",
path,
)));
}
}
path if matches!(
parts,
["memory", op, "command"]
if matches!(*op, "capture" | "search" | "query")
) =>
{
let Some(s) = value.as_str() else {
return Err(CoreError::validation(format!(
"Key '{}' requires a non-empty string command template.",
path,
)));
};
if s.trim().is_empty() {
return Err(CoreError::validation(format!(
"Invalid value for key '{}'. Provide a non-empty command template.",
path,
)));
}
}
_ if matches!(parts, ["memory", op] if matches!(*op, "capture" | "search" | "query")) => {
let op_name = parts[1];
return validate_memory_op_value(op_name, value);
}
_ if parts == ["memory"] => {
return validate_memory_section_value(value);
}
_ => {}
}
Ok(())
}
fn validate_memory_section_value(value: &serde_json::Value) -> CoreResult<()> {
let Some(obj) = value.as_object() else {
return Err(CoreError::validation(
"Key 'memory' requires an object whose keys are operation names (capture, search, query).",
));
};
for (key, child) in obj {
match key.as_str() {
"capture" | "search" | "query" => validate_memory_op_value(key, child)?,
other => {
return Err(CoreError::validation(format!(
"Unknown memory operation '{}'. Valid keys: capture, search, query.",
other
)));
}
}
}
Ok(())
}
fn validate_memory_op_value(op_name: &str, value: &serde_json::Value) -> CoreResult<()> {
let Some(obj) = value.as_object() else {
return Err(CoreError::validation(format!(
"Key 'memory.{}' requires an object describing the provider shape.",
op_name
)));
};
let Some(kind) = obj.get("kind").and_then(|v| v.as_str()) else {
return Err(CoreError::validation(format!(
"Key 'memory.{}' must include a string 'kind' field. Valid values: skill, command.",
op_name
)));
};
match kind {
"skill" => match obj.get("skill").and_then(|v| v.as_str()) {
Some(s) if !s.trim().is_empty() => Ok(()),
_ => Err(CoreError::validation(format!(
"Key 'memory.{}.skill' is required and must be a non-empty string when kind is 'skill'.",
op_name
))),
},
"command" => match obj.get("command").and_then(|v| v.as_str()) {
Some(s) if !s.trim().is_empty() => Ok(()),
_ => Err(CoreError::validation(format!(
"Key 'memory.{}.command' is required and must be a non-empty string when kind is 'command'.",
op_name
))),
},
other => Err(CoreError::validation(format!(
"Invalid 'kind' value '{}' for 'memory.{}'. Valid values: skill, command.",
other, op_name
))),
}
}
pub fn validate_memory_config(config: &MemoryConfig, search_paths: &[PathBuf]) -> CoreResult<()> {
for (op_name, op) in [
("capture", &config.capture),
("search", &config.search),
("query", &config.query),
] {
let Some(MemoryOpConfig::Skill { skill, .. }) = op else {
continue;
};
if skill.trim().is_empty() {
return Err(CoreError::validation(format!(
"Key 'memory.{}.skill' must be a non-empty string when kind is 'skill'.",
op_name
)));
}
if !skill_id_resolves(skill, search_paths) {
let searched = search_paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
return Err(CoreError::validation(format!(
"memory.{op}: skill id '{skill}' was not found under any of the searched skills directories: [{searched}]. Install the skill or correct the id.",
op = op_name,
skill = skill,
searched = if searched.is_empty() {
"(none configured)".to_string()
} else {
searched
},
)));
}
}
Ok(())
}
pub fn known_skills_search_paths(project_root: &Path) -> Vec<PathBuf> {
[
".agents/skills",
".claude/skills",
".codex/skills",
".opencode/skills",
".pi/skills",
".github/skills",
]
.into_iter()
.map(|p| project_root.join(p))
.collect()
}
pub fn skill_id_resolves(skill_id: &str, search_paths: &[PathBuf]) -> bool {
for base in search_paths {
if !base.is_dir() {
continue;
}
if base.join(skill_id).join("SKILL.md").is_file() {
return true;
}
let Ok(entries) = std::fs::read_dir(base) else {
continue;
};
for entry in entries {
let Ok(entry) = entry else {
continue;
};
let path = entry.path();
if !path.is_dir() {
continue;
}
if path.join(skill_id).join("SKILL.md").is_file() {
return true;
}
}
}
false
}
fn is_valid_branch_name(value: &str) -> bool {
if value.is_empty() || value.starts_with('-') || value.starts_with('/') || value.ends_with('/')
{
return false;
}
if value.contains("..")
|| value.contains("@{")
|| value.contains("//")
|| value.ends_with('.')
|| value.ends_with(".lock")
{
return false;
}
for ch in value.chars() {
if ch.is_ascii_control() || ch == ' ' {
return false;
}
if ch == '~' || ch == '^' || ch == ':' || ch == '?' || ch == '*' || ch == '[' || ch == '\\'
{
return false;
}
}
for segment in value.split('/') {
if segment.is_empty()
|| segment.starts_with('.')
|| segment.ends_with('.')
|| segment.ends_with(".lock")
{
return false;
}
}
true
}
pub fn is_valid_worktree_strategy(s: &str) -> bool {
WorktreeStrategy::parse_value(s).is_some()
}
pub fn is_valid_integration_mode(s: &str) -> bool {
IntegrationMode::parse_value(s).is_some()
}
pub fn is_valid_repository_mode(s: &str) -> bool {
RepositoryPersistenceMode::parse_value(s).is_some()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeTemplateDefaults {
pub strategy: String,
pub layout_dir_name: String,
pub integration_mode: String,
pub default_branch: String,
}
pub fn resolve_worktree_template_defaults(
target_path: &Path,
ctx: &ConfigContext,
) -> WorktreeTemplateDefaults {
let ito_path = ito_config::ito_dir::get_ito_path(target_path, ctx);
let merged = load_cascading_project_config(target_path, &ito_path, ctx).merged;
let mut defaults = WorktreeTemplateDefaults {
strategy: "checkout_subdir".to_string(),
layout_dir_name: "ito-worktrees".to_string(),
integration_mode: "commit_pr".to_string(),
default_branch: "main".to_string(),
};
if let Some(wt) = merged.get("worktrees") {
if let Some(v) = wt.get("strategy").and_then(|v| v.as_str())
&& !v.is_empty()
{
defaults.strategy = v.to_string();
}
if let Some(v) = wt.get("default_branch").and_then(|v| v.as_str())
&& !v.is_empty()
{
defaults.default_branch = v.to_string();
}
if let Some(layout) = wt.get("layout")
&& let Some(v) = layout.get("dir_name").and_then(|v| v.as_str())
&& !v.is_empty()
{
defaults.layout_dir_name = v.to_string();
}
if let Some(apply) = wt.get("apply")
&& let Some(v) = apply.get("integration_mode").and_then(|v| v.as_str())
&& !v.is_empty()
{
defaults.integration_mode = v.to_string();
}
}
defaults
}
pub fn json_unset_path(root: &mut serde_json::Value, parts: &[&str]) -> CoreResult<bool> {
if parts.is_empty() {
return Err(CoreError::validation("Invalid empty path"));
}
let mut cur = root;
for (i, p) in parts.iter().enumerate() {
let is_last = i + 1 == parts.len();
let serde_json::Value::Object(map) = cur else {
return Ok(false);
};
if is_last {
return Ok(map.remove(*p).is_some());
}
let Some(next) = map.get_mut(*p) else {
return Ok(false);
};
cur = next;
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn validate_config_value_accepts_valid_strategy() {
let parts = ["worktrees", "strategy"];
let value = json!("checkout_subdir");
assert!(validate_config_value(&parts, &value).is_ok());
let value = json!("checkout_siblings");
assert!(validate_config_value(&parts, &value).is_ok());
let value = json!("bare_control_siblings");
assert!(validate_config_value(&parts, &value).is_ok());
}
#[test]
fn validate_config_value_rejects_invalid_strategy() {
let parts = ["worktrees", "strategy"];
let value = json!("custom_layout");
let err = validate_config_value(&parts, &value).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Invalid value"));
assert!(msg.contains("custom_layout"));
}
#[test]
fn validate_config_value_rejects_non_string_strategy() {
let parts = ["worktrees", "strategy"];
let value = json!(42);
let err = validate_config_value(&parts, &value).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("requires a string value"));
}
#[test]
fn validate_config_value_accepts_valid_integration_mode() {
let parts = ["worktrees", "apply", "integration_mode"];
let value = json!("commit_pr");
assert!(validate_config_value(&parts, &value).is_ok());
let value = json!("merge_parent");
assert!(validate_config_value(&parts, &value).is_ok());
}
#[test]
fn validate_config_value_accepts_valid_repository_mode() {
let parts = ["repository", "mode"];
let value = json!("filesystem");
assert!(validate_config_value(&parts, &value).is_ok());
let value = json!("sqlite");
assert!(validate_config_value(&parts, &value).is_ok());
}
#[test]
fn validate_config_value_rejects_invalid_repository_mode() {
let parts = ["repository", "mode"];
let value = json!("remote");
let err = validate_config_value(&parts, &value).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Invalid value"));
assert!(msg.contains("repository.mode"));
}
#[test]
fn validate_config_value_rejects_invalid_integration_mode() {
let parts = ["worktrees", "apply", "integration_mode"];
let value = json!("squash_merge");
let err = validate_config_value(&parts, &value).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Invalid value"));
assert!(msg.contains("squash_merge"));
}
#[test]
fn validate_config_value_accepts_unknown_keys() {
let parts = ["worktrees", "enabled"];
let value = json!(true);
assert!(validate_config_value(&parts, &value).is_ok());
let parts = ["some", "other", "key"];
let value = json!("anything");
assert!(validate_config_value(&parts, &value).is_ok());
}
#[test]
fn is_valid_worktree_strategy_checks_correctly() {
assert!(is_valid_worktree_strategy("checkout_subdir"));
assert!(is_valid_worktree_strategy("checkout_siblings"));
assert!(is_valid_worktree_strategy("bare_control_siblings"));
assert!(!is_valid_worktree_strategy("custom"));
assert!(!is_valid_worktree_strategy(""));
}
#[test]
fn is_valid_integration_mode_checks_correctly() {
assert!(is_valid_integration_mode("commit_pr"));
assert!(is_valid_integration_mode("merge_parent"));
assert!(!is_valid_integration_mode("squash"));
assert!(!is_valid_integration_mode(""));
}
#[test]
fn is_valid_repository_mode_checks_correctly() {
assert!(is_valid_repository_mode("filesystem"));
assert!(is_valid_repository_mode("sqlite"));
assert!(!is_valid_repository_mode("remote"));
assert!(!is_valid_repository_mode(""));
}
#[test]
fn validate_config_value_accepts_valid_coordination_branch_name() {
let parts = ["changes", "coordination_branch", "name"];
let value = json!("ito/internal/changes");
assert!(validate_config_value(&parts, &value).is_ok());
}
#[test]
fn validate_config_value_rejects_invalid_coordination_branch_name() {
let parts = ["changes", "coordination_branch", "name"];
let value = json!("--ito-changes");
let err = validate_config_value(&parts, &value).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Invalid value"));
assert!(msg.contains("changes.coordination_branch.name"));
}
#[test]
fn validate_config_value_rejects_lock_suffix_in_path_segment() {
let parts = ["changes", "coordination_branch", "name"];
let value = json!("foo.lock/bar");
let err = validate_config_value(&parts, &value).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Invalid value"));
assert!(msg.contains("changes.coordination_branch.name"));
}
#[test]
fn validate_config_value_accepts_positive_sync_interval() {
let parts = ["changes", "coordination_branch", "sync_interval_seconds"];
let value = json!(120);
assert!(validate_config_value(&parts, &value).is_ok());
}
#[test]
fn validate_config_value_rejects_zero_sync_interval() {
let parts = ["changes", "coordination_branch", "sync_interval_seconds"];
let value = json!(0);
let err = validate_config_value(&parts, &value).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("positive integer"));
assert!(msg.contains("changes.coordination_branch.sync_interval_seconds"));
}
#[test]
fn validate_config_value_accepts_archive_main_integration_mode() {
let parts = ["changes", "archive", "main_integration_mode"];
let value = json!("pull_request_auto_merge");
assert!(validate_config_value(&parts, &value).is_ok());
}
#[test]
fn validate_config_value_rejects_invalid_archive_main_integration_mode() {
let parts = ["changes", "archive", "main_integration_mode"];
let value = json!("always_merge");
let err = validate_config_value(&parts, &value).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Invalid value"));
assert!(msg.contains("changes.archive.main_integration_mode"));
}
#[test]
fn validate_config_value_accepts_valid_audit_mirror_branch_name() {
let parts = ["audit", "mirror", "branch"];
let value = json!("ito/internal/audit");
assert!(validate_config_value(&parts, &value).is_ok());
}
#[test]
fn validate_config_value_rejects_invalid_audit_mirror_branch_name() {
let parts = ["audit", "mirror", "branch"];
let value = json!("--ito-audit");
let err = validate_config_value(&parts, &value).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Invalid value"));
assert!(msg.contains("audit.mirror.branch"));
}
#[test]
fn resolve_worktree_template_defaults_uses_defaults_when_missing() {
let project = tempfile::tempdir().expect("tempdir should succeed");
let ctx = ConfigContext {
project_dir: Some(project.path().to_path_buf()),
..Default::default()
};
let resolved = resolve_worktree_template_defaults(project.path(), &ctx);
assert_eq!(
resolved,
WorktreeTemplateDefaults {
strategy: "checkout_subdir".to_string(),
layout_dir_name: "ito-worktrees".to_string(),
integration_mode: "commit_pr".to_string(),
default_branch: "main".to_string(),
}
);
}
#[test]
fn resolve_worktree_template_defaults_reads_overrides() {
let project = tempfile::tempdir().expect("tempdir should succeed");
let ito_dir = project.path().join(".ito");
std::fs::create_dir_all(&ito_dir).expect("create .ito should succeed");
std::fs::write(
ito_dir.join("config.json"),
r#"{
"worktrees": {
"strategy": "bare_control_siblings",
"default_branch": "develop",
"layout": { "dir_name": "wt" },
"apply": { "integration_mode": "merge_parent" }
}
}
"#,
)
.expect("write config should succeed");
let ctx = ConfigContext {
project_dir: Some(project.path().to_path_buf()),
..Default::default()
};
let resolved = resolve_worktree_template_defaults(project.path(), &ctx);
assert_eq!(
resolved,
WorktreeTemplateDefaults {
strategy: "bare_control_siblings".to_string(),
layout_dir_name: "wt".to_string(),
integration_mode: "merge_parent".to_string(),
default_branch: "develop".to_string(),
}
);
}
#[test]
fn validate_config_value_rejects_unknown_memory_kind() {
let parts = ["memory", "capture", "kind"];
let value = json!("delegate");
let err = validate_config_value(&parts, &value).expect_err("expected error");
let msg = err.to_string();
assert!(msg.contains("memory.capture.kind"), "msg = {msg}");
assert!(msg.contains("skill") && msg.contains("command"));
}
#[test]
fn validate_config_value_accepts_valid_memory_kind() {
for op in ["capture", "search", "query"] {
let parts = ["memory", op, "kind"];
for kind in ["skill", "command"] {
assert!(
validate_config_value(&parts, &json!(kind)).is_ok(),
"expected memory.{op}.kind = {kind} to validate"
);
}
}
}
#[test]
fn validate_config_value_rejects_empty_memory_skill_id() {
let parts = ["memory", "search", "skill"];
let err = validate_config_value(&parts, &json!(" ")).expect_err("expected error");
assert!(
err.to_string().contains("memory.search.skill"),
"msg = {err}"
);
}
#[test]
fn validate_config_value_rejects_empty_memory_command_template() {
let parts = ["memory", "query", "command"];
let err = validate_config_value(&parts, &json!("")).expect_err("expected error");
assert!(
err.to_string().contains("memory.query.command"),
"msg = {err}"
);
}
#[test]
fn validate_config_value_rejects_unknown_memory_op_key() {
let parts = ["memory"];
let value = json!({
"curate": { "kind": "command", "command": "noop" }
});
let err = validate_config_value(&parts, &value).expect_err("expected error");
let msg = err.to_string();
assert!(msg.contains("Unknown memory operation"), "msg = {msg}");
assert!(msg.contains("curate"), "msg = {msg}");
}
#[test]
fn validate_config_value_rejects_memory_op_missing_required_field() {
let parts = ["memory", "capture"];
let err = validate_config_value(&parts, &json!({ "kind": "skill" }))
.expect_err("skill variant requires `skill`");
assert!(err.to_string().contains("memory.capture.skill"));
let err = validate_config_value(&parts, &json!({ "kind": "command" }))
.expect_err("command variant requires `command`");
assert!(err.to_string().contains("memory.capture.command"));
}
#[test]
fn validate_config_value_rejects_memory_op_unknown_kind() {
let parts = ["memory", "search"];
let err = validate_config_value(&parts, &json!({ "kind": "magic", "command": "noop" }))
.expect_err("expected error");
let msg = err.to_string();
assert!(msg.contains("Invalid 'kind' value 'magic'"), "msg = {msg}");
assert!(msg.contains("memory.search"), "msg = {msg}");
}
#[test]
fn validate_memory_config_passes_when_no_skill_provider() {
let config = MemoryConfig {
capture: Some(MemoryOpConfig::Command {
command: "brv curate \"{context}\"".to_string(),
}),
search: None,
query: None,
};
validate_memory_config(&config, &[]).expect("command-only config should validate");
}
#[test]
fn validate_memory_config_passes_when_skill_resolves_in_flat_layout() {
let tmp = tempfile::TempDir::new().unwrap();
let skill_dir = tmp.path().join(".claude/skills/my-skill");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "stub").unwrap();
let config = MemoryConfig {
capture: Some(MemoryOpConfig::Skill {
skill: "my-skill".to_string(),
options: None,
}),
search: None,
query: None,
};
let paths = known_skills_search_paths(tmp.path());
validate_memory_config(&config, &paths).expect("flat skill should resolve");
}
#[test]
fn validate_memory_config_passes_when_skill_resolves_in_grouped_layout() {
let tmp = tempfile::TempDir::new().unwrap();
let skill_dir = tmp
.path()
.join(".agents/skills/byterover/byterover-explore");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "stub").unwrap();
let config = MemoryConfig {
capture: Some(MemoryOpConfig::Skill {
skill: "byterover-explore".to_string(),
options: None,
}),
search: None,
query: None,
};
let paths = known_skills_search_paths(tmp.path());
validate_memory_config(&config, &paths)
.expect("grouped skill (.agents/skills/<group>/<id>) should resolve");
}
#[test]
fn validate_memory_config_rejects_missing_skill() {
let tmp = tempfile::TempDir::new().unwrap();
let config = MemoryConfig {
capture: None,
search: Some(MemoryOpConfig::Skill {
skill: "nonexistent".to_string(),
options: None,
}),
query: None,
};
let paths = known_skills_search_paths(tmp.path());
let err = validate_memory_config(&config, &paths)
.expect_err("missing skill should fail validation");
let msg = err.to_string();
assert!(msg.contains("memory.search"), "msg = {msg}");
assert!(msg.contains("nonexistent"), "msg = {msg}");
assert!(msg.contains(".agents/skills") || msg.contains(".claude/skills"));
}
#[test]
fn skill_id_resolves_returns_false_when_no_paths_exist() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = known_skills_search_paths(tmp.path());
assert!(!skill_id_resolves("anything", &paths));
}
}