1use 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, WorktreeStrategy};
12
13pub 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
34pub 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
48pub 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
61pub 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
74pub 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#[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
147pub 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 "changes.coordination_branch.name" => {
193 let Some(s) = value.as_str() else {
194 return Err(CoreError::validation(format!(
195 "Key '{}' requires a string value.",
196 path,
197 )));
198 };
199 if !is_valid_branch_name(s) {
200 return Err(CoreError::validation(format!(
201 "Invalid value '{}' for key '{}'. Provide a valid git branch name.",
202 s, path,
203 )));
204 }
205 }
206 _ => {}
207 }
208 Ok(())
209}
210
211fn is_valid_branch_name(value: &str) -> bool {
212 if value.is_empty() || value.starts_with('-') || value.starts_with('/') || value.ends_with('/')
213 {
214 return false;
215 }
216 if value.contains("..")
217 || value.contains("@{")
218 || value.contains("//")
219 || value.ends_with('.')
220 || value.ends_with(".lock")
221 {
222 return false;
223 }
224
225 for ch in value.chars() {
226 if ch.is_ascii_control() || ch == ' ' {
227 return false;
228 }
229 if ch == '~' || ch == '^' || ch == ':' || ch == '?' || ch == '*' || ch == '[' || ch == '\\'
230 {
231 return false;
232 }
233 }
234
235 for segment in value.split('/') {
236 if segment.is_empty()
237 || segment.starts_with('.')
238 || segment.ends_with('.')
239 || segment.ends_with(".lock")
240 {
241 return false;
242 }
243 }
244
245 true
246}
247
248pub fn is_valid_worktree_strategy(s: &str) -> bool {
252 WorktreeStrategy::parse_value(s).is_some()
253}
254
255pub fn is_valid_integration_mode(s: &str) -> bool {
259 IntegrationMode::parse_value(s).is_some()
260}
261
262#[derive(Debug, Clone, PartialEq, Eq)]
263pub struct WorktreeTemplateDefaults {
265 pub strategy: String,
267 pub layout_dir_name: String,
269 pub integration_mode: String,
271 pub default_branch: String,
273}
274
275pub fn resolve_worktree_template_defaults(
279 target_path: &Path,
280 ctx: &ConfigContext,
281) -> WorktreeTemplateDefaults {
282 let ito_path = ito_config::ito_dir::get_ito_path(target_path, ctx);
283 let merged = load_cascading_project_config(target_path, &ito_path, ctx).merged;
284
285 let mut defaults = WorktreeTemplateDefaults {
286 strategy: "checkout_subdir".to_string(),
287 layout_dir_name: "ito-worktrees".to_string(),
288 integration_mode: "commit_pr".to_string(),
289 default_branch: "main".to_string(),
290 };
291
292 if let Some(wt) = merged.get("worktrees") {
293 if let Some(v) = wt.get("strategy").and_then(|v| v.as_str())
294 && !v.is_empty()
295 {
296 defaults.strategy = v.to_string();
297 }
298
299 if let Some(v) = wt.get("default_branch").and_then(|v| v.as_str())
300 && !v.is_empty()
301 {
302 defaults.default_branch = v.to_string();
303 }
304
305 if let Some(layout) = wt.get("layout")
306 && let Some(v) = layout.get("dir_name").and_then(|v| v.as_str())
307 && !v.is_empty()
308 {
309 defaults.layout_dir_name = v.to_string();
310 }
311
312 if let Some(apply) = wt.get("apply")
313 && let Some(v) = apply.get("integration_mode").and_then(|v| v.as_str())
314 && !v.is_empty()
315 {
316 defaults.integration_mode = v.to_string();
317 }
318 }
319
320 defaults
321}
322
323pub fn json_unset_path(root: &mut serde_json::Value, parts: &[&str]) -> CoreResult<bool> {
331 if parts.is_empty() {
332 return Err(CoreError::validation("Invalid empty path"));
333 }
334
335 let mut cur = root;
336 for (i, p) in parts.iter().enumerate() {
337 let is_last = i + 1 == parts.len();
338 let serde_json::Value::Object(map) = cur else {
339 return Ok(false);
340 };
341
342 if is_last {
343 return Ok(map.remove(*p).is_some());
344 }
345
346 let Some(next) = map.get_mut(*p) else {
347 return Ok(false);
348 };
349 cur = next;
350 }
351
352 Ok(false)
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use serde_json::json;
359
360 #[test]
361 fn validate_config_value_accepts_valid_strategy() {
362 let parts = ["worktrees", "strategy"];
363 let value = json!("checkout_subdir");
364 assert!(validate_config_value(&parts, &value).is_ok());
365
366 let value = json!("checkout_siblings");
367 assert!(validate_config_value(&parts, &value).is_ok());
368
369 let value = json!("bare_control_siblings");
370 assert!(validate_config_value(&parts, &value).is_ok());
371 }
372
373 #[test]
374 fn validate_config_value_rejects_invalid_strategy() {
375 let parts = ["worktrees", "strategy"];
376 let value = json!("custom_layout");
377 let err = validate_config_value(&parts, &value).unwrap_err();
378 let msg = err.to_string();
379 assert!(msg.contains("Invalid value"));
380 assert!(msg.contains("custom_layout"));
381 }
382
383 #[test]
384 fn validate_config_value_rejects_non_string_strategy() {
385 let parts = ["worktrees", "strategy"];
386 let value = json!(42);
387 let err = validate_config_value(&parts, &value).unwrap_err();
388 let msg = err.to_string();
389 assert!(msg.contains("requires a string value"));
390 }
391
392 #[test]
393 fn validate_config_value_accepts_valid_integration_mode() {
394 let parts = ["worktrees", "apply", "integration_mode"];
395 let value = json!("commit_pr");
396 assert!(validate_config_value(&parts, &value).is_ok());
397
398 let value = json!("merge_parent");
399 assert!(validate_config_value(&parts, &value).is_ok());
400 }
401
402 #[test]
403 fn validate_config_value_rejects_invalid_integration_mode() {
404 let parts = ["worktrees", "apply", "integration_mode"];
405 let value = json!("squash_merge");
406 let err = validate_config_value(&parts, &value).unwrap_err();
407 let msg = err.to_string();
408 assert!(msg.contains("Invalid value"));
409 assert!(msg.contains("squash_merge"));
410 }
411
412 #[test]
413 fn validate_config_value_accepts_unknown_keys() {
414 let parts = ["worktrees", "enabled"];
415 let value = json!(true);
416 assert!(validate_config_value(&parts, &value).is_ok());
417
418 let parts = ["some", "other", "key"];
419 let value = json!("anything");
420 assert!(validate_config_value(&parts, &value).is_ok());
421 }
422
423 #[test]
424 fn is_valid_worktree_strategy_checks_correctly() {
425 assert!(is_valid_worktree_strategy("checkout_subdir"));
426 assert!(is_valid_worktree_strategy("checkout_siblings"));
427 assert!(is_valid_worktree_strategy("bare_control_siblings"));
428 assert!(!is_valid_worktree_strategy("custom"));
429 assert!(!is_valid_worktree_strategy(""));
430 }
431
432 #[test]
433 fn is_valid_integration_mode_checks_correctly() {
434 assert!(is_valid_integration_mode("commit_pr"));
435 assert!(is_valid_integration_mode("merge_parent"));
436 assert!(!is_valid_integration_mode("squash"));
437 assert!(!is_valid_integration_mode(""));
438 }
439
440 #[test]
441 fn validate_config_value_accepts_valid_coordination_branch_name() {
442 let parts = ["changes", "coordination_branch", "name"];
443 let value = json!("ito/internal/changes");
444 assert!(validate_config_value(&parts, &value).is_ok());
445 }
446
447 #[test]
448 fn validate_config_value_rejects_invalid_coordination_branch_name() {
449 let parts = ["changes", "coordination_branch", "name"];
450 let value = json!("--ito-changes");
451 let err = validate_config_value(&parts, &value).unwrap_err();
452 let msg = err.to_string();
453 assert!(msg.contains("Invalid value"));
454 assert!(msg.contains("changes.coordination_branch.name"));
455 }
456
457 #[test]
458 fn validate_config_value_rejects_lock_suffix_in_path_segment() {
459 let parts = ["changes", "coordination_branch", "name"];
460 let value = json!("foo.lock/bar");
461 let err = validate_config_value(&parts, &value).unwrap_err();
462 let msg = err.to_string();
463 assert!(msg.contains("Invalid value"));
464 assert!(msg.contains("changes.coordination_branch.name"));
465 }
466
467 #[test]
468 fn resolve_worktree_template_defaults_uses_defaults_when_missing() {
469 let project = tempfile::tempdir().expect("tempdir should succeed");
470 let ctx = ConfigContext {
471 project_dir: Some(project.path().to_path_buf()),
472 ..Default::default()
473 };
474
475 let resolved = resolve_worktree_template_defaults(project.path(), &ctx);
476 assert_eq!(
477 resolved,
478 WorktreeTemplateDefaults {
479 strategy: "checkout_subdir".to_string(),
480 layout_dir_name: "ito-worktrees".to_string(),
481 integration_mode: "commit_pr".to_string(),
482 default_branch: "main".to_string(),
483 }
484 );
485 }
486
487 #[test]
488 fn resolve_worktree_template_defaults_reads_overrides() {
489 let project = tempfile::tempdir().expect("tempdir should succeed");
490 let ito_dir = project.path().join(".ito");
491 std::fs::create_dir_all(&ito_dir).expect("create .ito should succeed");
492 std::fs::write(
493 ito_dir.join("config.json"),
494 r#"{
495 "worktrees": {
496 "strategy": "bare_control_siblings",
497 "default_branch": "develop",
498 "layout": { "dir_name": "wt" },
499 "apply": { "integration_mode": "merge_parent" }
500 }
501}
502"#,
503 )
504 .expect("write config should succeed");
505
506 let ctx = ConfigContext {
507 project_dir: Some(project.path().to_path_buf()),
508 ..Default::default()
509 };
510
511 let resolved = resolve_worktree_template_defaults(project.path(), &ctx);
512 assert_eq!(
513 resolved,
514 WorktreeTemplateDefaults {
515 strategy: "bare_control_siblings".to_string(),
516 layout_dir_name: "wt".to_string(),
517 integration_mode: "merge_parent".to_string(),
518 default_branch: "develop".to_string(),
519 }
520 );
521 }
522}