Skip to main content

joy_core/
store.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4use std::path::{Path, PathBuf};
5
6use serde::de::DeserializeOwned;
7use serde::Serialize;
8
9use crate::error::JoyError;
10
11pub const JOY_DIR: &str = ".joy";
12pub const CONFIG_FILE: &str = "config.yaml";
13pub const CONFIG_DEFAULTS_FILE: &str = "config.defaults.yaml";
14pub const PROJECT_FILE: &str = "project.yaml";
15pub const PROJECT_DEFAULTS_FILE: &str = "project.defaults.yaml";
16pub const CREDENTIALS_FILE: &str = "credentials.yaml";
17pub const ITEMS_DIR: &str = "items";
18pub const MILESTONES_DIR: &str = "milestones";
19pub const AI_DIR: &str = "ai";
20pub const AI_AGENTS_DIR: &str = "ai/agents";
21pub const AI_JOBS_DIR: &str = "ai/jobs";
22pub const LOG_DIR: &str = "logs";
23pub const RELEASES_DIR: &str = "releases";
24
25pub fn joy_dir(root: &Path) -> PathBuf {
26    root.join(JOY_DIR)
27}
28
29pub fn is_initialized(root: &Path) -> bool {
30    let dir = joy_dir(root);
31    let has_config = dir.join(CONFIG_FILE).is_file() || dir.join(CONFIG_DEFAULTS_FILE).is_file();
32    has_config && dir.join(PROJECT_FILE).is_file()
33}
34
35/// Walk up from `start` looking for a `.joy/` directory.
36pub fn find_project_root(start: &Path) -> Option<PathBuf> {
37    let mut current = start.to_path_buf();
38    loop {
39        if is_initialized(&current) {
40            return Some(current);
41        }
42        if !current.pop() {
43            return None;
44        }
45    }
46}
47
48pub fn write_yaml<T: Serialize>(path: &Path, value: &T) -> Result<(), JoyError> {
49    let yaml = serde_yaml_ng::to_string(value)?;
50    std::fs::write(path, yaml).map_err(|e| JoyError::WriteFile {
51        path: path.to_path_buf(),
52        source: e,
53    })
54}
55
56/// Write YAML while preserving top-level fields not in the struct.
57/// Reads the existing file, takes all modeled fields from the struct,
58/// and preserves any top-level keys that the struct doesn't know about.
59pub fn write_yaml_preserve<T: Serialize>(path: &Path, value: &T) -> Result<(), JoyError> {
60    use serde_yaml_ng::Value;
61
62    let new_value: Value = serde_yaml_ng::to_value(value)?;
63
64    let merged = if path.is_file() {
65        let existing_str = std::fs::read_to_string(path).map_err(|e| JoyError::ReadFile {
66            path: path.to_path_buf(),
67            source: e,
68        })?;
69        if let Ok(existing) = serde_yaml_ng::from_str::<Value>(&existing_str) {
70            if let (Value::Mapping(existing_map), Value::Mapping(new_map)) = (existing, &new_value)
71            {
72                // Start with the struct's values (authoritative for modeled fields)
73                let mut result = new_map.clone();
74                // Add back any top-level keys from the original that the struct doesn't have
75                for (key, val) in existing_map {
76                    if !result.contains_key(&key) {
77                        result.insert(key, val);
78                    }
79                }
80                Value::Mapping(result)
81            } else {
82                new_value
83            }
84        } else {
85            new_value
86        }
87    } else {
88        new_value
89    };
90
91    let yaml = serde_yaml_ng::to_string(&merged)?;
92    std::fs::write(path, yaml).map_err(|e| JoyError::WriteFile {
93        path: path.to_path_buf(),
94        source: e,
95    })
96}
97
98/// Returns the path to the personal global config: ~/.config/joy/config.yaml
99/// (Windows: %APPDATA%\joy\config.yaml).
100pub fn global_config_path() -> PathBuf {
101    config_base_dir()
102        .unwrap_or_else(|| PathBuf::from("."))
103        .join("joy")
104        .join("config.yaml")
105}
106
107/// Personal config base dir: `$XDG_CONFIG_HOME`, else on Windows `%APPDATA%`
108/// (fallback `%USERPROFILE%\AppData\Roaming`), else on Unix `$HOME/.config`.
109fn config_base_dir() -> Option<PathBuf> {
110    resolve_base_dir(
111        std::env::var("XDG_CONFIG_HOME").ok(),
112        std::env::var("APPDATA").ok(),
113        std::env::var("HOME").ok(),
114        std::env::var("USERPROFILE").ok(),
115        cfg!(windows),
116        "Roaming",
117        ".config",
118    )
119}
120
121/// Returns the path to the personal per-project config: .joy/config.yaml
122pub fn local_config_path(root: &Path) -> PathBuf {
123    joy_dir(root).join(CONFIG_FILE)
124}
125
126/// Returns the path to the committed project defaults: .joy/config.defaults.yaml
127pub fn defaults_config_path(root: &Path) -> PathBuf {
128    joy_dir(root).join(CONFIG_DEFAULTS_FILE)
129}
130
131pub fn project_defaults_path(root: &Path) -> PathBuf {
132    joy_dir(root).join(PROJECT_DEFAULTS_FILE)
133}
134
135/// Cross-platform base-directory resolver. Pure (no env access) so the
136/// Windows branches are unit-testable on any host OS. See JOY-01A2-96.
137///
138/// `xdg` (the relevant `XDG_*_HOME` value) wins on every platform when set.
139/// On Windows the base is `win_app_data` (`%LOCALAPPDATA%` for state,
140/// `%APPDATA%` for config), falling back to
141/// `<USERPROFILE|HOME>\AppData\<win_fallback>`. On Unix the base is
142/// `<HOME>/<unix_subdir>`. Empty env values are ignored. Returns `None`
143/// only when nothing resolves.
144pub(crate) fn resolve_base_dir(
145    xdg: Option<String>,
146    win_app_data: Option<String>,
147    home: Option<String>,
148    user_profile: Option<String>,
149    is_windows: bool,
150    win_fallback: &str,
151    unix_subdir: &str,
152) -> Option<PathBuf> {
153    let nonempty = |v: Option<String>| v.filter(|s| !s.is_empty());
154
155    if let Some(xdg) = nonempty(xdg) {
156        return Some(PathBuf::from(xdg));
157    }
158
159    if is_windows {
160        if let Some(app_data) = nonempty(win_app_data) {
161            return Some(PathBuf::from(app_data));
162        }
163        let base = nonempty(user_profile).or_else(|| nonempty(home))?;
164        return Some(PathBuf::from(base).join("AppData").join(win_fallback));
165    }
166
167    Some(PathBuf::from(nonempty(home)?).join(unix_subdir))
168}
169
170#[cfg(test)]
171mod platform_dir_tests {
172    use super::*;
173
174    fn s(v: &str) -> Option<String> {
175        Some(v.to_string())
176    }
177
178    #[test]
179    fn xdg_wins_on_all_platforms() {
180        for win in [false, true] {
181            let d = resolve_base_dir(
182                s("/xdg"),
183                s("C:\\AppData"),
184                s("/home/u"),
185                None,
186                win,
187                "Local",
188                ".local/state",
189            );
190            assert_eq!(d, Some(PathBuf::from("/xdg")));
191        }
192    }
193
194    #[test]
195    fn unix_uses_home_subdir() {
196        let state = resolve_base_dir(
197            None,
198            None,
199            s("/home/u"),
200            None,
201            false,
202            "Local",
203            ".local/state",
204        );
205        assert_eq!(state, Some(PathBuf::from("/home/u/.local/state")));
206        let cfg = resolve_base_dir(None, None, s("/home/u"), None, false, "Roaming", ".config");
207        assert_eq!(cfg, Some(PathBuf::from("/home/u/.config")));
208    }
209
210    #[test]
211    fn windows_uses_app_data() {
212        let state = resolve_base_dir(
213            None,
214            s("C:\\Users\\u\\AppData\\Local"),
215            None,
216            s("C:\\Users\\u"),
217            true,
218            "Local",
219            ".local/state",
220        );
221        assert_eq!(state, Some(PathBuf::from("C:\\Users\\u\\AppData\\Local")));
222        let cfg = resolve_base_dir(
223            None,
224            s("C:\\Users\\u\\AppData\\Roaming"),
225            None,
226            s("C:\\Users\\u"),
227            true,
228            "Roaming",
229            ".config",
230        );
231        assert_eq!(cfg, Some(PathBuf::from("C:\\Users\\u\\AppData\\Roaming")));
232    }
233
234    #[test]
235    fn windows_falls_back_to_user_profile_then_home() {
236        let via_profile = resolve_base_dir(
237            None,
238            None,
239            None,
240            s("C:\\Users\\u"),
241            true,
242            "Local",
243            ".local/state",
244        );
245        assert_eq!(
246            via_profile,
247            Some(PathBuf::from("C:\\Users\\u").join("AppData").join("Local"))
248        );
249        let via_home = resolve_base_dir(
250            None,
251            None,
252            s("C:\\Users\\u"),
253            None,
254            true,
255            "Roaming",
256            ".config",
257        );
258        assert_eq!(
259            via_home,
260            Some(
261                PathBuf::from("C:\\Users\\u")
262                    .join("AppData")
263                    .join("Roaming")
264            )
265        );
266    }
267
268    #[test]
269    fn empty_values_ignored_and_none_when_unresolvable() {
270        assert_eq!(
271            resolve_base_dir(s(""), s(""), s(""), s(""), false, "Local", ".local/state"),
272            None
273        );
274        assert_eq!(
275            resolve_base_dir(s(""), s(""), s(""), s(""), true, "Local", ".local/state"),
276            None
277        );
278        assert_eq!(
279            resolve_base_dir(None, None, None, None, true, "Roaming", ".config"),
280            None
281        );
282    }
283}
284
285/// Recursively merge `overlay` into `base`. Object keys are merged; all other
286/// types are replaced.
287/// Recursively merge `overlay` into `base` (public for use by config validation).
288pub fn deep_merge_value(base: &mut serde_json::Value, overlay: &serde_json::Value) {
289    deep_merge(base, overlay);
290}
291
292fn deep_merge(base: &mut serde_json::Value, overlay: &serde_json::Value) {
293    if let (Some(base_map), Some(overlay_map)) = (base.as_object_mut(), overlay.as_object()) {
294        for (key, value) in overlay_map {
295            if let Some(existing) = base_map.get_mut(key) {
296                deep_merge(existing, value);
297            } else {
298                base_map.insert(key.clone(), value.clone());
299            }
300        }
301    } else {
302        *base = overlay.clone();
303    }
304}
305
306/// Read a YAML file as a serde_json::Value, returning None if the file does not exist.
307fn read_yaml_value(path: &Path) -> Option<serde_json::Value> {
308    let content = std::fs::read_to_string(path).ok()?;
309    let value: serde_json::Value = serde_yaml_ng::from_str(&content).ok()?;
310    // Empty YAML files deserialize as null -- treat them as absent
311    if value.is_null() {
312        return None;
313    }
314    Some(value)
315}
316
317/// Load project config by merging four layers (code defaults < project defaults
318/// < global personal < local personal).
319pub fn load_config() -> crate::model::Config {
320    let cwd = match std::env::current_dir() {
321        Ok(p) => p,
322        Err(_) => return crate::model::Config::default(),
323    };
324    let root = match find_project_root(&cwd) {
325        Some(r) => r,
326        None => return crate::model::Config::default(),
327    };
328
329    // Layer 4: code defaults
330    let mut merged: serde_json::Value =
331        serde_json::to_value(crate::model::Config::default()).unwrap_or_default();
332
333    // Layer 3: project defaults (.joy/config.defaults.yaml)
334    if let Some(defaults) = read_yaml_value(&defaults_config_path(&root)) {
335        deep_merge(&mut merged, &defaults);
336    }
337
338    // Layer 2: global personal (~/.config/joy/config.yaml)
339    if let Some(global) = read_yaml_value(&global_config_path()) {
340        deep_merge(&mut merged, &global);
341    }
342
343    // Layer 1: local personal (.joy/config.yaml)
344    if let Some(local) = read_yaml_value(&local_config_path(&root)) {
345        deep_merge(&mut merged, &local);
346    }
347
348    match serde_json::from_value(merged) {
349        Ok(config) => config,
350        Err(e) => {
351            eprintln!("Warning: config has invalid values, using defaults: {e}");
352            crate::model::Config::default()
353        }
354    }
355}
356
357/// Load only the user-set config values (personal local + global, no defaults).
358/// Returns an empty object if no personal config exists.
359pub fn load_personal_config_value() -> serde_json::Value {
360    let cwd = match std::env::current_dir() {
361        Ok(p) => p,
362        Err(_) => return serde_json::json!({}),
363    };
364    let root = match find_project_root(&cwd) {
365        Some(r) => r,
366        None => return serde_json::json!({}),
367    };
368
369    let mut merged = serde_json::json!({});
370
371    if let Some(global) = read_yaml_value(&global_config_path()) {
372        deep_merge(&mut merged, &global);
373    }
374    if let Some(local) = read_yaml_value(&local_config_path(&root)) {
375        deep_merge(&mut merged, &local);
376    }
377
378    merged
379}
380
381/// Load the merged config as a serde_json::Value (preserves arbitrary keys).
382pub fn load_config_value() -> serde_json::Value {
383    let cwd = match std::env::current_dir() {
384        Ok(p) => p,
385        Err(_) => return serde_json::to_value(crate::model::Config::default()).unwrap_or_default(),
386    };
387    let root = match find_project_root(&cwd) {
388        Some(r) => r,
389        None => return serde_json::to_value(crate::model::Config::default()).unwrap_or_default(),
390    };
391
392    let mut merged: serde_json::Value =
393        serde_json::to_value(crate::model::Config::default()).unwrap_or_default();
394
395    if let Some(defaults) = read_yaml_value(&defaults_config_path(&root)) {
396        deep_merge(&mut merged, &defaults);
397    }
398    if let Some(global) = read_yaml_value(&global_config_path()) {
399        deep_merge(&mut merged, &global);
400    }
401    if let Some(local) = read_yaml_value(&local_config_path(&root)) {
402        deep_merge(&mut merged, &local);
403    }
404
405    merged
406}
407
408pub fn read_yaml<T: DeserializeOwned>(path: &Path) -> Result<T, JoyError> {
409    let bytes = std::fs::read(path).map_err(|e| JoyError::ReadFile {
410        path: path.to_path_buf(),
411        source: e,
412    })?;
413    // ADR-040: item / file content can be a JOYCRYPT blob on disk.
414    // The active session's zone keys live in a thread-local context
415    // populated by joy-cli after passphrase verification. If the
416    // blob's zone is not in that context, surface ZoneAccessDenied
417    // rather than a YAML parse error.
418    let plaintext = if crate::crypt::looks_like_blob(&bytes) {
419        let (_zone, plain) = crate::crypt::decrypt_blob(crate::crypt::active_zone_key, &bytes)?;
420        plain
421    } else {
422        bytes
423    };
424    serde_yaml_ng::from_slice(&plaintext).map_err(|e| JoyError::YamlParse {
425        path: path.to_path_buf(),
426        source: e,
427    })
428}
429
430/// Read project.yaml from an explicit path, applying schema migrations.
431///
432/// All Project deserialisation paths must funnel through this function
433/// (or [`load_project`]) so legacy auth-field renames are picked up
434/// uniformly. When a migration changes the parsed value, a one-line
435/// deprecation warning is emitted to stderr pointing at `joy update`.
436/// The on-disk file is not rewritten - persistence is explicit (per
437/// ADR-035). The warning is emitted at most once per process so a
438/// single command run does not flood stderr even when multiple code
439/// paths load project.yaml.
440pub fn read_project(
441    project_path: &Path,
442) -> Result<crate::model::project::Project, crate::error::JoyError> {
443    let content = std::fs::read_to_string(project_path).map_err(|e| JoyError::ReadFile {
444        path: project_path.to_path_buf(),
445        source: e,
446    })?;
447    let value: serde_yaml_ng::Value =
448        serde_yaml_ng::from_str(&content).map_err(|e| JoyError::YamlParse {
449            path: project_path.to_path_buf(),
450            source: e,
451        })?;
452    let (value, migrated) = crate::migrations::project_yaml::apply(value);
453    if migrated {
454        warn_legacy_schema_once();
455    }
456    serde_yaml_ng::from_value(value).map_err(|e| JoyError::YamlParse {
457        path: project_path.to_path_buf(),
458        source: e,
459    })
460}
461
462fn warn_legacy_schema_once() {
463    use std::sync::Once;
464    static WARN: Once = Once::new();
465    WARN.call_once(|| {
466        eprintln!(
467            "warning: project.yaml uses legacy auth field names from before v0.12; \
468             run `joy update` to normalise. Legacy support will be removed in v0.13."
469        );
470    });
471}
472
473/// Load the full project metadata from project.yaml under the given
474/// project root. Applies migrations via [`read_project`].
475pub fn load_project(root: &Path) -> Result<crate::model::project::Project, crate::error::JoyError> {
476    let project_path = joy_dir(root).join(PROJECT_FILE);
477    read_project(&project_path)
478}
479
480/// Load mode defaults by merging project.defaults.yaml with project.yaml modes section.
481pub fn load_mode_defaults(root: &Path) -> crate::model::project::ModeDefaults {
482    let defaults_path = project_defaults_path(root);
483    let mut base = read_yaml_value(&defaults_path)
484        .and_then(|v| v.get("modes").cloned())
485        .unwrap_or(serde_json::json!({}));
486
487    // Overlay from project.yaml modes section
488    let project_path = joy_dir(root).join(PROJECT_FILE);
489    if let Some(overlay) = read_yaml_value(&project_path).and_then(|v| v.get("modes").cloned()) {
490        deep_merge(&mut base, &overlay);
491    }
492
493    serde_json::from_value(base).unwrap_or_default()
494}
495
496/// Load the raw mode defaults from project.defaults.yaml (before project.yaml merge).
497/// Used for source tracking in resolve_mode().
498pub fn load_raw_mode_defaults(root: &Path) -> crate::model::project::ModeDefaults {
499    let path = project_defaults_path(root);
500    read_yaml_value(&path)
501        .and_then(|v| v.get("modes").cloned())
502        .and_then(|v| serde_json::from_value(v).ok())
503        .unwrap_or_default()
504}
505
506/// Load AI defaults (capabilities granted to AI members) from project.defaults.yaml,
507/// with project.yaml ai-defaults overlay.
508pub fn load_ai_defaults(root: &Path) -> crate::model::project::AiDefaults {
509    let defaults_path = project_defaults_path(root);
510    let mut base = read_yaml_value(&defaults_path)
511        .and_then(|v| v.get("ai-defaults").cloned())
512        .unwrap_or(serde_json::json!({}));
513
514    let project_path = joy_dir(root).join(PROJECT_FILE);
515    if let Some(overlay) =
516        read_yaml_value(&project_path).and_then(|v| v.get("ai-defaults").cloned())
517    {
518        deep_merge(&mut base, &overlay);
519    }
520
521    serde_json::from_value(base).unwrap_or_default()
522}
523
524/// Load the project acronym from project.yaml.
525pub fn load_acronym(root: &Path) -> Result<String, crate::error::JoyError> {
526    let project_path = joy_dir(root).join(PROJECT_FILE);
527    let project = read_project(&project_path)?;
528    project.acronym.ok_or_else(|| {
529        crate::error::JoyError::Other(
530            "project acronym not set -- run: joy project --acronym <ACRONYM>".to_string(),
531        )
532    })
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use crate::model::Config;
539    use tempfile::tempdir;
540
541    #[test]
542    fn write_and_read_yaml_roundtrip() {
543        let dir = tempdir().unwrap();
544        let path = dir.path().join("test.yaml");
545        let config = Config::default();
546        write_yaml(&path, &config).unwrap();
547        let parsed: Config = read_yaml(&path).unwrap();
548        assert_eq!(config, parsed);
549    }
550
551    #[test]
552    fn is_initialized_empty_dir() {
553        let dir = tempdir().unwrap();
554        assert!(!is_initialized(dir.path()));
555    }
556
557    #[test]
558    fn is_initialized_with_defaults_file() {
559        let dir = tempdir().unwrap();
560        let joy = dir.path().join(JOY_DIR);
561        std::fs::create_dir_all(&joy).unwrap();
562        write_yaml(&joy.join(CONFIG_DEFAULTS_FILE), &Config::default()).unwrap();
563        write_yaml(
564            &joy.join(PROJECT_FILE),
565            &crate::model::project::Project::new("test".into(), None),
566        )
567        .unwrap();
568        assert!(is_initialized(dir.path()));
569    }
570
571    #[test]
572    fn find_project_root_not_found() {
573        let dir = tempdir().unwrap();
574        assert!(find_project_root(dir.path()).is_none());
575    }
576
577    #[test]
578    fn deep_merge_objects() {
579        let mut base = serde_json::json!({"a": 1, "b": {"c": 2, "d": 3}});
580        let overlay = serde_json::json!({"b": {"c": 99, "e": 4}, "f": 5});
581        deep_merge(&mut base, &overlay);
582        assert_eq!(
583            base,
584            serde_json::json!({"a": 1, "b": {"c": 99, "d": 3, "e": 4}, "f": 5})
585        );
586    }
587
588    #[test]
589    fn deep_merge_replaces_non_objects() {
590        let mut base = serde_json::json!({"a": [1, 2]});
591        let overlay = serde_json::json!({"a": [3]});
592        deep_merge(&mut base, &overlay);
593        assert_eq!(base, serde_json::json!({"a": [3]}));
594    }
595
596    #[test]
597    fn read_yaml_value_returns_none_for_empty_file() {
598        let dir = tempdir().unwrap();
599        let path = dir.path().join("empty.yaml");
600        std::fs::write(&path, "").unwrap();
601        assert!(read_yaml_value(&path).is_none());
602    }
603
604    #[test]
605    fn read_yaml_value_returns_none_for_whitespace_only() {
606        let dir = tempdir().unwrap();
607        let path = dir.path().join("blank.yaml");
608        std::fs::write(&path, "  \n\n").unwrap();
609        assert!(read_yaml_value(&path).is_none());
610    }
611
612    // -----------------------------------------------------------------------
613    // Mode defaults loading integration tests
614    // -----------------------------------------------------------------------
615
616    use crate::model::config::InteractionLevel;
617    use crate::model::item::Capability;
618
619    fn setup_project_dir(dir: &std::path::Path) {
620        let joy = dir.join(JOY_DIR);
621        std::fs::create_dir_all(&joy).unwrap();
622        let project = crate::model::project::Project::new("test".into(), Some("TST".into()));
623        write_yaml(&joy.join(PROJECT_FILE), &project).unwrap();
624    }
625
626    #[test]
627    fn load_mode_defaults_from_file() {
628        let dir = tempdir().unwrap();
629        setup_project_dir(dir.path());
630        let defaults_content = r#"
631modes:
632  default: interactive
633  implement: collaborative
634  review: pairing
635"#;
636        std::fs::write(
637            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
638            defaults_content,
639        )
640        .unwrap();
641
642        let defaults = load_mode_defaults(dir.path());
643        assert_eq!(defaults.default, InteractionLevel::Interactive);
644        assert_eq!(
645            defaults.capabilities[&Capability::Implement],
646            InteractionLevel::Collaborative
647        );
648        assert_eq!(
649            defaults.capabilities[&Capability::Review],
650            InteractionLevel::Pairing
651        );
652    }
653
654    #[test]
655    fn load_mode_defaults_missing_file_returns_default() {
656        let dir = tempdir().unwrap();
657        setup_project_dir(dir.path());
658        let defaults = load_mode_defaults(dir.path());
659        assert_eq!(defaults.default, InteractionLevel::Collaborative);
660        assert!(defaults.capabilities.is_empty());
661    }
662
663    #[test]
664    fn load_mode_defaults_project_yaml_overrides() {
665        let dir = tempdir().unwrap();
666        setup_project_dir(dir.path());
667
668        let defaults_content = r#"
669modes:
670  default: collaborative
671  implement: collaborative
672"#;
673        std::fs::write(
674            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
675            defaults_content,
676        )
677        .unwrap();
678
679        // project.yaml overrides implement to interactive
680        let project_content = r#"
681name: test
682acronym: TST
683language: en
684created: "2026-01-01T00:00:00+00:00"
685members: {}
686modes:
687  implement: interactive
688"#;
689        std::fs::write(dir.path().join(JOY_DIR).join(PROJECT_FILE), project_content).unwrap();
690
691        let defaults = load_mode_defaults(dir.path());
692        assert_eq!(
693            defaults.capabilities[&Capability::Implement],
694            InteractionLevel::Interactive
695        );
696    }
697
698    #[test]
699    fn load_raw_mode_defaults_ignores_project_overrides() {
700        let dir = tempdir().unwrap();
701        setup_project_dir(dir.path());
702
703        let defaults_content = r#"
704modes:
705  implement: collaborative
706"#;
707        std::fs::write(
708            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
709            defaults_content,
710        )
711        .unwrap();
712
713        let project_content = r#"
714name: test
715acronym: TST
716language: en
717created: "2026-01-01T00:00:00+00:00"
718members: {}
719modes:
720  implement: interactive
721"#;
722        std::fs::write(dir.path().join(JOY_DIR).join(PROJECT_FILE), project_content).unwrap();
723
724        let raw = load_raw_mode_defaults(dir.path());
725        assert_eq!(
726            raw.capabilities[&Capability::Implement],
727            InteractionLevel::Collaborative
728        );
729    }
730
731    #[test]
732    fn load_ai_defaults_from_file() {
733        let dir = tempdir().unwrap();
734        setup_project_dir(dir.path());
735
736        let defaults_content = r#"
737ai-defaults:
738  capabilities:
739    - implement
740    - review
741    - plan
742"#;
743        std::fs::write(
744            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
745            defaults_content,
746        )
747        .unwrap();
748
749        let defaults = load_ai_defaults(dir.path());
750        assert_eq!(defaults.capabilities.len(), 3);
751    }
752
753    #[test]
754    fn load_ai_defaults_project_override_replaces_capabilities() {
755        let dir = tempdir().unwrap();
756        setup_project_dir(dir.path());
757
758        let defaults_content = r#"
759ai-defaults:
760  capabilities:
761    - implement
762    - review
763    - plan
764"#;
765        std::fs::write(
766            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
767            defaults_content,
768        )
769        .unwrap();
770
771        let project_content = r#"
772name: test
773acronym: TST
774language: en
775created: "2026-01-01T00:00:00+00:00"
776members: {}
777ai-defaults:
778  capabilities:
779    - implement
780"#;
781        std::fs::write(dir.path().join(JOY_DIR).join(PROJECT_FILE), project_content).unwrap();
782
783        let defaults = load_ai_defaults(dir.path());
784        assert_eq!(defaults.capabilities, vec![Capability::Implement]);
785    }
786}