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)]
671mod tests {
672 use super::*;
673 use serde_json::json;
674
675 #[test]
676 fn validate_config_value_accepts_valid_strategy() {
677 let parts = ["worktrees", "strategy"];
678 let value = json!("checkout_subdir");
679 assert!(validate_config_value(&parts, &value).is_ok());
680
681 let value = json!("checkout_siblings");
682 assert!(validate_config_value(&parts, &value).is_ok());
683
684 let value = json!("bare_control_siblings");
685 assert!(validate_config_value(&parts, &value).is_ok());
686 }
687
688 #[test]
689 fn validate_config_value_rejects_invalid_strategy() {
690 let parts = ["worktrees", "strategy"];
691 let value = json!("custom_layout");
692 let err = validate_config_value(&parts, &value).unwrap_err();
693 let msg = err.to_string();
694 assert!(msg.contains("Invalid value"));
695 assert!(msg.contains("custom_layout"));
696 }
697
698 #[test]
699 fn validate_config_value_rejects_non_string_strategy() {
700 let parts = ["worktrees", "strategy"];
701 let value = json!(42);
702 let err = validate_config_value(&parts, &value).unwrap_err();
703 let msg = err.to_string();
704 assert!(msg.contains("requires a string value"));
705 }
706
707 #[test]
708 fn validate_config_value_accepts_valid_integration_mode() {
709 let parts = ["worktrees", "apply", "integration_mode"];
710 let value = json!("commit_pr");
711 assert!(validate_config_value(&parts, &value).is_ok());
712
713 let value = json!("merge_parent");
714 assert!(validate_config_value(&parts, &value).is_ok());
715 }
716
717 #[test]
718 fn validate_config_value_accepts_valid_repository_mode() {
719 let parts = ["repository", "mode"];
720 let value = json!("filesystem");
721 assert!(validate_config_value(&parts, &value).is_ok());
722
723 let value = json!("sqlite");
724 assert!(validate_config_value(&parts, &value).is_ok());
725 }
726
727 #[test]
728 fn validate_config_value_rejects_invalid_repository_mode() {
729 let parts = ["repository", "mode"];
730 let value = json!("remote");
731 let err = validate_config_value(&parts, &value).unwrap_err();
732 let msg = err.to_string();
733 assert!(msg.contains("Invalid value"));
734 assert!(msg.contains("repository.mode"));
735 }
736
737 #[test]
738 fn validate_config_value_rejects_invalid_integration_mode() {
739 let parts = ["worktrees", "apply", "integration_mode"];
740 let value = json!("squash_merge");
741 let err = validate_config_value(&parts, &value).unwrap_err();
742 let msg = err.to_string();
743 assert!(msg.contains("Invalid value"));
744 assert!(msg.contains("squash_merge"));
745 }
746
747 #[test]
748 fn validate_config_value_accepts_unknown_keys() {
749 let parts = ["worktrees", "enabled"];
750 let value = json!(true);
751 assert!(validate_config_value(&parts, &value).is_ok());
752
753 let parts = ["some", "other", "key"];
754 let value = json!("anything");
755 assert!(validate_config_value(&parts, &value).is_ok());
756 }
757
758 #[test]
759 fn is_valid_worktree_strategy_checks_correctly() {
760 assert!(is_valid_worktree_strategy("checkout_subdir"));
761 assert!(is_valid_worktree_strategy("checkout_siblings"));
762 assert!(is_valid_worktree_strategy("bare_control_siblings"));
763 assert!(!is_valid_worktree_strategy("custom"));
764 assert!(!is_valid_worktree_strategy(""));
765 }
766
767 #[test]
768 fn is_valid_integration_mode_checks_correctly() {
769 assert!(is_valid_integration_mode("commit_pr"));
770 assert!(is_valid_integration_mode("merge_parent"));
771 assert!(!is_valid_integration_mode("squash"));
772 assert!(!is_valid_integration_mode(""));
773 }
774
775 #[test]
776 fn is_valid_repository_mode_checks_correctly() {
777 assert!(is_valid_repository_mode("filesystem"));
778 assert!(is_valid_repository_mode("sqlite"));
779 assert!(!is_valid_repository_mode("remote"));
780 assert!(!is_valid_repository_mode(""));
781 }
782
783 #[test]
784 fn validate_config_value_accepts_valid_coordination_branch_name() {
785 let parts = ["changes", "coordination_branch", "name"];
786 let value = json!("ito/internal/changes");
787 assert!(validate_config_value(&parts, &value).is_ok());
788 }
789
790 #[test]
791 fn validate_config_value_rejects_invalid_coordination_branch_name() {
792 let parts = ["changes", "coordination_branch", "name"];
793 let value = json!("--ito-changes");
794 let err = validate_config_value(&parts, &value).unwrap_err();
795 let msg = err.to_string();
796 assert!(msg.contains("Invalid value"));
797 assert!(msg.contains("changes.coordination_branch.name"));
798 }
799
800 #[test]
801 fn validate_config_value_rejects_lock_suffix_in_path_segment() {
802 let parts = ["changes", "coordination_branch", "name"];
803 let value = json!("foo.lock/bar");
804 let err = validate_config_value(&parts, &value).unwrap_err();
805 let msg = err.to_string();
806 assert!(msg.contains("Invalid value"));
807 assert!(msg.contains("changes.coordination_branch.name"));
808 }
809
810 #[test]
811 fn validate_config_value_accepts_positive_sync_interval() {
812 let parts = ["changes", "coordination_branch", "sync_interval_seconds"];
813 let value = json!(120);
814 assert!(validate_config_value(&parts, &value).is_ok());
815 }
816
817 #[test]
818 fn validate_config_value_rejects_zero_sync_interval() {
819 let parts = ["changes", "coordination_branch", "sync_interval_seconds"];
820 let value = json!(0);
821 let err = validate_config_value(&parts, &value).unwrap_err();
822 let msg = err.to_string();
823 assert!(msg.contains("positive integer"));
824 assert!(msg.contains("changes.coordination_branch.sync_interval_seconds"));
825 }
826
827 #[test]
828 fn validate_config_value_accepts_archive_main_integration_mode() {
829 let parts = ["changes", "archive", "main_integration_mode"];
830 let value = json!("pull_request_auto_merge");
831 assert!(validate_config_value(&parts, &value).is_ok());
832 }
833
834 #[test]
835 fn validate_config_value_rejects_invalid_archive_main_integration_mode() {
836 let parts = ["changes", "archive", "main_integration_mode"];
837 let value = json!("always_merge");
838 let err = validate_config_value(&parts, &value).unwrap_err();
839 let msg = err.to_string();
840 assert!(msg.contains("Invalid value"));
841 assert!(msg.contains("changes.archive.main_integration_mode"));
842 }
843
844 #[test]
845 fn validate_config_value_accepts_valid_audit_mirror_branch_name() {
846 let parts = ["audit", "mirror", "branch"];
847 let value = json!("ito/internal/audit");
848 assert!(validate_config_value(&parts, &value).is_ok());
849 }
850
851 #[test]
852 fn validate_config_value_rejects_invalid_audit_mirror_branch_name() {
853 let parts = ["audit", "mirror", "branch"];
854 let value = json!("--ito-audit");
855 let err = validate_config_value(&parts, &value).unwrap_err();
856 let msg = err.to_string();
857 assert!(msg.contains("Invalid value"));
858 assert!(msg.contains("audit.mirror.branch"));
859 }
860
861 #[test]
862 fn resolve_worktree_template_defaults_uses_defaults_when_missing() {
863 let project = tempfile::tempdir().expect("tempdir should succeed");
864 let ctx = ConfigContext {
865 project_dir: Some(project.path().to_path_buf()),
866 ..Default::default()
867 };
868
869 let resolved = resolve_worktree_template_defaults(project.path(), &ctx);
870 assert_eq!(
871 resolved,
872 WorktreeTemplateDefaults {
873 strategy: "checkout_subdir".to_string(),
874 layout_dir_name: "ito-worktrees".to_string(),
875 integration_mode: "commit_pr".to_string(),
876 default_branch: "main".to_string(),
877 }
878 );
879 }
880
881 #[test]
882 fn resolve_worktree_template_defaults_reads_overrides() {
883 let project = tempfile::tempdir().expect("tempdir should succeed");
884 let ito_dir = project.path().join(".ito");
885 std::fs::create_dir_all(&ito_dir).expect("create .ito should succeed");
886 std::fs::write(
887 ito_dir.join("config.json"),
888 r#"{
889 "worktrees": {
890 "strategy": "bare_control_siblings",
891 "default_branch": "develop",
892 "layout": { "dir_name": "wt" },
893 "apply": { "integration_mode": "merge_parent" }
894 }
895}
896"#,
897 )
898 .expect("write config should succeed");
899
900 let ctx = ConfigContext {
901 project_dir: Some(project.path().to_path_buf()),
902 ..Default::default()
903 };
904
905 let resolved = resolve_worktree_template_defaults(project.path(), &ctx);
906 assert_eq!(
907 resolved,
908 WorktreeTemplateDefaults {
909 strategy: "bare_control_siblings".to_string(),
910 layout_dir_name: "wt".to_string(),
911 integration_mode: "merge_parent".to_string(),
912 default_branch: "develop".to_string(),
913 }
914 );
915 }
916
917 #[test]
920 fn validate_config_value_rejects_unknown_memory_kind() {
921 let parts = ["memory", "capture", "kind"];
922 let value = json!("delegate");
923 let err = validate_config_value(&parts, &value).expect_err("expected error");
924 let msg = err.to_string();
925 assert!(msg.contains("memory.capture.kind"), "msg = {msg}");
926 assert!(msg.contains("skill") && msg.contains("command"));
927 }
928
929 #[test]
930 fn validate_config_value_accepts_valid_memory_kind() {
931 for op in ["capture", "search", "query"] {
932 let parts = ["memory", op, "kind"];
933 for kind in ["skill", "command"] {
934 assert!(
935 validate_config_value(&parts, &json!(kind)).is_ok(),
936 "expected memory.{op}.kind = {kind} to validate"
937 );
938 }
939 }
940 }
941
942 #[test]
943 fn validate_config_value_rejects_empty_memory_skill_id() {
944 let parts = ["memory", "search", "skill"];
945 let err = validate_config_value(&parts, &json!(" ")).expect_err("expected error");
946 assert!(
947 err.to_string().contains("memory.search.skill"),
948 "msg = {err}"
949 );
950 }
951
952 #[test]
953 fn validate_config_value_rejects_empty_memory_command_template() {
954 let parts = ["memory", "query", "command"];
955 let err = validate_config_value(&parts, &json!("")).expect_err("expected error");
956 assert!(
957 err.to_string().contains("memory.query.command"),
958 "msg = {err}"
959 );
960 }
961
962 #[test]
963 fn validate_config_value_rejects_unknown_memory_op_key() {
964 let parts = ["memory"];
965 let value = json!({
966 "curate": { "kind": "command", "command": "noop" }
967 });
968 let err = validate_config_value(&parts, &value).expect_err("expected error");
969 let msg = err.to_string();
970 assert!(msg.contains("Unknown memory operation"), "msg = {msg}");
971 assert!(msg.contains("curate"), "msg = {msg}");
972 }
973
974 #[test]
975 fn validate_config_value_rejects_memory_op_missing_required_field() {
976 let parts = ["memory", "capture"];
977
978 let err = validate_config_value(&parts, &json!({ "kind": "skill" }))
979 .expect_err("skill variant requires `skill`");
980 assert!(err.to_string().contains("memory.capture.skill"));
981
982 let err = validate_config_value(&parts, &json!({ "kind": "command" }))
983 .expect_err("command variant requires `command`");
984 assert!(err.to_string().contains("memory.capture.command"));
985 }
986
987 #[test]
988 fn validate_config_value_rejects_memory_op_unknown_kind() {
989 let parts = ["memory", "search"];
990 let err = validate_config_value(&parts, &json!({ "kind": "magic", "command": "noop" }))
991 .expect_err("expected error");
992 let msg = err.to_string();
993 assert!(msg.contains("Invalid 'kind' value 'magic'"), "msg = {msg}");
994 assert!(msg.contains("memory.search"), "msg = {msg}");
995 }
996
997 #[test]
998 fn validate_memory_config_passes_when_no_skill_provider() {
999 let config = MemoryConfig {
1000 capture: Some(MemoryOpConfig::Command {
1001 command: "brv curate \"{context}\"".to_string(),
1002 }),
1003 search: None,
1004 query: None,
1005 };
1006 validate_memory_config(&config, &[]).expect("command-only config should validate");
1007 }
1008
1009 #[test]
1010 fn validate_memory_config_passes_when_skill_resolves_in_flat_layout() {
1011 let tmp = tempfile::TempDir::new().unwrap();
1012 let skill_dir = tmp.path().join(".claude/skills/my-skill");
1013 std::fs::create_dir_all(&skill_dir).unwrap();
1014 std::fs::write(skill_dir.join("SKILL.md"), "stub").unwrap();
1015
1016 let config = MemoryConfig {
1017 capture: Some(MemoryOpConfig::Skill {
1018 skill: "my-skill".to_string(),
1019 options: None,
1020 }),
1021 search: None,
1022 query: None,
1023 };
1024 let paths = known_skills_search_paths(tmp.path());
1025 validate_memory_config(&config, &paths).expect("flat skill should resolve");
1026 }
1027
1028 #[test]
1029 fn validate_memory_config_passes_when_skill_resolves_in_grouped_layout() {
1030 let tmp = tempfile::TempDir::new().unwrap();
1031 let skill_dir = tmp
1032 .path()
1033 .join(".agents/skills/byterover/byterover-explore");
1034 std::fs::create_dir_all(&skill_dir).unwrap();
1035 std::fs::write(skill_dir.join("SKILL.md"), "stub").unwrap();
1036
1037 let config = MemoryConfig {
1038 capture: Some(MemoryOpConfig::Skill {
1039 skill: "byterover-explore".to_string(),
1040 options: None,
1041 }),
1042 search: None,
1043 query: None,
1044 };
1045 let paths = known_skills_search_paths(tmp.path());
1046 validate_memory_config(&config, &paths)
1047 .expect("grouped skill (.agents/skills/<group>/<id>) should resolve");
1048 }
1049
1050 #[test]
1051 fn validate_memory_config_rejects_missing_skill() {
1052 let tmp = tempfile::TempDir::new().unwrap();
1053 let config = MemoryConfig {
1054 capture: None,
1055 search: Some(MemoryOpConfig::Skill {
1056 skill: "nonexistent".to_string(),
1057 options: None,
1058 }),
1059 query: None,
1060 };
1061 let paths = known_skills_search_paths(tmp.path());
1062 let err = validate_memory_config(&config, &paths)
1063 .expect_err("missing skill should fail validation");
1064 let msg = err.to_string();
1065 assert!(msg.contains("memory.search"), "msg = {msg}");
1066 assert!(msg.contains("nonexistent"), "msg = {msg}");
1067 assert!(msg.contains(".agents/skills") || msg.contains(".claude/skills"));
1069 }
1070
1071 #[test]
1072 fn skill_id_resolves_returns_false_when_no_paths_exist() {
1073 let tmp = tempfile::TempDir::new().unwrap();
1074 let paths = known_skills_search_paths(tmp.path());
1075 assert!(!skill_id_resolves("anything", &paths));
1076 }
1077}