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::{
12 ArchiveMainIntegrationMode, IntegrationMode, RepositoryPersistenceMode, WorktreeStrategy,
13};
14
15pub 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
36pub 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
50pub 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
63pub 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
76pub 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#[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
149pub 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 _ => {}
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
314pub fn is_valid_worktree_strategy(s: &str) -> bool {
318 WorktreeStrategy::parse_value(s).is_some()
319}
320
321pub fn is_valid_integration_mode(s: &str) -> bool {
325 IntegrationMode::parse_value(s).is_some()
326}
327
328pub fn is_valid_repository_mode(s: &str) -> bool {
332 RepositoryPersistenceMode::parse_value(s).is_some()
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct WorktreeTemplateDefaults {
338 pub strategy: String,
340 pub layout_dir_name: String,
342 pub integration_mode: String,
344 pub default_branch: String,
346}
347
348pub 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
396pub 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}