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, RepositoryPersistenceMode, 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 "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
279pub fn is_valid_worktree_strategy(s: &str) -> bool {
283 WorktreeStrategy::parse_value(s).is_some()
284}
285
286pub fn is_valid_integration_mode(s: &str) -> bool {
290 IntegrationMode::parse_value(s).is_some()
291}
292
293pub fn is_valid_repository_mode(s: &str) -> bool {
297 RepositoryPersistenceMode::parse_value(s).is_some()
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct WorktreeTemplateDefaults {
303 pub strategy: String,
305 pub layout_dir_name: String,
307 pub integration_mode: String,
309 pub default_branch: String,
311}
312
313pub 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
361pub 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}