1use std::io::ErrorKind;
13use std::path::{Path, PathBuf};
14
15use ito_common::fs::{FileSystem, StdFs};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19pub mod defaults;
21
22pub mod schema;
24
25pub mod types;
27
28const REPO_CONFIG_FILE_NAME: &str = "ito.json";
29const REPO_DOT_CONFIG_FILE_NAME: &str = ".ito.json";
30const ITO_DIR_CONFIG_FILE_NAME: &str = "config.json";
31const ITO_DIR_LOCAL_CONFIG_FILE_NAME: &str = "config.local.json";
32const PROJECT_LOCAL_CONFIG_PATH: &str = ".local/ito/config.json";
33
34#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
35pub struct GlobalConfig {
37 #[serde(rename = "projectPath")]
39 pub project_path: Option<String>,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43pub struct ProjectConfig {
45 #[serde(rename = "projectPath")]
47 pub project_path: Option<String>,
48}
49
50#[derive(Debug, Clone, Default)]
51pub struct ConfigContext {
53 pub xdg_config_home: Option<PathBuf>,
55 pub home_dir: Option<PathBuf>,
57 pub project_dir: Option<PathBuf>,
59}
60
61impl ConfigContext {
62 pub fn from_process_env() -> Self {
64 let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from);
65
66 let home_dir = std::env::var_os("HOME")
68 .map(PathBuf::from)
69 .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from));
70
71 let project_dir = std::env::var_os("PROJECT_DIR").map(PathBuf::from);
72 let project_dir = project_dir.map(|p| {
73 if p.is_absolute() {
74 return p;
75 }
76 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
77 cwd.join(p)
78 });
79
80 Self {
81 xdg_config_home,
82 home_dir,
83 project_dir,
84 }
85 }
86}
87
88fn read_to_string_optional_fs<F: FileSystem>(fs: &F, path: &Path) -> Option<String> {
89 match fs.read_to_string(path) {
90 Ok(s) => Some(s),
91 Err(e) if e.kind() == ErrorKind::NotFound => None,
92 Err(_) => None,
93 }
94}
95
96pub fn load_project_config(project_root: &Path) -> Option<ProjectConfig> {
98 load_project_config_fs(&StdFs, project_root)
99}
100
101pub fn load_project_config_fs<F: FileSystem>(fs: &F, project_root: &Path) -> Option<ProjectConfig> {
103 let path = project_root.join(REPO_CONFIG_FILE_NAME);
104 let contents = read_to_string_optional_fs(fs, &path)?;
105
106 match serde_json::from_str(&contents) {
107 Ok(v) => Some(v),
108 Err(_) => {
109 eprintln!(
110 "Warning: Invalid JSON in {}, ignoring project config",
111 path.display()
112 );
113 None
114 }
115 }
116}
117
118fn load_json_object_fs<F: FileSystem>(fs: &F, path: &Path) -> Option<Value> {
119 let contents = read_to_string_optional_fs(fs, path)?;
120
121 let v: Value = match serde_json::from_str(&contents) {
122 Ok(v) => v,
123 Err(_) => {
124 eprintln!("Warning: Invalid JSON in {}, ignoring", path.display());
125 return None;
126 }
127 };
128
129 match v {
130 Value::Object(mut obj) => {
131 obj.remove("$schema");
133 Some(Value::Object(obj))
134 }
135 _ => {
136 eprintln!(
137 "Warning: Expected JSON object in {}, ignoring",
138 path.display()
139 );
140 None
141 }
142 }
143}
144
145fn merge_json(base: &mut Value, overlay: Value) {
146 match (base, overlay) {
147 (Value::Object(base_map), Value::Object(overlay_map)) => {
148 for (k, v) in overlay_map {
149 let entry = base_map.get_mut(&k);
150 if let Some(base_v) = entry {
151 merge_json(base_v, v);
152 continue;
153 }
154 base_map.insert(k, v);
155 }
156 }
157 (base_v, overlay_v) => {
158 *base_v = overlay_v;
159 }
160 }
161}
162
163fn migrate_legacy_worktree_keys(config: &mut Value) {
172 let Value::Object(root) = config else {
173 return;
174 };
175
176 let Some(Value::Object(wt)) = root.get_mut("worktrees") else {
177 return;
178 };
179
180 if let Some(legacy_val) = wt.remove("defaultBranch") {
182 eprintln!(
183 "Warning: Config key 'worktrees.defaultBranch' is deprecated. \
184 Use 'worktrees.default_branch' instead."
185 );
186 if !wt.contains_key("default_branch") {
187 wt.insert("default_branch".to_string(), legacy_val);
188 }
189 }
190
191 if let Some(legacy_val) = wt.remove("localFiles") {
193 eprintln!(
194 "Warning: Config key 'worktrees.localFiles' is deprecated. \
195 Use 'worktrees.apply.copy_from_main' instead."
196 );
197 let apply = wt
198 .entry("apply")
199 .or_insert_with(|| Value::Object(serde_json::Map::new()));
200 if let Value::Object(apply_map) = apply
201 && !apply_map.contains_key("copy_from_main")
202 {
203 apply_map.insert("copy_from_main".to_string(), legacy_val);
204 }
205 }
206}
207
208fn project_path_from_json(v: &Value) -> Option<String> {
209 let Value::Object(map) = v else {
210 return None;
211 };
212 let Some(Value::String(s)) = map.get("projectPath") else {
213 return None;
214 };
215 if s.trim().is_empty() {
216 return None;
217 }
218 Some(s.clone())
219}
220
221pub fn load_repo_project_path_override(project_root: &Path) -> Option<String> {
227 load_repo_project_path_override_fs(&StdFs, project_root)
228}
229
230pub fn load_repo_project_path_override_fs<F: FileSystem>(
232 fs: &F,
233 project_root: &Path,
234) -> Option<String> {
235 let mut out = None;
236
237 let repo = project_root.join(REPO_CONFIG_FILE_NAME);
238 if let Some(v) = load_json_object_fs(fs, &repo)
239 && let Some(p) = project_path_from_json(&v)
240 {
241 out = Some(p);
242 }
243
244 let repo = project_root.join(REPO_DOT_CONFIG_FILE_NAME);
245 if let Some(v) = load_json_object_fs(fs, &repo)
246 && let Some(p) = project_path_from_json(&v)
247 {
248 out = Some(p);
249 }
250
251 out
252}
253
254#[derive(Debug, Clone)]
255pub struct CascadingProjectConfig {
257 pub merged: Value,
259 pub loaded_from: Vec<PathBuf>,
261}
262
263pub type ResolvedConfig = CascadingProjectConfig;
265
266pub fn project_config_paths(
268 project_root: &Path,
269 ito_path: &Path,
270 ctx: &ConfigContext,
271) -> Vec<PathBuf> {
272 let mut out: Vec<PathBuf> = vec![
273 project_root.join(REPO_CONFIG_FILE_NAME),
274 project_root.join(REPO_DOT_CONFIG_FILE_NAME),
275 ito_path.join(ITO_DIR_CONFIG_FILE_NAME),
276 ito_path.join(ITO_DIR_LOCAL_CONFIG_FILE_NAME),
277 project_root.join(PROJECT_LOCAL_CONFIG_PATH),
278 ];
279 if let Some(p) = &ctx.project_dir {
280 out.push(p.join(ITO_DIR_CONFIG_FILE_NAME));
281 }
282
283 out
284}
285
286pub fn load_cascading_project_config(
296 project_root: &Path,
297 ito_path: &Path,
298 ctx: &ConfigContext,
299) -> CascadingProjectConfig {
300 load_cascading_project_config_fs(&StdFs, project_root, ito_path, ctx)
301}
302
303pub fn resolve_coordination_branch_settings(merged: &Value) -> (bool, String) {
308 let Ok(cfg) = serde_json::from_value::<types::ItoConfig>(merged.clone()) else {
309 let defaults = types::CoordinationBranchConfig::default();
310 return (defaults.enabled.0, defaults.name);
311 };
312
313 (
314 cfg.changes.coordination_branch.enabled.0,
315 cfg.changes.coordination_branch.name,
316 )
317}
318
319pub fn load_cascading_project_config_fs<F: FileSystem>(
321 fs: &F,
322 project_root: &Path,
323 ito_path: &Path,
324 ctx: &ConfigContext,
325) -> CascadingProjectConfig {
326 let mut merged = defaults::default_config_json();
327 let mut loaded_from: Vec<PathBuf> = Vec::new();
328
329 let paths = project_config_paths(project_root, ito_path, ctx);
330 for path in paths {
331 let Some(mut v) = load_json_object_fs(fs, &path) else {
332 continue;
333 };
334 migrate_legacy_worktree_keys(&mut v);
338 merge_json(&mut merged, v);
339 loaded_from.push(path);
340 }
341
342 CascadingProjectConfig {
343 merged,
344 loaded_from,
345 }
346}
347
348pub fn global_config_path(ctx: &ConfigContext) -> Option<PathBuf> {
350 ito_config_dir(ctx).map(|d| d.join("config.json"))
351}
352
353pub fn ito_config_dir(ctx: &ConfigContext) -> Option<PathBuf> {
355 #[cfg(windows)]
356 {
357 let appdata = std::env::var_os("APPDATA").map(PathBuf::from);
359 let base = appdata
360 .or_else(|| ctx.xdg_config_home.clone())
361 .or_else(|| ctx.home_dir.clone());
362 return base.map(|b| b.join("ito"));
363 }
364
365 #[cfg(not(windows))]
366 {
367 let base = if let Some(xdg) = &ctx.xdg_config_home {
368 xdg.clone()
369 } else if let Some(home) = &ctx.home_dir {
370 home.join(".config")
371 } else {
372 return None;
373 };
374
375 Some(base.join("ito"))
376 }
377}
378
379pub fn load_global_config(ctx: &ConfigContext) -> GlobalConfig {
381 load_global_config_fs(&StdFs, ctx)
382}
383
384pub fn load_global_config_fs<F: FileSystem>(fs: &F, ctx: &ConfigContext) -> GlobalConfig {
386 let Some(path) = global_config_path(ctx) else {
387 return GlobalConfig::default();
388 };
389
390 let Some(contents) = read_to_string_optional_fs(fs, &path) else {
391 return GlobalConfig::default();
392 };
393
394 match serde_json::from_str(&contents) {
395 Ok(v) => v,
396 Err(_) => {
397 eprintln!(
398 "Warning: Invalid JSON in {}, using defaults",
399 path.display()
400 );
401 GlobalConfig::default()
402 }
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn cascading_project_config_merges_sources_in_order_with_scalar_override() {
412 let repo = tempfile::tempdir().unwrap();
413
414 std::fs::write(
415 repo.path().join("ito.json"),
416 "{\"obj\":{\"a\":1},\"arr\":[1],\"x\":\"repo\"}",
417 )
418 .unwrap();
419 std::fs::write(
420 repo.path().join(".ito.json"),
421 "{\"obj\":{\"b\":2},\"arr\":[2],\"y\":\"dot\"}",
422 )
423 .unwrap();
424
425 let project_dir = tempfile::tempdir().unwrap();
426 std::fs::write(
427 project_dir.path().join("config.json"),
428 "{\"obj\":{\"c\":3},\"x\":\"project_dir\"}",
429 )
430 .unwrap();
431
432 let ctx = ConfigContext {
433 xdg_config_home: None,
434 home_dir: None,
435 project_dir: Some(project_dir.path().to_path_buf()),
436 };
437 let ito_path = crate::ito_dir::get_ito_path(repo.path(), &ctx);
438 std::fs::create_dir_all(&ito_path).unwrap();
439 std::fs::write(
440 ito_path.join("config.json"),
441 "{\"obj\":{\"a\":9},\"z\":\"ito_dir\"}",
442 )
443 .unwrap();
444
445 let r = load_cascading_project_config(repo.path(), &ito_path, &ctx);
446
447 assert_eq!(
448 r.merged.get("obj").unwrap(),
449 &serde_json::json!({"a": 9, "b": 2, "c": 3})
450 );
451 assert_eq!(r.merged.get("arr").unwrap(), &serde_json::json!([2]));
452 assert_eq!(
453 r.merged.get("x").unwrap(),
454 &serde_json::json!("project_dir")
455 );
456 assert_eq!(r.merged.get("y").unwrap(), &serde_json::json!("dot"));
457 assert_eq!(r.merged.get("z").unwrap(), &serde_json::json!("ito_dir"));
458
459 assert!(r.merged.get("cache").is_some());
461 assert!(r.merged.get("harnesses").is_some());
462
463 assert_eq!(
464 r.loaded_from,
465 vec![
466 repo.path().join("ito.json"),
467 repo.path().join(".ito.json"),
468 ito_path.join("config.json"),
469 project_dir.path().join("config.json"),
470 ]
471 );
472 }
473
474 #[test]
475 fn cascading_project_config_ignores_invalid_json_sources() {
476 let repo = tempfile::tempdir().unwrap();
477
478 std::fs::write(repo.path().join("ito.json"), "{\"a\":1}").unwrap();
479 std::fs::write(repo.path().join(".ito.json"), "not-json").unwrap();
480
481 let ctx = ConfigContext::default();
482 let ito_path = crate::ito_dir::get_ito_path(repo.path(), &ctx);
483
484 let r = load_cascading_project_config(repo.path(), &ito_path, &ctx);
485 assert_eq!(r.merged.get("a").unwrap(), &serde_json::json!(1));
486 assert!(r.merged.get("cache").is_some());
487
488 assert_eq!(r.loaded_from, vec![repo.path().join("ito.json")]);
489 }
490
491 #[test]
492 fn cascading_project_config_ignores_schema_ref_key() {
493 let repo = tempfile::tempdir().unwrap();
494 std::fs::write(
495 repo.path().join("ito.json"),
496 "{\"$schema\":\"./config.schema.json\",\"a\":1}",
497 )
498 .unwrap();
499
500 let ctx = ConfigContext::default();
501 let ito_path = crate::ito_dir::get_ito_path(repo.path(), &ctx);
502
503 let r = load_cascading_project_config(repo.path(), &ito_path, &ctx);
504 assert_eq!(r.merged.get("a").unwrap(), &serde_json::json!(1));
505 assert!(r.merged.get("$schema").is_none());
506 }
507
508 #[test]
509 fn global_config_path_prefers_xdg() {
510 let ctx = ConfigContext {
511 xdg_config_home: Some(PathBuf::from("/tmp/xdg")),
512 home_dir: Some(PathBuf::from("/tmp/home")),
513 project_dir: None,
514 };
515 #[cfg(not(windows))]
516 assert_eq!(
517 global_config_path(&ctx).unwrap(),
518 PathBuf::from("/tmp/xdg/ito/config.json")
519 );
520 }
521
522 #[test]
523 fn ito_config_dir_prefers_xdg() {
524 let ctx = ConfigContext {
525 xdg_config_home: Some(PathBuf::from("/tmp/xdg")),
526 home_dir: Some(PathBuf::from("/tmp/home")),
527 project_dir: None,
528 };
529 #[cfg(not(windows))]
530 assert_eq!(ito_config_dir(&ctx).unwrap(), PathBuf::from("/tmp/xdg/ito"));
531 }
532
533 #[test]
534 fn worktrees_config_has_defaults_in_cascading_config() {
535 let repo = tempfile::tempdir().unwrap();
536 let ctx = ConfigContext::default();
537 let ito_path = crate::ito_dir::get_ito_path(repo.path(), &ctx);
538
539 let r = load_cascading_project_config(repo.path(), &ito_path, &ctx);
540 let wt = r
541 .merged
542 .get("worktrees")
543 .expect("worktrees key should exist");
544
545 assert_eq!(wt.get("enabled").and_then(|v| v.as_bool()), Some(false));
546 assert_eq!(
547 wt.get("strategy").and_then(|v| v.as_str()),
548 Some("checkout_subdir")
549 );
550 assert_eq!(
551 wt.get("default_branch").and_then(|v| v.as_str()),
552 Some("main")
553 );
554
555 let layout = wt.get("layout").unwrap();
556 assert_eq!(
557 layout.get("dir_name").and_then(|v| v.as_str()),
558 Some("ito-worktrees")
559 );
560
561 let apply = wt.get("apply").unwrap();
562 assert_eq!(apply.get("enabled").and_then(|v| v.as_bool()), Some(true));
563 assert_eq!(
564 apply.get("integration_mode").and_then(|v| v.as_str()),
565 Some("commit_pr")
566 );
567
568 let copy = apply
569 .get("copy_from_main")
570 .and_then(|v| v.as_array())
571 .unwrap();
572 assert_eq!(copy.len(), 3);
573 }
574
575 #[test]
576 fn legacy_worktree_default_branch_key_migrates() {
577 let repo = tempfile::tempdir().unwrap();
578 std::fs::write(
579 repo.path().join("ito.json"),
580 r#"{"worktrees":{"defaultBranch":"develop"}}"#,
581 )
582 .unwrap();
583
584 let ctx = ConfigContext::default();
585 let ito_path = crate::ito_dir::get_ito_path(repo.path(), &ctx);
586
587 let r = load_cascading_project_config(repo.path(), &ito_path, &ctx);
588 let wt = r.merged.get("worktrees").unwrap();
589
590 assert_eq!(
591 wt.get("default_branch").and_then(|v| v.as_str()),
592 Some("develop")
593 );
594 assert!(wt.get("defaultBranch").is_none());
595 }
596
597 #[test]
598 fn legacy_worktree_local_files_key_migrates() {
599 let repo = tempfile::tempdir().unwrap();
600 std::fs::write(
601 repo.path().join("ito.json"),
602 r#"{"worktrees":{"localFiles":[".env",".secrets"]}}"#,
603 )
604 .unwrap();
605
606 let ctx = ConfigContext::default();
607 let ito_path = crate::ito_dir::get_ito_path(repo.path(), &ctx);
608
609 let r = load_cascading_project_config(repo.path(), &ito_path, &ctx);
610 let wt = r.merged.get("worktrees").unwrap();
611 let apply = wt.get("apply").unwrap();
612 let copy = apply
613 .get("copy_from_main")
614 .and_then(|v| v.as_array())
615 .unwrap();
616
617 assert_eq!(copy.len(), 2);
618 assert_eq!(copy[0].as_str(), Some(".env"));
619 assert_eq!(copy[1].as_str(), Some(".secrets"));
620 assert!(wt.get("localFiles").is_none());
621 }
622
623 #[test]
624 fn new_worktree_keys_take_precedence_over_legacy() {
625 let repo = tempfile::tempdir().unwrap();
626 std::fs::write(
627 repo.path().join("ito.json"),
628 r#"{"worktrees":{"defaultBranch":"legacy","default_branch":"new-main","localFiles":[".old"],"apply":{"copy_from_main":[".new"]}}}"#,
629 )
630 .unwrap();
631
632 let ctx = ConfigContext::default();
633 let ito_path = crate::ito_dir::get_ito_path(repo.path(), &ctx);
634
635 let r = load_cascading_project_config(repo.path(), &ito_path, &ctx);
636 let wt = r.merged.get("worktrees").unwrap();
637
638 assert_eq!(
639 wt.get("default_branch").and_then(|v| v.as_str()),
640 Some("new-main")
641 );
642
643 let apply = wt.get("apply").unwrap();
644 let copy = apply
645 .get("copy_from_main")
646 .and_then(|v| v.as_array())
647 .unwrap();
648 assert_eq!(copy.len(), 1);
649 assert_eq!(copy[0].as_str(), Some(".new"));
650 }
651
652 #[test]
653 fn coordination_branch_defaults_exist_in_cascading_config() {
654 let repo = tempfile::tempdir().unwrap();
655 let ctx = ConfigContext::default();
656 let ito_path = crate::ito_dir::get_ito_path(repo.path(), &ctx);
657
658 let r = load_cascading_project_config(repo.path(), &ito_path, &ctx);
659 let changes = r.merged.get("changes").expect("changes key should exist");
660 let coordination = changes
661 .get("coordination_branch")
662 .expect("coordination_branch key should exist");
663
664 assert_eq!(
665 coordination.get("enabled").and_then(|v| v.as_bool()),
666 Some(true)
667 );
668 assert_eq!(
669 coordination.get("name").and_then(|v| v.as_str()),
670 Some("ito/internal/changes")
671 );
672 }
673
674 #[test]
675 fn coordination_branch_defaults_can_be_overridden() {
676 let repo = tempfile::tempdir().unwrap();
677 std::fs::write(
678 repo.path().join("ito.json"),
679 r#"{"changes":{"coordination_branch":{"enabled":false,"name":"team/internal/coord"}}}"#,
680 )
681 .unwrap();
682
683 let ctx = ConfigContext::default();
684 let ito_path = crate::ito_dir::get_ito_path(repo.path(), &ctx);
685
686 let r = load_cascading_project_config(repo.path(), &ito_path, &ctx);
687 let changes = r.merged.get("changes").expect("changes key should exist");
688 let coordination = changes
689 .get("coordination_branch")
690 .expect("coordination_branch key should exist");
691
692 assert_eq!(
693 coordination.get("enabled").and_then(|v| v.as_bool()),
694 Some(false)
695 );
696 assert_eq!(
697 coordination.get("name").and_then(|v| v.as_str()),
698 Some("team/internal/coord")
699 );
700 }
701
702 }