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,
ProposalIntegrationMode, 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(", ")
)));
}
}
"changes.proposal.integration_mode" => {
let Some(s) = value.as_str() else {
return Err(CoreError::validation(format!(
"Key '{}' requires a string value. Valid values: {}",
path,
ProposalIntegrationMode::ALL.join(", ")
)));
};
if ProposalIntegrationMode::parse_value(s).is_none() {
return Err(CoreError::validation(format!(
"Invalid value '{}' for key '{}'. Valid values: {}",
s,
path,
ProposalIntegrationMode::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)]
#[path = "config_tests.rs"]
mod config_tests;