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;
7
8use crate::errors::{CoreError, CoreResult};
9use ito_config::ConfigContext;
10use ito_config::load_cascading_project_config;
11use ito_config::types::{
12    ArchiveMainIntegrationMode, IntegrationMode, RepositoryPersistenceMode, WorktreeStrategy,
13};
14
15/// Read a JSON config file, returning an empty object if the file doesn't exist.
16///
17/// # Errors
18///
19/// Returns [`CoreError::Serde`] if the file contains invalid JSON or is not a JSON object.
20pub fn read_json_config(path: &Path) -> CoreResult<serde_json::Value> {
21    let Ok(contents) = std::fs::read_to_string(path) else {
22        return Ok(serde_json::Value::Object(serde_json::Map::new()));
23    };
24    let v: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
25        CoreError::serde(format!("Invalid JSON in {}", path.display()), e.to_string())
26    })?;
27    match v {
28        serde_json::Value::Object(_) => Ok(v),
29        _ => Err(CoreError::serde(
30            format!("Expected JSON object in {}", path.display()),
31            "root value is not an object",
32        )),
33    }
34}
35
36/// Write a JSON value to a config file (pretty-printed with trailing newline).
37///
38/// # Errors
39///
40/// Returns [`CoreError::Serde`] if serialization fails, or [`CoreError::Io`] if writing fails.
41pub fn write_json_config(path: &Path, value: &serde_json::Value) -> CoreResult<()> {
42    let mut bytes = serde_json::to_vec_pretty(value)
43        .map_err(|e| CoreError::serde("Failed to serialize JSON config", e.to_string()))?;
44    bytes.push(b'\n');
45    ito_common::io::write_atomic_std(path, bytes)
46        .map_err(|e| CoreError::io(format!("Failed to write config to {}", path.display()), e))?;
47    Ok(())
48}
49
50/// Parse a CLI argument as a JSON value, falling back to a string if parsing fails.
51///
52/// If `force_string` is true, always returns a JSON string without attempting to parse.
53pub fn parse_json_value_arg(raw: &str, force_string: bool) -> serde_json::Value {
54    if force_string {
55        return serde_json::Value::String(raw.to_string());
56    }
57    match serde_json::from_str::<serde_json::Value>(raw) {
58        Ok(v) => v,
59        Err(_) => serde_json::Value::String(raw.to_string()),
60    }
61}
62
63/// Split a dot-delimited config key path into parts, trimming and filtering empty segments.
64pub fn json_split_path(key: &str) -> Vec<&str> {
65    let mut out: Vec<&str> = Vec::new();
66    for part in key.split('.') {
67        let part = part.trim();
68        if part.is_empty() {
69            continue;
70        }
71        out.push(part);
72    }
73    out
74}
75
76/// Navigate a JSON object by a slice of path parts, returning the value if found.
77pub fn json_get_path<'a>(
78    root: &'a serde_json::Value,
79    parts: &[&str],
80) -> Option<&'a serde_json::Value> {
81    let mut cur = root;
82    for p in parts {
83        let serde_json::Value::Object(map) = cur else {
84            return None;
85        };
86        let next = map.get(*p)?;
87        cur = next;
88    }
89    Some(cur)
90}
91
92/// Set a value at a dot-delimited path in a JSON object, creating intermediate objects as needed.
93///
94/// # Errors
95///
96/// Returns [`CoreError::Validation`] if the path is empty or if setting the path fails.
97#[allow(clippy::match_like_matches_macro)]
98pub fn json_set_path(
99    root: &mut serde_json::Value,
100    parts: &[&str],
101    value: serde_json::Value,
102) -> CoreResult<()> {
103    if parts.is_empty() {
104        return Err(CoreError::validation("Invalid empty path"));
105    }
106
107    let mut cur = root;
108    for (i, key) in parts.iter().enumerate() {
109        let is_last = i + 1 == parts.len();
110
111        let is_object = match cur {
112            serde_json::Value::Object(_) => true,
113            _ => false,
114        };
115        if !is_object {
116            *cur = serde_json::Value::Object(serde_json::Map::new());
117        }
118
119        let serde_json::Value::Object(map) = cur else {
120            return Err(CoreError::validation("Failed to set path"));
121        };
122
123        if is_last {
124            map.insert((*key).to_string(), value);
125            return Ok(());
126        }
127
128        let needs_object = match map.get(*key) {
129            Some(serde_json::Value::Object(_)) => false,
130            Some(_) => true,
131            None => true,
132        };
133        if needs_object {
134            map.insert(
135                (*key).to_string(),
136                serde_json::Value::Object(serde_json::Map::new()),
137            );
138        }
139
140        let Some(next) = map.get_mut(*key) else {
141            return Err(CoreError::validation("Failed to set path"));
142        };
143        cur = next;
144    }
145
146    Ok(())
147}
148
149/// Validate a config value for known keys that require enum values.
150///
151/// Returns `Ok(())` if the key is not constrained or the value is valid.
152/// Returns `Err` with a descriptive message if the value is invalid.
153///
154/// # Errors
155///
156/// Returns [`CoreError::Validation`] if the value does not match the allowed enum values.
157pub fn validate_config_value(parts: &[&str], value: &serde_json::Value) -> CoreResult<()> {
158    let path = parts.join(".");
159    match path.as_str() {
160        "worktrees.strategy" => {
161            let Some(s) = value.as_str() else {
162                return Err(CoreError::validation(format!(
163                    "Key '{}' requires a string value. Valid values: {}",
164                    path,
165                    WorktreeStrategy::ALL.join(", ")
166                )));
167            };
168            if WorktreeStrategy::parse_value(s).is_none() {
169                return Err(CoreError::validation(format!(
170                    "Invalid value '{}' for key '{}'. Valid values: {}",
171                    s,
172                    path,
173                    WorktreeStrategy::ALL.join(", ")
174                )));
175            }
176        }
177        "worktrees.apply.integration_mode" => {
178            let Some(s) = value.as_str() else {
179                return Err(CoreError::validation(format!(
180                    "Key '{}' requires a string value. Valid values: {}",
181                    path,
182                    IntegrationMode::ALL.join(", ")
183                )));
184            };
185            if IntegrationMode::parse_value(s).is_none() {
186                return Err(CoreError::validation(format!(
187                    "Invalid value '{}' for key '{}'. Valid values: {}",
188                    s,
189                    path,
190                    IntegrationMode::ALL.join(", ")
191                )));
192            }
193        }
194        "repository.mode" => {
195            let Some(s) = value.as_str() else {
196                return Err(CoreError::validation(format!(
197                    "Key '{}' requires a string value. Valid values: {}",
198                    path,
199                    RepositoryPersistenceMode::ALL.join(", ")
200                )));
201            };
202            if RepositoryPersistenceMode::parse_value(s).is_none() {
203                return Err(CoreError::validation(format!(
204                    "Invalid value '{}' for key '{}'. Valid values: {}",
205                    s,
206                    path,
207                    RepositoryPersistenceMode::ALL.join(", ")
208                )));
209            }
210        }
211        "changes.coordination_branch.name" => {
212            let Some(s) = value.as_str() else {
213                return Err(CoreError::validation(format!(
214                    "Key '{}' requires a string value.",
215                    path,
216                )));
217            };
218            if !is_valid_branch_name(s) {
219                return Err(CoreError::validation(format!(
220                    "Invalid value '{}' for key '{}'. Provide a valid git branch name.",
221                    s, path,
222                )));
223            }
224        }
225        "changes.coordination_branch.sync_interval_seconds" => {
226            let Some(n) = value.as_u64() else {
227                return Err(CoreError::validation(format!(
228                    "Key '{}' requires a positive integer value in seconds.",
229                    path,
230                )));
231            };
232            if n == 0 {
233                return Err(CoreError::validation(format!(
234                    "Invalid value '{}' for key '{}'. Provide a positive integer number of seconds.",
235                    n, path,
236                )));
237            }
238        }
239        "changes.archive.main_integration_mode" => {
240            let Some(s) = value.as_str() else {
241                return Err(CoreError::validation(format!(
242                    "Key '{}' requires a string value. Valid values: {}",
243                    path,
244                    ArchiveMainIntegrationMode::ALL.join(", ")
245                )));
246            };
247            if ArchiveMainIntegrationMode::parse_value(s).is_none() {
248                return Err(CoreError::validation(format!(
249                    "Invalid value '{}' for key '{}'. Valid values: {}",
250                    s,
251                    path,
252                    ArchiveMainIntegrationMode::ALL.join(", ")
253                )));
254            }
255        }
256        "audit.mirror.branch" => {
257            let Some(s) = value.as_str() else {
258                return Err(CoreError::validation(format!(
259                    "Key '{}' requires a string value.",
260                    path,
261                )));
262            };
263            if !is_valid_branch_name(s) {
264                return Err(CoreError::validation(format!(
265                    "Invalid value '{}' for key '{}'. Provide a valid git branch name.",
266                    s, path,
267                )));
268            }
269        }
270        // Wildcard: config keys are open-ended strings; only enum-constrained
271        // keys are validated above. New constrained keys should be added here.
272        _ => {}
273    }
274    Ok(())
275}
276
277fn is_valid_branch_name(value: &str) -> bool {
278    if value.is_empty() || value.starts_with('-') || value.starts_with('/') || value.ends_with('/')
279    {
280        return false;
281    }
282    if value.contains("..")
283        || value.contains("@{")
284        || value.contains("//")
285        || value.ends_with('.')
286        || value.ends_with(".lock")
287    {
288        return false;
289    }
290
291    for ch in value.chars() {
292        if ch.is_ascii_control() || ch == ' ' {
293            return false;
294        }
295        if ch == '~' || ch == '^' || ch == ':' || ch == '?' || ch == '*' || ch == '[' || ch == '\\'
296        {
297            return false;
298        }
299    }
300
301    for segment in value.split('/') {
302        if segment.is_empty()
303            || segment.starts_with('.')
304            || segment.ends_with('.')
305            || segment.ends_with(".lock")
306        {
307            return false;
308        }
309    }
310
311    true
312}
313
314/// Validate that a worktree strategy string is one of the supported values.
315///
316/// Returns `true` if valid, `false` otherwise.
317pub fn is_valid_worktree_strategy(s: &str) -> bool {
318    WorktreeStrategy::parse_value(s).is_some()
319}
320
321/// Validate that an integration mode string is one of the supported values.
322///
323/// Returns `true` if valid, `false` otherwise.
324pub fn is_valid_integration_mode(s: &str) -> bool {
325    IntegrationMode::parse_value(s).is_some()
326}
327
328/// Validate that a repository persistence mode string is one of the supported values.
329///
330/// Returns `true` if valid, `false` otherwise.
331pub fn is_valid_repository_mode(s: &str) -> bool {
332    RepositoryPersistenceMode::parse_value(s).is_some()
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
336/// Resolved defaults used when rendering worktree-aware templates.
337pub struct WorktreeTemplateDefaults {
338    /// Worktree strategy (e.g., `checkout_subdir`).
339    pub strategy: String,
340    /// Directory name used by the strategy layout.
341    pub layout_dir_name: String,
342    /// Integration mode for applying changes.
343    pub integration_mode: String,
344    /// Default branch name.
345    pub default_branch: String,
346}
347
348/// Resolve effective worktree defaults from cascading project configuration.
349///
350/// Falls back to built-in defaults when keys are not configured.
351pub fn resolve_worktree_template_defaults(
352    target_path: &Path,
353    ctx: &ConfigContext,
354) -> WorktreeTemplateDefaults {
355    let ito_path = ito_config::ito_dir::get_ito_path(target_path, ctx);
356    let merged = load_cascading_project_config(target_path, &ito_path, ctx).merged;
357
358    let mut defaults = WorktreeTemplateDefaults {
359        strategy: "checkout_subdir".to_string(),
360        layout_dir_name: "ito-worktrees".to_string(),
361        integration_mode: "commit_pr".to_string(),
362        default_branch: "main".to_string(),
363    };
364
365    if let Some(wt) = merged.get("worktrees") {
366        if let Some(v) = wt.get("strategy").and_then(|v| v.as_str())
367            && !v.is_empty()
368        {
369            defaults.strategy = v.to_string();
370        }
371
372        if let Some(v) = wt.get("default_branch").and_then(|v| v.as_str())
373            && !v.is_empty()
374        {
375            defaults.default_branch = v.to_string();
376        }
377
378        if let Some(layout) = wt.get("layout")
379            && let Some(v) = layout.get("dir_name").and_then(|v| v.as_str())
380            && !v.is_empty()
381        {
382            defaults.layout_dir_name = v.to_string();
383        }
384
385        if let Some(apply) = wt.get("apply")
386            && let Some(v) = apply.get("integration_mode").and_then(|v| v.as_str())
387            && !v.is_empty()
388        {
389            defaults.integration_mode = v.to_string();
390        }
391    }
392
393    defaults
394}
395
396/// Remove a key at a dot-delimited path in a JSON object.
397///
398/// Returns `true` if a key was removed, `false` if the path didn't exist.
399///
400/// # Errors
401///
402/// Returns [`CoreError::Validation`] if the path is empty.
403pub fn json_unset_path(root: &mut serde_json::Value, parts: &[&str]) -> CoreResult<bool> {
404    if parts.is_empty() {
405        return Err(CoreError::validation("Invalid empty path"));
406    }
407
408    let mut cur = root;
409    for (i, p) in parts.iter().enumerate() {
410        let is_last = i + 1 == parts.len();
411        let serde_json::Value::Object(map) = cur else {
412            return Ok(false);
413        };
414
415        if is_last {
416            return Ok(map.remove(*p).is_some());
417        }
418
419        let Some(next) = map.get_mut(*p) else {
420            return Ok(false);
421        };
422        cur = next;
423    }
424
425    Ok(false)
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use serde_json::json;
432
433    #[test]
434    fn validate_config_value_accepts_valid_strategy() {
435        let parts = ["worktrees", "strategy"];
436        let value = json!("checkout_subdir");
437        assert!(validate_config_value(&parts, &value).is_ok());
438
439        let value = json!("checkout_siblings");
440        assert!(validate_config_value(&parts, &value).is_ok());
441
442        let value = json!("bare_control_siblings");
443        assert!(validate_config_value(&parts, &value).is_ok());
444    }
445
446    #[test]
447    fn validate_config_value_rejects_invalid_strategy() {
448        let parts = ["worktrees", "strategy"];
449        let value = json!("custom_layout");
450        let err = validate_config_value(&parts, &value).unwrap_err();
451        let msg = err.to_string();
452        assert!(msg.contains("Invalid value"));
453        assert!(msg.contains("custom_layout"));
454    }
455
456    #[test]
457    fn validate_config_value_rejects_non_string_strategy() {
458        let parts = ["worktrees", "strategy"];
459        let value = json!(42);
460        let err = validate_config_value(&parts, &value).unwrap_err();
461        let msg = err.to_string();
462        assert!(msg.contains("requires a string value"));
463    }
464
465    #[test]
466    fn validate_config_value_accepts_valid_integration_mode() {
467        let parts = ["worktrees", "apply", "integration_mode"];
468        let value = json!("commit_pr");
469        assert!(validate_config_value(&parts, &value).is_ok());
470
471        let value = json!("merge_parent");
472        assert!(validate_config_value(&parts, &value).is_ok());
473    }
474
475    #[test]
476    fn validate_config_value_accepts_valid_repository_mode() {
477        let parts = ["repository", "mode"];
478        let value = json!("filesystem");
479        assert!(validate_config_value(&parts, &value).is_ok());
480
481        let value = json!("sqlite");
482        assert!(validate_config_value(&parts, &value).is_ok());
483    }
484
485    #[test]
486    fn validate_config_value_rejects_invalid_repository_mode() {
487        let parts = ["repository", "mode"];
488        let value = json!("remote");
489        let err = validate_config_value(&parts, &value).unwrap_err();
490        let msg = err.to_string();
491        assert!(msg.contains("Invalid value"));
492        assert!(msg.contains("repository.mode"));
493    }
494
495    #[test]
496    fn validate_config_value_rejects_invalid_integration_mode() {
497        let parts = ["worktrees", "apply", "integration_mode"];
498        let value = json!("squash_merge");
499        let err = validate_config_value(&parts, &value).unwrap_err();
500        let msg = err.to_string();
501        assert!(msg.contains("Invalid value"));
502        assert!(msg.contains("squash_merge"));
503    }
504
505    #[test]
506    fn validate_config_value_accepts_unknown_keys() {
507        let parts = ["worktrees", "enabled"];
508        let value = json!(true);
509        assert!(validate_config_value(&parts, &value).is_ok());
510
511        let parts = ["some", "other", "key"];
512        let value = json!("anything");
513        assert!(validate_config_value(&parts, &value).is_ok());
514    }
515
516    #[test]
517    fn is_valid_worktree_strategy_checks_correctly() {
518        assert!(is_valid_worktree_strategy("checkout_subdir"));
519        assert!(is_valid_worktree_strategy("checkout_siblings"));
520        assert!(is_valid_worktree_strategy("bare_control_siblings"));
521        assert!(!is_valid_worktree_strategy("custom"));
522        assert!(!is_valid_worktree_strategy(""));
523    }
524
525    #[test]
526    fn is_valid_integration_mode_checks_correctly() {
527        assert!(is_valid_integration_mode("commit_pr"));
528        assert!(is_valid_integration_mode("merge_parent"));
529        assert!(!is_valid_integration_mode("squash"));
530        assert!(!is_valid_integration_mode(""));
531    }
532
533    #[test]
534    fn is_valid_repository_mode_checks_correctly() {
535        assert!(is_valid_repository_mode("filesystem"));
536        assert!(is_valid_repository_mode("sqlite"));
537        assert!(!is_valid_repository_mode("remote"));
538        assert!(!is_valid_repository_mode(""));
539    }
540
541    #[test]
542    fn validate_config_value_accepts_valid_coordination_branch_name() {
543        let parts = ["changes", "coordination_branch", "name"];
544        let value = json!("ito/internal/changes");
545        assert!(validate_config_value(&parts, &value).is_ok());
546    }
547
548    #[test]
549    fn validate_config_value_rejects_invalid_coordination_branch_name() {
550        let parts = ["changes", "coordination_branch", "name"];
551        let value = json!("--ito-changes");
552        let err = validate_config_value(&parts, &value).unwrap_err();
553        let msg = err.to_string();
554        assert!(msg.contains("Invalid value"));
555        assert!(msg.contains("changes.coordination_branch.name"));
556    }
557
558    #[test]
559    fn validate_config_value_rejects_lock_suffix_in_path_segment() {
560        let parts = ["changes", "coordination_branch", "name"];
561        let value = json!("foo.lock/bar");
562        let err = validate_config_value(&parts, &value).unwrap_err();
563        let msg = err.to_string();
564        assert!(msg.contains("Invalid value"));
565        assert!(msg.contains("changes.coordination_branch.name"));
566    }
567
568    #[test]
569    fn validate_config_value_accepts_positive_sync_interval() {
570        let parts = ["changes", "coordination_branch", "sync_interval_seconds"];
571        let value = json!(120);
572        assert!(validate_config_value(&parts, &value).is_ok());
573    }
574
575    #[test]
576    fn validate_config_value_rejects_zero_sync_interval() {
577        let parts = ["changes", "coordination_branch", "sync_interval_seconds"];
578        let value = json!(0);
579        let err = validate_config_value(&parts, &value).unwrap_err();
580        let msg = err.to_string();
581        assert!(msg.contains("positive integer"));
582        assert!(msg.contains("changes.coordination_branch.sync_interval_seconds"));
583    }
584
585    #[test]
586    fn validate_config_value_accepts_archive_main_integration_mode() {
587        let parts = ["changes", "archive", "main_integration_mode"];
588        let value = json!("pull_request_auto_merge");
589        assert!(validate_config_value(&parts, &value).is_ok());
590    }
591
592    #[test]
593    fn validate_config_value_rejects_invalid_archive_main_integration_mode() {
594        let parts = ["changes", "archive", "main_integration_mode"];
595        let value = json!("always_merge");
596        let err = validate_config_value(&parts, &value).unwrap_err();
597        let msg = err.to_string();
598        assert!(msg.contains("Invalid value"));
599        assert!(msg.contains("changes.archive.main_integration_mode"));
600    }
601
602    #[test]
603    fn validate_config_value_accepts_valid_audit_mirror_branch_name() {
604        let parts = ["audit", "mirror", "branch"];
605        let value = json!("ito/internal/audit");
606        assert!(validate_config_value(&parts, &value).is_ok());
607    }
608
609    #[test]
610    fn validate_config_value_rejects_invalid_audit_mirror_branch_name() {
611        let parts = ["audit", "mirror", "branch"];
612        let value = json!("--ito-audit");
613        let err = validate_config_value(&parts, &value).unwrap_err();
614        let msg = err.to_string();
615        assert!(msg.contains("Invalid value"));
616        assert!(msg.contains("audit.mirror.branch"));
617    }
618
619    #[test]
620    fn resolve_worktree_template_defaults_uses_defaults_when_missing() {
621        let project = tempfile::tempdir().expect("tempdir should succeed");
622        let ctx = ConfigContext {
623            project_dir: Some(project.path().to_path_buf()),
624            ..Default::default()
625        };
626
627        let resolved = resolve_worktree_template_defaults(project.path(), &ctx);
628        assert_eq!(
629            resolved,
630            WorktreeTemplateDefaults {
631                strategy: "checkout_subdir".to_string(),
632                layout_dir_name: "ito-worktrees".to_string(),
633                integration_mode: "commit_pr".to_string(),
634                default_branch: "main".to_string(),
635            }
636        );
637    }
638
639    #[test]
640    fn resolve_worktree_template_defaults_reads_overrides() {
641        let project = tempfile::tempdir().expect("tempdir should succeed");
642        let ito_dir = project.path().join(".ito");
643        std::fs::create_dir_all(&ito_dir).expect("create .ito should succeed");
644        std::fs::write(
645            ito_dir.join("config.json"),
646            r#"{
647  "worktrees": {
648    "strategy": "bare_control_siblings",
649    "default_branch": "develop",
650    "layout": { "dir_name": "wt" },
651    "apply": { "integration_mode": "merge_parent" }
652  }
653}
654"#,
655        )
656        .expect("write config should succeed");
657
658        let ctx = ConfigContext {
659            project_dir: Some(project.path().to_path_buf()),
660            ..Default::default()
661        };
662
663        let resolved = resolve_worktree_template_defaults(project.path(), &ctx);
664        assert_eq!(
665            resolved,
666            WorktreeTemplateDefaults {
667                strategy: "bare_control_siblings".to_string(),
668                layout_dir_name: "wt".to_string(),
669                integration_mode: "merge_parent".to_string(),
670                default_branch: "develop".to_string(),
671            }
672        );
673    }
674}