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