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 ProposalIntegrationMode, 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 "changes.proposal.integration_mode" => {
258 let Some(s) = value.as_str() else {
259 return Err(CoreError::validation(format!(
260 "Key '{}' requires a string value. Valid values: {}",
261 path,
262 ProposalIntegrationMode::ALL.join(", ")
263 )));
264 };
265 if ProposalIntegrationMode::parse_value(s).is_none() {
266 return Err(CoreError::validation(format!(
267 "Invalid value '{}' for key '{}'. Valid values: {}",
268 s,
269 path,
270 ProposalIntegrationMode::ALL.join(", ")
271 )));
272 }
273 }
274 "audit.mirror.branch" => {
275 let Some(s) = value.as_str() else {
276 return Err(CoreError::validation(format!(
277 "Key '{}' requires a string value.",
278 path,
279 )));
280 };
281 if !is_valid_branch_name(s) {
282 return Err(CoreError::validation(format!(
283 "Invalid value '{}' for key '{}'. Provide a valid git branch name.",
284 s, path,
285 )));
286 }
287 }
288 path if matches!(
289 parts,
290 ["memory", op, "kind"]
291 if matches!(*op, "capture" | "search" | "query")
292 ) =>
293 {
294 let Some(s) = value.as_str() else {
295 return Err(CoreError::validation(format!(
296 "Key '{}' requires a string value. Valid values: skill, command",
297 path,
298 )));
299 };
300 if !matches!(s, "skill" | "command") {
301 return Err(CoreError::validation(format!(
302 "Invalid value '{}' for key '{}'. Valid values: skill, command",
303 s, path,
304 )));
305 }
306 }
307 path if matches!(
308 parts,
309 ["memory", op, "skill"]
310 if matches!(*op, "capture" | "search" | "query")
311 ) =>
312 {
313 let Some(s) = value.as_str() else {
314 return Err(CoreError::validation(format!(
315 "Key '{}' requires a non-empty string skill id.",
316 path,
317 )));
318 };
319 if s.trim().is_empty() {
320 return Err(CoreError::validation(format!(
321 "Invalid value for key '{}'. Provide a non-empty skill id.",
322 path,
323 )));
324 }
325 }
326 path if matches!(
327 parts,
328 ["memory", op, "command"]
329 if matches!(*op, "capture" | "search" | "query")
330 ) =>
331 {
332 let Some(s) = value.as_str() else {
333 return Err(CoreError::validation(format!(
334 "Key '{}' requires a non-empty string command template.",
335 path,
336 )));
337 };
338 if s.trim().is_empty() {
339 return Err(CoreError::validation(format!(
340 "Invalid value for key '{}'. Provide a non-empty command template.",
341 path,
342 )));
343 }
344 }
345 _ if matches!(parts, ["memory", op] if matches!(*op, "capture" | "search" | "query")) => {
346 let op_name = parts[1];
347 return validate_memory_op_value(op_name, value);
348 }
349 _ if parts == ["memory"] => {
350 return validate_memory_section_value(value);
351 }
352 _ => {}
355 }
356 Ok(())
357}
358
359fn validate_memory_section_value(value: &serde_json::Value) -> CoreResult<()> {
365 let Some(obj) = value.as_object() else {
366 return Err(CoreError::validation(
367 "Key 'memory' requires an object whose keys are operation names (capture, search, query).",
368 ));
369 };
370 for (key, child) in obj {
371 match key.as_str() {
372 "capture" | "search" | "query" => validate_memory_op_value(key, child)?,
373 other => {
374 return Err(CoreError::validation(format!(
375 "Unknown memory operation '{}'. Valid keys: capture, search, query.",
376 other
377 )));
378 }
379 }
380 }
381 Ok(())
382}
383
384fn validate_memory_op_value(op_name: &str, value: &serde_json::Value) -> CoreResult<()> {
388 let Some(obj) = value.as_object() else {
389 return Err(CoreError::validation(format!(
390 "Key 'memory.{}' requires an object describing the provider shape.",
391 op_name
392 )));
393 };
394
395 let Some(kind) = obj.get("kind").and_then(|v| v.as_str()) else {
396 return Err(CoreError::validation(format!(
397 "Key 'memory.{}' must include a string 'kind' field. Valid values: skill, command.",
398 op_name
399 )));
400 };
401
402 match kind {
403 "skill" => match obj.get("skill").and_then(|v| v.as_str()) {
404 Some(s) if !s.trim().is_empty() => Ok(()),
405 _ => Err(CoreError::validation(format!(
406 "Key 'memory.{}.skill' is required and must be a non-empty string when kind is 'skill'.",
407 op_name
408 ))),
409 },
410 "command" => match obj.get("command").and_then(|v| v.as_str()) {
411 Some(s) if !s.trim().is_empty() => Ok(()),
412 _ => Err(CoreError::validation(format!(
413 "Key 'memory.{}.command' is required and must be a non-empty string when kind is 'command'.",
414 op_name
415 ))),
416 },
417 other => Err(CoreError::validation(format!(
418 "Invalid 'kind' value '{}' for 'memory.{}'. Valid values: skill, command.",
419 other, op_name
420 ))),
421 }
422}
423
424pub fn validate_memory_config(config: &MemoryConfig, search_paths: &[PathBuf]) -> CoreResult<()> {
439 for (op_name, op) in [
440 ("capture", &config.capture),
441 ("search", &config.search),
442 ("query", &config.query),
443 ] {
444 let Some(MemoryOpConfig::Skill { skill, .. }) = op else {
445 continue;
446 };
447
448 if skill.trim().is_empty() {
449 return Err(CoreError::validation(format!(
450 "Key 'memory.{}.skill' must be a non-empty string when kind is 'skill'.",
451 op_name
452 )));
453 }
454
455 if !skill_id_resolves(skill, search_paths) {
456 let searched = search_paths
457 .iter()
458 .map(|p| p.display().to_string())
459 .collect::<Vec<_>>()
460 .join(", ");
461 return Err(CoreError::validation(format!(
462 "memory.{op}: skill id '{skill}' was not found under any of the searched skills directories: [{searched}]. Install the skill or correct the id.",
463 op = op_name,
464 skill = skill,
465 searched = if searched.is_empty() {
466 "(none configured)".to_string()
467 } else {
468 searched
469 },
470 )));
471 }
472 }
473 Ok(())
474}
475
476pub fn known_skills_search_paths(project_root: &Path) -> Vec<PathBuf> {
485 [
486 ".agents/skills",
487 ".claude/skills",
488 ".codex/skills",
489 ".opencode/skills",
490 ".pi/skills",
491 ".github/skills",
492 ]
493 .into_iter()
494 .map(|p| project_root.join(p))
495 .collect()
496}
497
498pub fn skill_id_resolves(skill_id: &str, search_paths: &[PathBuf]) -> bool {
508 for base in search_paths {
509 if !base.is_dir() {
510 continue;
511 }
512 if base.join(skill_id).join("SKILL.md").is_file() {
514 return true;
515 }
516 let Ok(entries) = std::fs::read_dir(base) else {
518 continue;
519 };
520 for entry in entries {
521 let Ok(entry) = entry else {
522 continue;
523 };
524 let path = entry.path();
525 if !path.is_dir() {
526 continue;
527 }
528 if path.join(skill_id).join("SKILL.md").is_file() {
529 return true;
530 }
531 }
532 }
533 false
534}
535
536fn is_valid_branch_name(value: &str) -> bool {
537 if value.is_empty() || value.starts_with('-') || value.starts_with('/') || value.ends_with('/')
538 {
539 return false;
540 }
541 if value.contains("..")
542 || value.contains("@{")
543 || value.contains("//")
544 || value.ends_with('.')
545 || value.ends_with(".lock")
546 {
547 return false;
548 }
549
550 for ch in value.chars() {
551 if ch.is_ascii_control() || ch == ' ' {
552 return false;
553 }
554 if ch == '~' || ch == '^' || ch == ':' || ch == '?' || ch == '*' || ch == '[' || ch == '\\'
555 {
556 return false;
557 }
558 }
559
560 for segment in value.split('/') {
561 if segment.is_empty()
562 || segment.starts_with('.')
563 || segment.ends_with('.')
564 || segment.ends_with(".lock")
565 {
566 return false;
567 }
568 }
569
570 true
571}
572
573pub fn is_valid_worktree_strategy(s: &str) -> bool {
577 WorktreeStrategy::parse_value(s).is_some()
578}
579
580pub fn is_valid_integration_mode(s: &str) -> bool {
584 IntegrationMode::parse_value(s).is_some()
585}
586
587pub fn is_valid_repository_mode(s: &str) -> bool {
591 RepositoryPersistenceMode::parse_value(s).is_some()
592}
593
594#[derive(Debug, Clone, PartialEq, Eq)]
595pub struct WorktreeTemplateDefaults {
597 pub strategy: String,
599 pub layout_dir_name: String,
601 pub integration_mode: String,
603 pub default_branch: String,
605}
606
607pub fn resolve_worktree_template_defaults(
611 target_path: &Path,
612 ctx: &ConfigContext,
613) -> WorktreeTemplateDefaults {
614 let ito_path = ito_config::ito_dir::get_ito_path(target_path, ctx);
615 let merged = load_cascading_project_config(target_path, &ito_path, ctx).merged;
616
617 let mut defaults = WorktreeTemplateDefaults {
618 strategy: "checkout_subdir".to_string(),
619 layout_dir_name: "ito-worktrees".to_string(),
620 integration_mode: "commit_pr".to_string(),
621 default_branch: "main".to_string(),
622 };
623
624 if let Some(wt) = merged.get("worktrees") {
625 if let Some(v) = wt.get("strategy").and_then(|v| v.as_str())
626 && !v.is_empty()
627 {
628 defaults.strategy = v.to_string();
629 }
630
631 if let Some(v) = wt.get("default_branch").and_then(|v| v.as_str())
632 && !v.is_empty()
633 {
634 defaults.default_branch = v.to_string();
635 }
636
637 if let Some(layout) = wt.get("layout")
638 && let Some(v) = layout.get("dir_name").and_then(|v| v.as_str())
639 && !v.is_empty()
640 {
641 defaults.layout_dir_name = v.to_string();
642 }
643
644 if let Some(apply) = wt.get("apply")
645 && let Some(v) = apply.get("integration_mode").and_then(|v| v.as_str())
646 && !v.is_empty()
647 {
648 defaults.integration_mode = v.to_string();
649 }
650 }
651
652 defaults
653}
654
655pub fn json_unset_path(root: &mut serde_json::Value, parts: &[&str]) -> CoreResult<bool> {
663 if parts.is_empty() {
664 return Err(CoreError::validation("Invalid empty path"));
665 }
666
667 let mut cur = root;
668 for (i, p) in parts.iter().enumerate() {
669 let is_last = i + 1 == parts.len();
670 let serde_json::Value::Object(map) = cur else {
671 return Ok(false);
672 };
673
674 if is_last {
675 return Ok(map.remove(*p).is_some());
676 }
677
678 let Some(next) = map.get_mut(*p) else {
679 return Ok(false);
680 };
681 cur = next;
682 }
683
684 Ok(false)
685}
686
687#[cfg(test)]
688#[path = "config_tests.rs"]
689mod config_tests;