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