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    ProposalIntegrationMode, 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        "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        // Wildcard: config keys are open-ended strings; only enum-constrained
353        // keys are validated above. New constrained keys should be added here.
354        _ => {}
355    }
356    Ok(())
357}
358
359/// Validate a structurally-set `memory` section value.
360///
361/// Accepts the leaf values for each operation (`capture`, `search`, `query`).
362/// Unknown operation keys are rejected here so that `ito config set memory <json>`
363/// surfaces typos like `curate` (instead of `capture`) with a clear message.
364fn 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
384/// Validate a structurally-set `memory.<op>` value.
385///
386/// `op_name` is `capture`, `search`, or `query`.
387fn 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
424/// Validate a deserialized [`MemoryConfig`].
425///
426/// Performs structural checks that serde alone cannot enforce — most notably
427/// that any operation configured with `kind: "skill"` references a skill id
428/// discoverable under one of the supplied search paths.
429///
430/// `search_paths` SHOULD be the list of skills directories returned by
431/// [`known_skills_search_paths`] for the active project.
432///
433/// # Errors
434///
435/// Returns [`CoreError::Validation`] for any operation whose skill id does
436/// not resolve to a directory containing `SKILL.md` under one of the
437/// supplied search paths. Lists the searched paths in the error message.
438pub 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
476/// Return the conventional skills directories Ito searches when resolving a
477/// skill id under [`MemoryOpConfig::Skill`].
478///
479/// Order is deterministic for stable error messages but does not imply any
480/// preference — a skill id matches as soon as any directory contains
481/// `<dir>/<skill-id>/SKILL.md` (or the skill id directly under
482/// `.agents/skills/<group>/<skill-id>/SKILL.md` for the shared layout used
483/// by ByteRover).
484pub 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
498/// Returns `true` if `skill_id` resolves to a directory containing
499/// `SKILL.md` under any of the supplied search paths.
500///
501/// Resolution accepts two layouts:
502/// - **Flat**: `<search-path>/<skill-id>/SKILL.md` (used by `.claude/skills/`,
503///   `.opencode/skills/`, etc.).
504/// - **Grouped**: `<search-path>/<group>/<skill-id>/SKILL.md` (used by
505///   `.agents/skills/<group>/<skill-id>/SKILL.md` — e.g. the ByteRover hub
506///   skills mirrored under `.agents/skills/byterover/`).
507pub 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        // Flat layout.
513        if base.join(skill_id).join("SKILL.md").is_file() {
514            return true;
515        }
516        // Grouped layout — one level deeper.
517        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
573/// Validate that a worktree strategy string is one of the supported values.
574///
575/// Returns `true` if valid, `false` otherwise.
576pub fn is_valid_worktree_strategy(s: &str) -> bool {
577    WorktreeStrategy::parse_value(s).is_some()
578}
579
580/// Validate that an integration mode string is one of the supported values.
581///
582/// Returns `true` if valid, `false` otherwise.
583pub fn is_valid_integration_mode(s: &str) -> bool {
584    IntegrationMode::parse_value(s).is_some()
585}
586
587/// Validate that a repository persistence mode string is one of the supported values.
588///
589/// Returns `true` if valid, `false` otherwise.
590pub fn is_valid_repository_mode(s: &str) -> bool {
591    RepositoryPersistenceMode::parse_value(s).is_some()
592}
593
594#[derive(Debug, Clone, PartialEq, Eq)]
595/// Resolved defaults used when rendering worktree-aware templates.
596pub struct WorktreeTemplateDefaults {
597    /// Worktree strategy (e.g., `checkout_subdir`).
598    pub strategy: String,
599    /// Directory name used by the strategy layout.
600    pub layout_dir_name: String,
601    /// Integration mode for applying changes.
602    pub integration_mode: String,
603    /// Default branch name.
604    pub default_branch: String,
605}
606
607/// Resolve effective worktree defaults from cascading project configuration.
608///
609/// Falls back to built-in defaults when keys are not configured.
610pub 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
655/// Remove a key at a dot-delimited path in a JSON object.
656///
657/// Returns `true` if a key was removed, `false` if the path didn't exist.
658///
659/// # Errors
660///
661/// Returns [`CoreError::Validation`] if the path is empty.
662pub 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;