ito_config/config/
mod.rs

1//! Configuration loading.
2//!
3//! Ito supports configuration at multiple layers:
4//!
5//! - Repo-local: `ito.json` and `.ito.json`
6//! - Project/Ito dir: `<itoDir>/config.json` (and optionally `$PROJECT_DIR/config.json`)
7//! - Global: `~/.config/ito/config.json` (or `$XDG_CONFIG_HOME/ito/config.json`)
8//!
9//! This module loads these sources, merges them with defaults, and records the
10//! paths that contributed to the final configuration.
11
12use std::io::ErrorKind;
13use std::path::{Path, PathBuf};
14
15use ito_common::fs::{FileSystem, StdFs};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19/// Default config values and JSON serialization helpers.
20pub mod defaults;
21
22/// JSON schema generation for Ito configuration.
23pub mod schema;
24
25/// Serde models for `config.json`.
26pub 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)]
35/// Global (user-level) configuration.
36pub struct GlobalConfig {
37    /// Preferred Ito working directory name.
38    #[serde(rename = "projectPath")]
39    pub project_path: Option<String>,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43/// Repo-local configuration.
44pub struct ProjectConfig {
45    /// Repo-local Ito working directory name override.
46    #[serde(rename = "projectPath")]
47    pub project_path: Option<String>,
48}
49
50#[derive(Debug, Clone, Default)]
51/// Process environment inputs for configuration resolution.
52pub struct ConfigContext {
53    /// Optional XDG config home path override.
54    pub xdg_config_home: Option<PathBuf>,
55    /// Home directory, used for non-XDG config lookup.
56    pub home_dir: Option<PathBuf>,
57    /// Optional project directory override (used by some harnesses).
58    pub project_dir: Option<PathBuf>,
59}
60
61impl ConfigContext {
62    /// Build a context from environment variables.
63    pub fn from_process_env() -> Self {
64        let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from);
65
66        // Use HOME consistently across platforms for tests.
67        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
96/// Load `ito.json` from `project_root`.
97pub fn load_project_config(project_root: &Path) -> Option<ProjectConfig> {
98    load_project_config_fs(&StdFs, project_root)
99}
100
101/// Like [`load_project_config`], but uses an injected file-system.
102pub 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            // Ignore JSON schema references in config.
132            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
163/// Migrate legacy camelCase worktree keys to their new snake_case equivalents.
164///
165/// Legacy key mappings:
166/// - `worktrees.defaultBranch` → `worktrees.default_branch`
167/// - `worktrees.localFiles` → `worktrees.apply.copy_from_main`
168///
169/// New keys take precedence if both old and new are present.
170/// Emits deprecation warnings to stderr when legacy keys are found.
171fn 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    // worktrees.defaultBranch → worktrees.default_branch
181    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    // worktrees.localFiles → worktrees.apply.copy_from_main
192    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
221/// Returns a repo-local `projectPath` override (Ito working directory name).
222///
223/// Precedence (low -> high): `ito.json`, then `.ito.json`.
224///
225/// NOTE: This does *not* consult `<itoDir>/config.json` to avoid cycles.
226pub fn load_repo_project_path_override(project_root: &Path) -> Option<String> {
227    load_repo_project_path_override_fs(&StdFs, project_root)
228}
229
230/// Like [`load_repo_project_path_override`], but uses an injected file-system.
231pub 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)]
255/// Merged project configuration along with provenance.
256pub struct CascadingProjectConfig {
257    /// Fully merged config JSON.
258    pub merged: Value,
259    /// Paths that were successfully loaded and merged.
260    pub loaded_from: Vec<PathBuf>,
261}
262
263/// Alias used by consumers who only care about the resolved config output.
264pub type ResolvedConfig = CascadingProjectConfig;
265
266/// Return the ordered list of configuration file paths consulted for a project.
267pub 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
286/// Load and merge project configuration sources in precedence order.
287///
288/// Precedence (low -> high):
289/// 1) `<repo-root>/ito.json`
290/// 2) `<repo-root>/.ito.json`
291/// 3) `<itoDir>/config.json` (team/project defaults, typically committed)
292/// 4) `<itoDir>/config.local.json` (per-developer overrides, gitignored)
293/// 5) `<repo-root>/.local/ito/config.json` (optional per-developer overrides, gitignored)
294/// 6) `$PROJECT_DIR/config.json` (when set)
295pub 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
303/// Resolve coordination branch settings from merged config JSON.
304///
305/// Falls back to documented defaults when the merged value cannot be
306/// deserialized into [`types::ItoConfig`].
307pub 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
319/// Like [`load_cascading_project_config`], but uses an injected file-system.
320pub 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 camelCase worktree keys before merging so that
335        // the new key names participate in the normal merge process and
336        // override defaults correctly.
337        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
348/// Return the global configuration file path, if it can be determined.
349pub fn global_config_path(ctx: &ConfigContext) -> Option<PathBuf> {
350    ito_config_dir(ctx).map(|d| d.join("config.json"))
351}
352
353/// Return the global configuration directory (`~/.config/ito` or XDG equivalent).
354pub fn ito_config_dir(ctx: &ConfigContext) -> Option<PathBuf> {
355    #[cfg(windows)]
356    {
357        // TS uses APPDATA on Windows. We accept HOME/USERPROFILE for tests but prefer APPDATA.
358        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
379/// Load the global config file.
380pub fn load_global_config(ctx: &ConfigContext) -> GlobalConfig {
381    load_global_config_fs(&StdFs, ctx)
382}
383
384/// Like [`load_global_config`], but uses an injected file-system.
385pub 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        // Defaults are present.
460        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    // ito_dir tests live in crate::ito_dir.
703}