Skip to main content

ito_core/
config.rs

1//! JSON configuration file CRUD operations.
2//!
3//! This module provides low-level functions for reading, writing, and
4//! manipulating JSON configuration files with dot-delimited path navigation.
5
6use 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
16/// Read a JSON config file, returning an empty object if the file doesn't exist.
17///
18/// # Errors
19///
20/// Returns [`CoreError::Serde`] if the file contains invalid JSON or is not a JSON object.
21pub 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
37/// Write a JSON value to a config file (pretty-printed with trailing newline).
38///
39/// # Errors
40///
41/// Returns [`CoreError::Serde`] if serialization fails, or [`CoreError::Io`] if writing fails.
42pub 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
51/// Parse a CLI argument as a JSON value, falling back to a string if parsing fails.
52///
53/// If `force_string` is true, always returns a JSON string without attempting to parse.
54pub 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
64/// Split a dot-delimited config key path into parts, trimming and filtering empty segments.
65pub 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
77/// Navigate a JSON object by a slice of path parts, returning the value if found.
78pub 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/// Set a value at a dot-delimited path in a JSON object, creating intermediate objects as needed.
94///
95/// # Errors
96///
97/// Returns [`CoreError::Validation`] if the path is empty or if setting the path fails.
98#[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
150/// Validate a config value for known keys that require enum values.
151///
152/// Returns `Ok(())` if the key is not constrained or the value is valid.
153/// Returns `Err` with a descriptive message if the value is invalid.
154///
155/// # Errors
156///
157/// Returns [`CoreError::Validation`] if the value does not match the allowed enum values.
158pub 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        // Wildcard: config keys are open-ended strings; only enum-constrained
336        // keys are validated above. New constrained keys should be added here.
337        _ => {}
338    }
339    Ok(())
340}
341
342/// Validate a structurally-set `memory` section value.
343///
344/// Accepts the leaf values for each operation (`capture`, `search`, `query`).
345/// Unknown operation keys are rejected here so that `ito config set memory <json>`
346/// surfaces typos like `curate` (instead of `capture`) with a clear message.
347fn 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
367/// Validate a structurally-set `memory.<op>` value.
368///
369/// `op_name` is `capture`, `search`, or `query`.
370fn 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
407/// Validate a deserialized [`MemoryConfig`].
408///
409/// Performs structural checks that serde alone cannot enforce — most notably
410/// that any operation configured with `kind: "skill"` references a skill id
411/// discoverable under one of the supplied search paths.
412///
413/// `search_paths` SHOULD be the list of skills directories returned by
414/// [`known_skills_search_paths`] for the active project.
415///
416/// # Errors
417///
418/// Returns [`CoreError::Validation`] for any operation whose skill id does
419/// not resolve to a directory containing `SKILL.md` under one of the
420/// supplied search paths. Lists the searched paths in the error message.
421pub 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
459/// Return the conventional skills directories Ito searches when resolving a
460/// skill id under [`MemoryOpConfig::Skill`].
461///
462/// Order is deterministic for stable error messages but does not imply any
463/// preference — a skill id matches as soon as any directory contains
464/// `<dir>/<skill-id>/SKILL.md` (or the skill id directly under
465/// `.agents/skills/<group>/<skill-id>/SKILL.md` for the shared layout used
466/// by ByteRover).
467pub 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
481/// Returns `true` if `skill_id` resolves to a directory containing
482/// `SKILL.md` under any of the supplied search paths.
483///
484/// Resolution accepts two layouts:
485/// - **Flat**: `<search-path>/<skill-id>/SKILL.md` (used by `.claude/skills/`,
486///   `.opencode/skills/`, etc.).
487/// - **Grouped**: `<search-path>/<group>/<skill-id>/SKILL.md` (used by
488///   `.agents/skills/<group>/<skill-id>/SKILL.md` — e.g. the ByteRover hub
489///   skills mirrored under `.agents/skills/byterover/`).
490pub 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        // Flat layout.
496        if base.join(skill_id).join("SKILL.md").is_file() {
497            return true;
498        }
499        // Grouped layout — one level deeper.
500        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
556/// Validate that a worktree strategy string is one of the supported values.
557///
558/// Returns `true` if valid, `false` otherwise.
559pub fn is_valid_worktree_strategy(s: &str) -> bool {
560    WorktreeStrategy::parse_value(s).is_some()
561}
562
563/// Validate that an integration mode string is one of the supported values.
564///
565/// Returns `true` if valid, `false` otherwise.
566pub fn is_valid_integration_mode(s: &str) -> bool {
567    IntegrationMode::parse_value(s).is_some()
568}
569
570/// Validate that a repository persistence mode string is one of the supported values.
571///
572/// Returns `true` if valid, `false` otherwise.
573pub fn is_valid_repository_mode(s: &str) -> bool {
574    RepositoryPersistenceMode::parse_value(s).is_some()
575}
576
577#[derive(Debug, Clone, PartialEq, Eq)]
578/// Resolved defaults used when rendering worktree-aware templates.
579pub struct WorktreeTemplateDefaults {
580    /// Worktree strategy (e.g., `checkout_subdir`).
581    pub strategy: String,
582    /// Directory name used by the strategy layout.
583    pub layout_dir_name: String,
584    /// Integration mode for applying changes.
585    pub integration_mode: String,
586    /// Default branch name.
587    pub default_branch: String,
588}
589
590/// Resolve effective worktree defaults from cascading project configuration.
591///
592/// Falls back to built-in defaults when keys are not configured.
593pub 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
638/// Remove a key at a dot-delimited path in a JSON object.
639///
640/// Returns `true` if a key was removed, `false` if the path didn't exist.
641///
642/// # Errors
643///
644/// Returns [`CoreError::Validation`] if the path is empty.
645pub 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    // ---- memory config validation ------------------------------------------------
918
919    #[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        // Searched-paths list should include at least one of the conventional dirs.
1068        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}