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::types::{IntegrationMode, WorktreeStrategy};
10
11/// Read a JSON config file, returning an empty object if the file doesn't exist.
12///
13/// # Errors
14///
15/// Returns [`CoreError::Serde`] if the file contains invalid JSON or is not a JSON object.
16pub fn read_json_config(path: &Path) -> CoreResult<serde_json::Value> {
17    let Ok(contents) = std::fs::read_to_string(path) else {
18        return Ok(serde_json::Value::Object(serde_json::Map::new()));
19    };
20    let v: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
21        CoreError::serde(format!("Invalid JSON in {}", path.display()), e.to_string())
22    })?;
23    match v {
24        serde_json::Value::Object(_) => Ok(v),
25        _ => Err(CoreError::serde(
26            format!("Expected JSON object in {}", path.display()),
27            "root value is not an object",
28        )),
29    }
30}
31
32/// Write a JSON value to a config file (pretty-printed with trailing newline).
33///
34/// # Errors
35///
36/// Returns [`CoreError::Serde`] if serialization fails, or [`CoreError::Io`] if writing fails.
37pub fn write_json_config(path: &Path, value: &serde_json::Value) -> CoreResult<()> {
38    let mut bytes = serde_json::to_vec_pretty(value)
39        .map_err(|e| CoreError::serde("Failed to serialize JSON config", e.to_string()))?;
40    bytes.push(b'\n');
41    ito_common::io::write_atomic_std(path, bytes)
42        .map_err(|e| CoreError::io(format!("Failed to write config to {}", path.display()), e))?;
43    Ok(())
44}
45
46/// Parse a CLI argument as a JSON value, falling back to a string if parsing fails.
47///
48/// If `force_string` is true, always returns a JSON string without attempting to parse.
49pub fn parse_json_value_arg(raw: &str, force_string: bool) -> serde_json::Value {
50    if force_string {
51        return serde_json::Value::String(raw.to_string());
52    }
53    match serde_json::from_str::<serde_json::Value>(raw) {
54        Ok(v) => v,
55        Err(_) => serde_json::Value::String(raw.to_string()),
56    }
57}
58
59/// Split a dot-delimited config key path into parts, trimming and filtering empty segments.
60pub fn json_split_path(key: &str) -> Vec<&str> {
61    let mut out: Vec<&str> = Vec::new();
62    for part in key.split('.') {
63        let part = part.trim();
64        if part.is_empty() {
65            continue;
66        }
67        out.push(part);
68    }
69    out
70}
71
72/// Navigate a JSON object by a slice of path parts, returning the value if found.
73pub fn json_get_path<'a>(
74    root: &'a serde_json::Value,
75    parts: &[&str],
76) -> Option<&'a serde_json::Value> {
77    let mut cur = root;
78    for p in parts {
79        let serde_json::Value::Object(map) = cur else {
80            return None;
81        };
82        let next = map.get(*p)?;
83        cur = next;
84    }
85    Some(cur)
86}
87
88/// Set a value at a dot-delimited path in a JSON object, creating intermediate objects as needed.
89///
90/// # Errors
91///
92/// Returns [`CoreError::Validation`] if the path is empty or if setting the path fails.
93pub fn json_set_path(
94    root: &mut serde_json::Value,
95    parts: &[&str],
96    value: serde_json::Value,
97) -> CoreResult<()> {
98    if parts.is_empty() {
99        return Err(CoreError::validation("Invalid empty path"));
100    }
101
102    let mut cur = root;
103    for (i, key) in parts.iter().enumerate() {
104        let is_last = i + 1 == parts.len();
105
106        let is_object = matches!(cur, serde_json::Value::Object(_));
107        if !is_object {
108            *cur = serde_json::Value::Object(serde_json::Map::new());
109        }
110
111        let serde_json::Value::Object(map) = cur else {
112            return Err(CoreError::validation("Failed to set path"));
113        };
114
115        if is_last {
116            map.insert((*key).to_string(), value);
117            return Ok(());
118        }
119
120        let needs_object = match map.get(*key) {
121            Some(serde_json::Value::Object(_)) => false,
122            Some(_) => true,
123            None => true,
124        };
125        if needs_object {
126            map.insert(
127                (*key).to_string(),
128                serde_json::Value::Object(serde_json::Map::new()),
129            );
130        }
131
132        let Some(next) = map.get_mut(*key) else {
133            return Err(CoreError::validation("Failed to set path"));
134        };
135        cur = next;
136    }
137
138    Ok(())
139}
140
141/// Validate a config value for known keys that require enum values.
142///
143/// Returns `Ok(())` if the key is not constrained or the value is valid.
144/// Returns `Err` with a descriptive message if the value is invalid.
145///
146/// # Errors
147///
148/// Returns [`CoreError::Validation`] if the value does not match the allowed enum values.
149pub fn validate_config_value(parts: &[&str], value: &serde_json::Value) -> CoreResult<()> {
150    let path = parts.join(".");
151    match path.as_str() {
152        "worktrees.strategy" => {
153            let Some(s) = value.as_str() else {
154                return Err(CoreError::validation(format!(
155                    "Key '{}' requires a string value. Valid values: {}",
156                    path,
157                    WorktreeStrategy::ALL.join(", ")
158                )));
159            };
160            if WorktreeStrategy::parse_value(s).is_none() {
161                return Err(CoreError::validation(format!(
162                    "Invalid value '{}' for key '{}'. Valid values: {}",
163                    s,
164                    path,
165                    WorktreeStrategy::ALL.join(", ")
166                )));
167            }
168        }
169        "worktrees.apply.integration_mode" => {
170            let Some(s) = value.as_str() else {
171                return Err(CoreError::validation(format!(
172                    "Key '{}' requires a string value. Valid values: {}",
173                    path,
174                    IntegrationMode::ALL.join(", ")
175                )));
176            };
177            if IntegrationMode::parse_value(s).is_none() {
178                return Err(CoreError::validation(format!(
179                    "Invalid value '{}' for key '{}'. Valid values: {}",
180                    s,
181                    path,
182                    IntegrationMode::ALL.join(", ")
183                )));
184            }
185        }
186        _ => {}
187    }
188    Ok(())
189}
190
191/// Validate that a worktree strategy string is one of the supported values.
192///
193/// Returns `true` if valid, `false` otherwise.
194pub fn is_valid_worktree_strategy(s: &str) -> bool {
195    WorktreeStrategy::parse_value(s).is_some()
196}
197
198/// Validate that an integration mode string is one of the supported values.
199///
200/// Returns `true` if valid, `false` otherwise.
201pub fn is_valid_integration_mode(s: &str) -> bool {
202    IntegrationMode::parse_value(s).is_some()
203}
204
205/// Remove a key at a dot-delimited path in a JSON object.
206///
207/// Returns `true` if a key was removed, `false` if the path didn't exist.
208///
209/// # Errors
210///
211/// Returns [`CoreError::Validation`] if the path is empty.
212pub fn json_unset_path(root: &mut serde_json::Value, parts: &[&str]) -> CoreResult<bool> {
213    if parts.is_empty() {
214        return Err(CoreError::validation("Invalid empty path"));
215    }
216
217    let mut cur = root;
218    for (i, p) in parts.iter().enumerate() {
219        let is_last = i + 1 == parts.len();
220        let serde_json::Value::Object(map) = cur else {
221            return Ok(false);
222        };
223
224        if is_last {
225            return Ok(map.remove(*p).is_some());
226        }
227
228        let Some(next) = map.get_mut(*p) else {
229            return Ok(false);
230        };
231        cur = next;
232    }
233
234    Ok(false)
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use serde_json::json;
241
242    #[test]
243    fn validate_config_value_accepts_valid_strategy() {
244        let parts = ["worktrees", "strategy"];
245        let value = json!("checkout_subdir");
246        assert!(validate_config_value(&parts, &value).is_ok());
247
248        let value = json!("checkout_siblings");
249        assert!(validate_config_value(&parts, &value).is_ok());
250
251        let value = json!("bare_control_siblings");
252        assert!(validate_config_value(&parts, &value).is_ok());
253    }
254
255    #[test]
256    fn validate_config_value_rejects_invalid_strategy() {
257        let parts = ["worktrees", "strategy"];
258        let value = json!("custom_layout");
259        let err = validate_config_value(&parts, &value).unwrap_err();
260        let msg = err.to_string();
261        assert!(msg.contains("Invalid value"));
262        assert!(msg.contains("custom_layout"));
263    }
264
265    #[test]
266    fn validate_config_value_rejects_non_string_strategy() {
267        let parts = ["worktrees", "strategy"];
268        let value = json!(42);
269        let err = validate_config_value(&parts, &value).unwrap_err();
270        let msg = err.to_string();
271        assert!(msg.contains("requires a string value"));
272    }
273
274    #[test]
275    fn validate_config_value_accepts_valid_integration_mode() {
276        let parts = ["worktrees", "apply", "integration_mode"];
277        let value = json!("commit_pr");
278        assert!(validate_config_value(&parts, &value).is_ok());
279
280        let value = json!("merge_parent");
281        assert!(validate_config_value(&parts, &value).is_ok());
282    }
283
284    #[test]
285    fn validate_config_value_rejects_invalid_integration_mode() {
286        let parts = ["worktrees", "apply", "integration_mode"];
287        let value = json!("squash_merge");
288        let err = validate_config_value(&parts, &value).unwrap_err();
289        let msg = err.to_string();
290        assert!(msg.contains("Invalid value"));
291        assert!(msg.contains("squash_merge"));
292    }
293
294    #[test]
295    fn validate_config_value_accepts_unknown_keys() {
296        let parts = ["worktrees", "enabled"];
297        let value = json!(true);
298        assert!(validate_config_value(&parts, &value).is_ok());
299
300        let parts = ["some", "other", "key"];
301        let value = json!("anything");
302        assert!(validate_config_value(&parts, &value).is_ok());
303    }
304
305    #[test]
306    fn is_valid_worktree_strategy_checks_correctly() {
307        assert!(is_valid_worktree_strategy("checkout_subdir"));
308        assert!(is_valid_worktree_strategy("checkout_siblings"));
309        assert!(is_valid_worktree_strategy("bare_control_siblings"));
310        assert!(!is_valid_worktree_strategy("custom"));
311        assert!(!is_valid_worktree_strategy(""));
312    }
313
314    #[test]
315    fn is_valid_integration_mode_checks_correctly() {
316        assert!(is_valid_integration_mode("commit_pr"));
317        assert!(is_valid_integration_mode("merge_parent"));
318        assert!(!is_valid_integration_mode("squash"));
319        assert!(!is_valid_integration_mode(""));
320    }
321}