1use std::path::{Path, PathBuf};
7
8use crate::errors::{CoreError, CoreResult};
9use ito_config::ConfigContext;
10use ito_config::load_cascading_project_config;
11use ito_config::types::{
12 ArchiveMainIntegrationMode, IntegrationMode, MemoryConfig, MemoryOpConfig,
13 RepositoryPersistenceMode, WorktreeStrategy,
14};
15
16pub fn read_json_config(path: &Path) -> CoreResult<serde_json::Value> {
22 let Ok(contents) = std::fs::read_to_string(path) else {
23 return Ok(serde_json::Value::Object(serde_json::Map::new()));
24 };
25 let v: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
26 CoreError::serde(format!("Invalid JSON in {}", path.display()), e.to_string())
27 })?;
28 match v {
29 serde_json::Value::Object(_) => Ok(v),
30 _ => Err(CoreError::serde(
31 format!("Expected JSON object in {}", path.display()),
32 "root value is not an object",
33 )),
34 }
35}
36
37pub fn write_json_config(path: &Path, value: &serde_json::Value) -> CoreResult<()> {
43 let mut bytes = serde_json::to_vec_pretty(value)
44 .map_err(|e| CoreError::serde("Failed to serialize JSON config", e.to_string()))?;
45 bytes.push(b'\n');
46 ito_common::io::write_atomic_std(path, bytes)
47 .map_err(|e| CoreError::io(format!("Failed to write config to {}", path.display()), e))?;
48 Ok(())
49}
50
51pub fn parse_json_value_arg(raw: &str, force_string: bool) -> serde_json::Value {
55 if force_string {
56 return serde_json::Value::String(raw.to_string());
57 }
58 match serde_json::from_str::<serde_json::Value>(raw) {
59 Ok(v) => v,
60 Err(_) => serde_json::Value::String(raw.to_string()),
61 }
62}
63
64pub fn json_split_path(key: &str) -> Vec<&str> {
66 let mut out: Vec<&str> = Vec::new();
67 for part in key.split('.') {
68 let part = part.trim();
69 if part.is_empty() {
70 continue;
71 }
72 out.push(part);
73 }
74 out
75}
76
77pub fn json_get_path<'a>(
79 root: &'a serde_json::Value,
80 parts: &[&str],
81) -> Option<&'a serde_json::Value> {
82 let mut cur = root;
83 for p in parts {
84 let serde_json::Value::Object(map) = cur else {
85 return None;
86 };
87 let next = map.get(*p)?;
88 cur = next;
89 }
90 Some(cur)
91}
92
93#[allow(clippy::match_like_matches_macro)]
99pub fn json_set_path(
100 root: &mut serde_json::Value,
101 parts: &[&str],
102 value: serde_json::Value,
103) -> CoreResult<()> {
104 if parts.is_empty() {
105 return Err(CoreError::validation("Invalid empty path"));
106 }
107
108 let mut cur = root;
109 for (i, key) in parts.iter().enumerate() {
110 let is_last = i + 1 == parts.len();
111
112 let is_object = match cur {
113 serde_json::Value::Object(_) => true,
114 _ => false,
115 };
116 if !is_object {
117 *cur = serde_json::Value::Object(serde_json::Map::new());
118 }
119
120 let serde_json::Value::Object(map) = cur else {
121 return Err(CoreError::validation("Failed to set path"));
122 };
123
124 if is_last {
125 map.insert((*key).to_string(), value);
126 return Ok(());
127 }
128
129 let needs_object = match map.get(*key) {
130 Some(serde_json::Value::Object(_)) => false,
131 Some(_) => true,
132 None => true,
133 };
134 if needs_object {
135 map.insert(
136 (*key).to_string(),
137 serde_json::Value::Object(serde_json::Map::new()),
138 );
139 }
140
141 let Some(next) = map.get_mut(*key) else {
142 return Err(CoreError::validation("Failed to set path"));
143 };
144 cur = next;
145 }
146
147 Ok(())
148}
149
150pub fn validate_config_value(parts: &[&str], value: &serde_json::Value) -> CoreResult<()> {
159 let path = parts.join(".");
160 match path.as_str() {
161 "worktrees.strategy" => {
162 let Some(s) = value.as_str() else {
163 return Err(CoreError::validation(format!(
164 "Key '{}' requires a string value. Valid values: {}",
165 path,
166 WorktreeStrategy::ALL.join(", ")
167 )));
168 };
169 if WorktreeStrategy::parse_value(s).is_none() {
170 return Err(CoreError::validation(format!(
171 "Invalid value '{}' for key '{}'. Valid values: {}",
172 s,
173 path,
174 WorktreeStrategy::ALL.join(", ")
175 )));
176 }
177 }
178 "worktrees.apply.integration_mode" => {
179 let Some(s) = value.as_str() else {
180 return Err(CoreError::validation(format!(
181 "Key '{}' requires a string value. Valid values: {}",
182 path,
183 IntegrationMode::ALL.join(", ")
184 )));
185 };
186 if IntegrationMode::parse_value(s).is_none() {
187 return Err(CoreError::validation(format!(
188 "Invalid value '{}' for key '{}'. Valid values: {}",
189 s,
190 path,
191 IntegrationMode::ALL.join(", ")
192 )));
193 }
194 }
195 "repository.mode" => {
196 let Some(s) = value.as_str() else {
197 return Err(CoreError::validation(format!(
198 "Key '{}' requires a string value. Valid values: {}",
199 path,
200 RepositoryPersistenceMode::ALL.join(", ")
201 )));
202 };
203 if RepositoryPersistenceMode::parse_value(s).is_none() {
204 return Err(CoreError::validation(format!(
205 "Invalid value '{}' for key '{}'. Valid values: {}",
206 s,
207 path,
208 RepositoryPersistenceMode::ALL.join(", ")
209 )));
210 }
211 }
212 "changes.coordination_branch.name" => {
213 let Some(s) = value.as_str() else {
214 return Err(CoreError::validation(format!(
215 "Key '{}' requires a string value.",
216 path,
217 )));
218 };
219 if !is_valid_branch_name(s) {
220 return Err(CoreError::validation(format!(
221 "Invalid value '{}' for key '{}'. Provide a valid git branch name.",
222 s, path,
223 )));
224 }
225 }
226 "changes.coordination_branch.sync_interval_seconds" => {
227 let Some(n) = value.as_u64() else {
228 return Err(CoreError::validation(format!(
229 "Key '{}' requires a positive integer value in seconds.",
230 path,
231 )));
232 };
233 if n == 0 {
234 return Err(CoreError::validation(format!(
235 "Invalid value '{}' for key '{}'. Provide a positive integer number of seconds.",
236 n, path,
237 )));
238 }
239 }
240 "changes.archive.main_integration_mode" => {
241 let Some(s) = value.as_str() else {
242 return Err(CoreError::validation(format!(
243 "Key '{}' requires a string value. Valid values: {}",
244 path,
245 ArchiveMainIntegrationMode::ALL.join(", ")
246 )));
247 };
248 if ArchiveMainIntegrationMode::parse_value(s).is_none() {
249 return Err(CoreError::validation(format!(
250 "Invalid value '{}' for key '{}'. Valid values: {}",
251 s,
252 path,
253 ArchiveMainIntegrationMode::ALL.join(", ")
254 )));
255 }
256 }
257 "audit.mirror.branch" => {
258 let Some(s) = value.as_str() else {
259 return Err(CoreError::validation(format!(
260 "Key '{}' requires a string value.",
261 path,
262 )));
263 };
264 if !is_valid_branch_name(s) {
265 return Err(CoreError::validation(format!(
266 "Invalid value '{}' for key '{}'. Provide a valid git branch name.",
267 s, path,
268 )));
269 }
270 }
271 path if matches!(
272 parts,
273 ["memory", op, "kind"]
274 if matches!(*op, "capture" | "search" | "query")
275 ) =>
276 {
277 let Some(s) = value.as_str() else {
278 return Err(CoreError::validation(format!(
279 "Key '{}' requires a string value. Valid values: skill, command",
280 path,
281 )));
282 };
283 if !matches!(s, "skill" | "command") {
284 return Err(CoreError::validation(format!(
285 "Invalid value '{}' for key '{}'. Valid values: skill, command",
286 s, path,
287 )));
288 }
289 }
290 path if matches!(
291 parts,
292 ["memory", op, "skill"]
293 if matches!(*op, "capture" | "search" | "query")
294 ) =>
295 {
296 let Some(s) = value.as_str() else {
297 return Err(CoreError::validation(format!(
298 "Key '{}' requires a non-empty string skill id.",
299 path,
300 )));
301 };
302 if s.trim().is_empty() {
303 return Err(CoreError::validation(format!(
304 "Invalid value for key '{}'. Provide a non-empty skill id.",
305 path,
306 )));
307 }
308 }
309 path if matches!(
310 parts,
311 ["memory", op, "command"]
312 if matches!(*op, "capture" | "search" | "query")
313 ) =>
314 {
315 let Some(s) = value.as_str() else {
316 return Err(CoreError::validation(format!(
317 "Key '{}' requires a non-empty string command template.",
318 path,
319 )));
320 };
321 if s.trim().is_empty() {
322 return Err(CoreError::validation(format!(
323 "Invalid value for key '{}'. Provide a non-empty command template.",
324 path,
325 )));
326 }
327 }
328 _ if matches!(parts, ["memory", op] if matches!(*op, "capture" | "search" | "query")) => {
329 let op_name = parts[1];
330 return validate_memory_op_value(op_name, value);
331 }
332 _ if parts == ["memory"] => {
333 return validate_memory_section_value(value);
334 }
335 _ => {}
338 }
339 Ok(())
340}
341
342fn validate_memory_section_value(value: &serde_json::Value) -> CoreResult<()> {
348 let Some(obj) = value.as_object() else {
349 return Err(CoreError::validation(
350 "Key 'memory' requires an object whose keys are operation names (capture, search, query).",
351 ));
352 };
353 for (key, child) in obj {
354 match key.as_str() {
355 "capture" | "search" | "query" => validate_memory_op_value(key, child)?,
356 other => {
357 return Err(CoreError::validation(format!(
358 "Unknown memory operation '{}'. Valid keys: capture, search, query.",
359 other
360 )));
361 }
362 }
363 }
364 Ok(())
365}
366
367fn validate_memory_op_value(op_name: &str, value: &serde_json::Value) -> CoreResult<()> {
371 let Some(obj) = value.as_object() else {
372 return Err(CoreError::validation(format!(
373 "Key 'memory.{}' requires an object describing the provider shape.",
374 op_name
375 )));
376 };
377
378 let Some(kind) = obj.get("kind").and_then(|v| v.as_str()) else {
379 return Err(CoreError::validation(format!(
380 "Key 'memory.{}' must include a string 'kind' field. Valid values: skill, command.",
381 op_name
382 )));
383 };
384
385 match kind {
386 "skill" => match obj.get("skill").and_then(|v| v.as_str()) {
387 Some(s) if !s.trim().is_empty() => Ok(()),
388 _ => Err(CoreError::validation(format!(
389 "Key 'memory.{}.skill' is required and must be a non-empty string when kind is 'skill'.",
390 op_name
391 ))),
392 },
393 "command" => match obj.get("command").and_then(|v| v.as_str()) {
394 Some(s) if !s.trim().is_empty() => Ok(()),
395 _ => Err(CoreError::validation(format!(
396 "Key 'memory.{}.command' is required and must be a non-empty string when kind is 'command'.",
397 op_name
398 ))),
399 },
400 other => Err(CoreError::validation(format!(
401 "Invalid 'kind' value '{}' for 'memory.{}'. Valid values: skill, command.",
402 other, op_name
403 ))),
404 }
405}
406
407pub fn validate_memory_config(config: &MemoryConfig, search_paths: &[PathBuf]) -> CoreResult<()> {
422 for (op_name, op) in [
423 ("capture", &config.capture),
424 ("search", &config.search),
425 ("query", &config.query),
426 ] {
427 let Some(MemoryOpConfig::Skill { skill, .. }) = op else {
428 continue;
429 };
430
431 if skill.trim().is_empty() {
432 return Err(CoreError::validation(format!(
433 "Key 'memory.{}.skill' must be a non-empty string when kind is 'skill'.",
434 op_name
435 )));
436 }
437
438 if !skill_id_resolves(skill, search_paths) {
439 let searched = search_paths
440 .iter()
441 .map(|p| p.display().to_string())
442 .collect::<Vec<_>>()
443 .join(", ");
444 return Err(CoreError::validation(format!(
445 "memory.{op}: skill id '{skill}' was not found under any of the searched skills directories: [{searched}]. Install the skill or correct the id.",
446 op = op_name,
447 skill = skill,
448 searched = if searched.is_empty() {
449 "(none configured)".to_string()
450 } else {
451 searched
452 },
453 )));
454 }
455 }
456 Ok(())
457}
458
459pub fn known_skills_search_paths(project_root: &Path) -> Vec<PathBuf> {
468 [
469 ".agents/skills",
470 ".claude/skills",
471 ".codex/skills",
472 ".opencode/skills",
473 ".pi/skills",
474 ".github/skills",
475 ]
476 .into_iter()
477 .map(|p| project_root.join(p))
478 .collect()
479}
480
481pub fn skill_id_resolves(skill_id: &str, search_paths: &[PathBuf]) -> bool {
491 for base in search_paths {
492 if !base.is_dir() {
493 continue;
494 }
495 if base.join(skill_id).join("SKILL.md").is_file() {
497 return true;
498 }
499 let Ok(entries) = std::fs::read_dir(base) else {
501 continue;
502 };
503 for entry in entries {
504 let Ok(entry) = entry else {
505 continue;
506 };
507 let path = entry.path();
508 if !path.is_dir() {
509 continue;
510 }
511 if path.join(skill_id).join("SKILL.md").is_file() {
512 return true;
513 }
514 }
515 }
516 false
517}
518
519fn is_valid_branch_name(value: &str) -> bool {
520 if value.is_empty() || value.starts_with('-') || value.starts_with('/') || value.ends_with('/')
521 {
522 return false;
523 }
524 if value.contains("..")
525 || value.contains("@{")
526 || value.contains("//")
527 || value.ends_with('.')
528 || value.ends_with(".lock")
529 {
530 return false;
531 }
532
533 for ch in value.chars() {
534 if ch.is_ascii_control() || ch == ' ' {
535 return false;
536 }
537 if ch == '~' || ch == '^' || ch == ':' || ch == '?' || ch == '*' || ch == '[' || ch == '\\'
538 {
539 return false;
540 }
541 }
542
543 for segment in value.split('/') {
544 if segment.is_empty()
545 || segment.starts_with('.')
546 || segment.ends_with('.')
547 || segment.ends_with(".lock")
548 {
549 return false;
550 }
551 }
552
553 true
554}
555
556pub fn is_valid_worktree_strategy(s: &str) -> bool {
560 WorktreeStrategy::parse_value(s).is_some()
561}
562
563pub fn is_valid_integration_mode(s: &str) -> bool {
567 IntegrationMode::parse_value(s).is_some()
568}
569
570pub fn is_valid_repository_mode(s: &str) -> bool {
574 RepositoryPersistenceMode::parse_value(s).is_some()
575}
576
577#[derive(Debug, Clone, PartialEq, Eq)]
578pub struct WorktreeTemplateDefaults {
580 pub strategy: String,
582 pub layout_dir_name: String,
584 pub integration_mode: String,
586 pub default_branch: String,
588}
589
590pub fn resolve_worktree_template_defaults(
594 target_path: &Path,
595 ctx: &ConfigContext,
596) -> WorktreeTemplateDefaults {
597 let ito_path = ito_config::ito_dir::get_ito_path(target_path, ctx);
598 let merged = load_cascading_project_config(target_path, &ito_path, ctx).merged;
599
600 let mut defaults = WorktreeTemplateDefaults {
601 strategy: "checkout_subdir".to_string(),
602 layout_dir_name: "ito-worktrees".to_string(),
603 integration_mode: "commit_pr".to_string(),
604 default_branch: "main".to_string(),
605 };
606
607 if let Some(wt) = merged.get("worktrees") {
608 if let Some(v) = wt.get("strategy").and_then(|v| v.as_str())
609 && !v.is_empty()
610 {
611 defaults.strategy = v.to_string();
612 }
613
614 if let Some(v) = wt.get("default_branch").and_then(|v| v.as_str())
615 && !v.is_empty()
616 {
617 defaults.default_branch = v.to_string();
618 }
619
620 if let Some(layout) = wt.get("layout")
621 && let Some(v) = layout.get("dir_name").and_then(|v| v.as_str())
622 && !v.is_empty()
623 {
624 defaults.layout_dir_name = v.to_string();
625 }
626
627 if let Some(apply) = wt.get("apply")
628 && let Some(v) = apply.get("integration_mode").and_then(|v| v.as_str())
629 && !v.is_empty()
630 {
631 defaults.integration_mode = v.to_string();
632 }
633 }
634
635 defaults
636}
637
638pub fn json_unset_path(root: &mut serde_json::Value, parts: &[&str]) -> CoreResult<bool> {
646 if parts.is_empty() {
647 return Err(CoreError::validation("Invalid empty path"));
648 }
649
650 let mut cur = root;
651 for (i, p) in parts.iter().enumerate() {
652 let is_last = i + 1 == parts.len();
653 let serde_json::Value::Object(map) = cur else {
654 return Ok(false);
655 };
656
657 if is_last {
658 return Ok(map.remove(*p).is_some());
659 }
660
661 let Some(next) = map.get_mut(*p) else {
662 return Ok(false);
663 };
664 cur = next;
665 }
666
667 Ok(false)
668}
669
670#[cfg(test)]
671#[path = "config_tests.rs"]
672mod config_tests;