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
99pub fn global_config_path() -> PathBuf {
100    let config_dir = std::env::var("XDG_CONFIG_HOME")
101        .map(PathBuf::from)
102        .unwrap_or_else(|_| {
103            dirs_path_home()
104                .unwrap_or_else(|| PathBuf::from("."))
105                .join(".config")
106        });
107    config_dir.join("joy").join("config.yaml")
108}
109
110/// Returns the path to the personal per-project config: .joy/config.yaml
111pub fn local_config_path(root: &Path) -> PathBuf {
112    joy_dir(root).join(CONFIG_FILE)
113}
114
115/// Returns the path to the committed project defaults: .joy/config.defaults.yaml
116pub fn defaults_config_path(root: &Path) -> PathBuf {
117    joy_dir(root).join(CONFIG_DEFAULTS_FILE)
118}
119
120pub fn project_defaults_path(root: &Path) -> PathBuf {
121    joy_dir(root).join(PROJECT_DEFAULTS_FILE)
122}
123
124fn dirs_path_home() -> Option<PathBuf> {
125    std::env::var("HOME").ok().map(PathBuf::from)
126}
127
128/// Recursively merge `overlay` into `base`. Object keys are merged; all other
129/// types are replaced.
130/// Recursively merge `overlay` into `base` (public for use by config validation).
131pub fn deep_merge_value(base: &mut serde_json::Value, overlay: &serde_json::Value) {
132    deep_merge(base, overlay);
133}
134
135fn deep_merge(base: &mut serde_json::Value, overlay: &serde_json::Value) {
136    if let (Some(base_map), Some(overlay_map)) = (base.as_object_mut(), overlay.as_object()) {
137        for (key, value) in overlay_map {
138            if let Some(existing) = base_map.get_mut(key) {
139                deep_merge(existing, value);
140            } else {
141                base_map.insert(key.clone(), value.clone());
142            }
143        }
144    } else {
145        *base = overlay.clone();
146    }
147}
148
149/// Read a YAML file as a serde_json::Value, returning None if the file does not exist.
150fn read_yaml_value(path: &Path) -> Option<serde_json::Value> {
151    let content = std::fs::read_to_string(path).ok()?;
152    let value: serde_json::Value = serde_yaml_ng::from_str(&content).ok()?;
153    // Empty YAML files deserialize as null -- treat them as absent
154    if value.is_null() {
155        return None;
156    }
157    Some(value)
158}
159
160/// Load project config by merging four layers (code defaults < project defaults
161/// < global personal < local personal).
162pub fn load_config() -> crate::model::Config {
163    let cwd = match std::env::current_dir() {
164        Ok(p) => p,
165        Err(_) => return crate::model::Config::default(),
166    };
167    let root = match find_project_root(&cwd) {
168        Some(r) => r,
169        None => return crate::model::Config::default(),
170    };
171
172    // Layer 4: code defaults
173    let mut merged: serde_json::Value =
174        serde_json::to_value(crate::model::Config::default()).unwrap_or_default();
175
176    // Layer 3: project defaults (.joy/config.defaults.yaml)
177    if let Some(defaults) = read_yaml_value(&defaults_config_path(&root)) {
178        deep_merge(&mut merged, &defaults);
179    }
180
181    // Layer 2: global personal (~/.config/joy/config.yaml)
182    if let Some(global) = read_yaml_value(&global_config_path()) {
183        deep_merge(&mut merged, &global);
184    }
185
186    // Layer 1: local personal (.joy/config.yaml)
187    if let Some(local) = read_yaml_value(&local_config_path(&root)) {
188        deep_merge(&mut merged, &local);
189    }
190
191    match serde_json::from_value(merged) {
192        Ok(config) => config,
193        Err(e) => {
194            eprintln!("Warning: config has invalid values, using defaults: {e}");
195            crate::model::Config::default()
196        }
197    }
198}
199
200/// Load only the user-set config values (personal local + global, no defaults).
201/// Returns an empty object if no personal config exists.
202pub fn load_personal_config_value() -> serde_json::Value {
203    let cwd = match std::env::current_dir() {
204        Ok(p) => p,
205        Err(_) => return serde_json::json!({}),
206    };
207    let root = match find_project_root(&cwd) {
208        Some(r) => r,
209        None => return serde_json::json!({}),
210    };
211
212    let mut merged = serde_json::json!({});
213
214    if let Some(global) = read_yaml_value(&global_config_path()) {
215        deep_merge(&mut merged, &global);
216    }
217    if let Some(local) = read_yaml_value(&local_config_path(&root)) {
218        deep_merge(&mut merged, &local);
219    }
220
221    merged
222}
223
224/// Load the merged config as a serde_json::Value (preserves arbitrary keys).
225pub fn load_config_value() -> serde_json::Value {
226    let cwd = match std::env::current_dir() {
227        Ok(p) => p,
228        Err(_) => return serde_json::to_value(crate::model::Config::default()).unwrap_or_default(),
229    };
230    let root = match find_project_root(&cwd) {
231        Some(r) => r,
232        None => return serde_json::to_value(crate::model::Config::default()).unwrap_or_default(),
233    };
234
235    let mut merged: serde_json::Value =
236        serde_json::to_value(crate::model::Config::default()).unwrap_or_default();
237
238    if let Some(defaults) = read_yaml_value(&defaults_config_path(&root)) {
239        deep_merge(&mut merged, &defaults);
240    }
241    if let Some(global) = read_yaml_value(&global_config_path()) {
242        deep_merge(&mut merged, &global);
243    }
244    if let Some(local) = read_yaml_value(&local_config_path(&root)) {
245        deep_merge(&mut merged, &local);
246    }
247
248    merged
249}
250
251pub fn read_yaml<T: DeserializeOwned>(path: &Path) -> Result<T, JoyError> {
252    let bytes = std::fs::read(path).map_err(|e| JoyError::ReadFile {
253        path: path.to_path_buf(),
254        source: e,
255    })?;
256    // ADR-040: item / file content can be a JOYCRYPT blob on disk.
257    // The active session's zone keys live in a thread-local context
258    // populated by joy-cli after passphrase verification. If the
259    // blob's zone is not in that context, surface ZoneAccessDenied
260    // rather than a YAML parse error.
261    let plaintext = if crate::crypt::looks_like_blob(&bytes) {
262        let (_zone, plain) = crate::crypt::decrypt_blob(crate::crypt::active_zone_key, &bytes)?;
263        plain
264    } else {
265        bytes
266    };
267    serde_yaml_ng::from_slice(&plaintext).map_err(|e| JoyError::YamlParse {
268        path: path.to_path_buf(),
269        source: e,
270    })
271}
272
273/// Read project.yaml from an explicit path, applying schema migrations.
274///
275/// All Project deserialisation paths must funnel through this function
276/// (or [`load_project`]) so legacy auth-field renames are picked up
277/// uniformly. When a migration changes the parsed value, a one-line
278/// deprecation warning is emitted to stderr pointing at `joy update`.
279/// The on-disk file is not rewritten - persistence is explicit (per
280/// ADR-035). The warning is emitted at most once per process so a
281/// single command run does not flood stderr even when multiple code
282/// paths load project.yaml.
283pub fn read_project(
284    project_path: &Path,
285) -> Result<crate::model::project::Project, crate::error::JoyError> {
286    let content = std::fs::read_to_string(project_path).map_err(|e| JoyError::ReadFile {
287        path: project_path.to_path_buf(),
288        source: e,
289    })?;
290    let value: serde_yaml_ng::Value =
291        serde_yaml_ng::from_str(&content).map_err(|e| JoyError::YamlParse {
292            path: project_path.to_path_buf(),
293            source: e,
294        })?;
295    let (value, migrated) = crate::migrations::project_yaml::apply(value);
296    if migrated {
297        warn_legacy_schema_once();
298    }
299    serde_yaml_ng::from_value(value).map_err(|e| JoyError::YamlParse {
300        path: project_path.to_path_buf(),
301        source: e,
302    })
303}
304
305fn warn_legacy_schema_once() {
306    use std::sync::Once;
307    static WARN: Once = Once::new();
308    WARN.call_once(|| {
309        eprintln!(
310            "warning: project.yaml uses legacy auth field names from before v0.12; \
311             run `joy update` to normalise. Legacy support will be removed in v0.13."
312        );
313    });
314}
315
316/// Load the full project metadata from project.yaml under the given
317/// project root. Applies migrations via [`read_project`].
318pub fn load_project(root: &Path) -> Result<crate::model::project::Project, crate::error::JoyError> {
319    let project_path = joy_dir(root).join(PROJECT_FILE);
320    read_project(&project_path)
321}
322
323/// Load mode defaults by merging project.defaults.yaml with project.yaml modes section.
324pub fn load_mode_defaults(root: &Path) -> crate::model::project::ModeDefaults {
325    let defaults_path = project_defaults_path(root);
326    let mut base = read_yaml_value(&defaults_path)
327        .and_then(|v| v.get("modes").cloned())
328        .unwrap_or(serde_json::json!({}));
329
330    // Overlay from project.yaml modes section
331    let project_path = joy_dir(root).join(PROJECT_FILE);
332    if let Some(overlay) = read_yaml_value(&project_path).and_then(|v| v.get("modes").cloned()) {
333        deep_merge(&mut base, &overlay);
334    }
335
336    serde_json::from_value(base).unwrap_or_default()
337}
338
339/// Load the raw mode defaults from project.defaults.yaml (before project.yaml merge).
340/// Used for source tracking in resolve_mode().
341pub fn load_raw_mode_defaults(root: &Path) -> crate::model::project::ModeDefaults {
342    let path = project_defaults_path(root);
343    read_yaml_value(&path)
344        .and_then(|v| v.get("modes").cloned())
345        .and_then(|v| serde_json::from_value(v).ok())
346        .unwrap_or_default()
347}
348
349/// Load AI defaults (capabilities granted to AI members) from project.defaults.yaml,
350/// with project.yaml ai-defaults overlay.
351pub fn load_ai_defaults(root: &Path) -> crate::model::project::AiDefaults {
352    let defaults_path = project_defaults_path(root);
353    let mut base = read_yaml_value(&defaults_path)
354        .and_then(|v| v.get("ai-defaults").cloned())
355        .unwrap_or(serde_json::json!({}));
356
357    let project_path = joy_dir(root).join(PROJECT_FILE);
358    if let Some(overlay) =
359        read_yaml_value(&project_path).and_then(|v| v.get("ai-defaults").cloned())
360    {
361        deep_merge(&mut base, &overlay);
362    }
363
364    serde_json::from_value(base).unwrap_or_default()
365}
366
367/// Load the project acronym from project.yaml.
368pub fn load_acronym(root: &Path) -> Result<String, crate::error::JoyError> {
369    let project_path = joy_dir(root).join(PROJECT_FILE);
370    let project = read_project(&project_path)?;
371    project.acronym.ok_or_else(|| {
372        crate::error::JoyError::Other(
373            "project acronym not set -- run: joy project --acronym <ACRONYM>".to_string(),
374        )
375    })
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::model::Config;
382    use tempfile::tempdir;
383
384    #[test]
385    fn write_and_read_yaml_roundtrip() {
386        let dir = tempdir().unwrap();
387        let path = dir.path().join("test.yaml");
388        let config = Config::default();
389        write_yaml(&path, &config).unwrap();
390        let parsed: Config = read_yaml(&path).unwrap();
391        assert_eq!(config, parsed);
392    }
393
394    #[test]
395    fn is_initialized_empty_dir() {
396        let dir = tempdir().unwrap();
397        assert!(!is_initialized(dir.path()));
398    }
399
400    #[test]
401    fn is_initialized_with_defaults_file() {
402        let dir = tempdir().unwrap();
403        let joy = dir.path().join(JOY_DIR);
404        std::fs::create_dir_all(&joy).unwrap();
405        write_yaml(&joy.join(CONFIG_DEFAULTS_FILE), &Config::default()).unwrap();
406        write_yaml(
407            &joy.join(PROJECT_FILE),
408            &crate::model::project::Project::new("test".into(), None),
409        )
410        .unwrap();
411        assert!(is_initialized(dir.path()));
412    }
413
414    #[test]
415    fn find_project_root_not_found() {
416        let dir = tempdir().unwrap();
417        assert!(find_project_root(dir.path()).is_none());
418    }
419
420    #[test]
421    fn deep_merge_objects() {
422        let mut base = serde_json::json!({"a": 1, "b": {"c": 2, "d": 3}});
423        let overlay = serde_json::json!({"b": {"c": 99, "e": 4}, "f": 5});
424        deep_merge(&mut base, &overlay);
425        assert_eq!(
426            base,
427            serde_json::json!({"a": 1, "b": {"c": 99, "d": 3, "e": 4}, "f": 5})
428        );
429    }
430
431    #[test]
432    fn deep_merge_replaces_non_objects() {
433        let mut base = serde_json::json!({"a": [1, 2]});
434        let overlay = serde_json::json!({"a": [3]});
435        deep_merge(&mut base, &overlay);
436        assert_eq!(base, serde_json::json!({"a": [3]}));
437    }
438
439    #[test]
440    fn read_yaml_value_returns_none_for_empty_file() {
441        let dir = tempdir().unwrap();
442        let path = dir.path().join("empty.yaml");
443        std::fs::write(&path, "").unwrap();
444        assert!(read_yaml_value(&path).is_none());
445    }
446
447    #[test]
448    fn read_yaml_value_returns_none_for_whitespace_only() {
449        let dir = tempdir().unwrap();
450        let path = dir.path().join("blank.yaml");
451        std::fs::write(&path, "  \n\n").unwrap();
452        assert!(read_yaml_value(&path).is_none());
453    }
454
455    // -----------------------------------------------------------------------
456    // Mode defaults loading integration tests
457    // -----------------------------------------------------------------------
458
459    use crate::model::config::InteractionLevel;
460    use crate::model::item::Capability;
461
462    fn setup_project_dir(dir: &std::path::Path) {
463        let joy = dir.join(JOY_DIR);
464        std::fs::create_dir_all(&joy).unwrap();
465        let project = crate::model::project::Project::new("test".into(), Some("TST".into()));
466        write_yaml(&joy.join(PROJECT_FILE), &project).unwrap();
467    }
468
469    #[test]
470    fn load_mode_defaults_from_file() {
471        let dir = tempdir().unwrap();
472        setup_project_dir(dir.path());
473        let defaults_content = r#"
474modes:
475  default: interactive
476  implement: collaborative
477  review: pairing
478"#;
479        std::fs::write(
480            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
481            defaults_content,
482        )
483        .unwrap();
484
485        let defaults = load_mode_defaults(dir.path());
486        assert_eq!(defaults.default, InteractionLevel::Interactive);
487        assert_eq!(
488            defaults.capabilities[&Capability::Implement],
489            InteractionLevel::Collaborative
490        );
491        assert_eq!(
492            defaults.capabilities[&Capability::Review],
493            InteractionLevel::Pairing
494        );
495    }
496
497    #[test]
498    fn load_mode_defaults_missing_file_returns_default() {
499        let dir = tempdir().unwrap();
500        setup_project_dir(dir.path());
501        let defaults = load_mode_defaults(dir.path());
502        assert_eq!(defaults.default, InteractionLevel::Collaborative);
503        assert!(defaults.capabilities.is_empty());
504    }
505
506    #[test]
507    fn load_mode_defaults_project_yaml_overrides() {
508        let dir = tempdir().unwrap();
509        setup_project_dir(dir.path());
510
511        let defaults_content = r#"
512modes:
513  default: collaborative
514  implement: collaborative
515"#;
516        std::fs::write(
517            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
518            defaults_content,
519        )
520        .unwrap();
521
522        // project.yaml overrides implement to interactive
523        let project_content = r#"
524name: test
525acronym: TST
526language: en
527created: "2026-01-01T00:00:00+00:00"
528members: {}
529modes:
530  implement: interactive
531"#;
532        std::fs::write(dir.path().join(JOY_DIR).join(PROJECT_FILE), project_content).unwrap();
533
534        let defaults = load_mode_defaults(dir.path());
535        assert_eq!(
536            defaults.capabilities[&Capability::Implement],
537            InteractionLevel::Interactive
538        );
539    }
540
541    #[test]
542    fn load_raw_mode_defaults_ignores_project_overrides() {
543        let dir = tempdir().unwrap();
544        setup_project_dir(dir.path());
545
546        let defaults_content = r#"
547modes:
548  implement: collaborative
549"#;
550        std::fs::write(
551            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
552            defaults_content,
553        )
554        .unwrap();
555
556        let project_content = r#"
557name: test
558acronym: TST
559language: en
560created: "2026-01-01T00:00:00+00:00"
561members: {}
562modes:
563  implement: interactive
564"#;
565        std::fs::write(dir.path().join(JOY_DIR).join(PROJECT_FILE), project_content).unwrap();
566
567        let raw = load_raw_mode_defaults(dir.path());
568        assert_eq!(
569            raw.capabilities[&Capability::Implement],
570            InteractionLevel::Collaborative
571        );
572    }
573
574    #[test]
575    fn load_ai_defaults_from_file() {
576        let dir = tempdir().unwrap();
577        setup_project_dir(dir.path());
578
579        let defaults_content = r#"
580ai-defaults:
581  capabilities:
582    - implement
583    - review
584    - plan
585"#;
586        std::fs::write(
587            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
588            defaults_content,
589        )
590        .unwrap();
591
592        let defaults = load_ai_defaults(dir.path());
593        assert_eq!(defaults.capabilities.len(), 3);
594    }
595
596    #[test]
597    fn load_ai_defaults_project_override_replaces_capabilities() {
598        let dir = tempdir().unwrap();
599        setup_project_dir(dir.path());
600
601        let defaults_content = r#"
602ai-defaults:
603  capabilities:
604    - implement
605    - review
606    - plan
607"#;
608        std::fs::write(
609            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
610            defaults_content,
611        )
612        .unwrap();
613
614        let project_content = r#"
615name: test
616acronym: TST
617language: en
618created: "2026-01-01T00:00:00+00:00"
619members: {}
620ai-defaults:
621  capabilities:
622    - implement
623"#;
624        std::fs::write(dir.path().join(JOY_DIR).join(PROJECT_FILE), project_content).unwrap();
625
626        let defaults = load_ai_defaults(dir.path());
627        assert_eq!(defaults.capabilities, vec![Capability::Implement]);
628    }
629}