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 content = std::fs::read_to_string(path).map_err(|e| JoyError::ReadFile {
253        path: path.to_path_buf(),
254        source: e,
255    })?;
256    serde_yaml_ng::from_str(&content).map_err(|e| JoyError::YamlParse {
257        path: path.to_path_buf(),
258        source: e,
259    })
260}
261
262/// Read project.yaml from an explicit path, applying schema migrations.
263///
264/// All Project deserialisation paths must funnel through this function
265/// (or [`load_project`]) so legacy auth-field renames are picked up
266/// uniformly. When a migration changes the parsed value, a one-line
267/// deprecation warning is emitted to stderr pointing at
268/// `joy auth update`. The on-disk file is not rewritten - persistence
269/// is explicit (per ADR-035). The warning is emitted at most once per
270/// process so a single command run does not flood stderr even when
271/// multiple code paths load project.yaml.
272pub fn read_project(
273    project_path: &Path,
274) -> Result<crate::model::project::Project, crate::error::JoyError> {
275    let content = std::fs::read_to_string(project_path).map_err(|e| JoyError::ReadFile {
276        path: project_path.to_path_buf(),
277        source: e,
278    })?;
279    let value: serde_yaml_ng::Value =
280        serde_yaml_ng::from_str(&content).map_err(|e| JoyError::YamlParse {
281            path: project_path.to_path_buf(),
282            source: e,
283        })?;
284    let (value, migrated) = crate::migrations::project_yaml::apply(value);
285    if migrated {
286        warn_legacy_schema_once();
287    }
288    serde_yaml_ng::from_value(value).map_err(|e| JoyError::YamlParse {
289        path: project_path.to_path_buf(),
290        source: e,
291    })
292}
293
294fn warn_legacy_schema_once() {
295    use std::sync::Once;
296    static WARN: Once = Once::new();
297    WARN.call_once(|| {
298        eprintln!(
299            "warning: project.yaml uses legacy auth field names from before v0.12; \
300             run `joy auth update` to normalise. Legacy support will be removed in v0.13."
301        );
302    });
303}
304
305/// Load the full project metadata from project.yaml under the given
306/// project root. Applies migrations via [`read_project`].
307pub fn load_project(root: &Path) -> Result<crate::model::project::Project, crate::error::JoyError> {
308    let project_path = joy_dir(root).join(PROJECT_FILE);
309    read_project(&project_path)
310}
311
312/// Load mode defaults by merging project.defaults.yaml with project.yaml modes section.
313pub fn load_mode_defaults(root: &Path) -> crate::model::project::ModeDefaults {
314    let defaults_path = project_defaults_path(root);
315    let mut base = read_yaml_value(&defaults_path)
316        .and_then(|v| v.get("modes").cloned())
317        .unwrap_or(serde_json::json!({}));
318
319    // Overlay from project.yaml modes section
320    let project_path = joy_dir(root).join(PROJECT_FILE);
321    if let Some(overlay) = read_yaml_value(&project_path).and_then(|v| v.get("modes").cloned()) {
322        deep_merge(&mut base, &overlay);
323    }
324
325    serde_json::from_value(base).unwrap_or_default()
326}
327
328/// Load the raw mode defaults from project.defaults.yaml (before project.yaml merge).
329/// Used for source tracking in resolve_mode().
330pub fn load_raw_mode_defaults(root: &Path) -> crate::model::project::ModeDefaults {
331    let path = project_defaults_path(root);
332    read_yaml_value(&path)
333        .and_then(|v| v.get("modes").cloned())
334        .and_then(|v| serde_json::from_value(v).ok())
335        .unwrap_or_default()
336}
337
338/// Load AI defaults (capabilities granted to AI members) from project.defaults.yaml,
339/// with project.yaml ai-defaults overlay.
340pub fn load_ai_defaults(root: &Path) -> crate::model::project::AiDefaults {
341    let defaults_path = project_defaults_path(root);
342    let mut base = read_yaml_value(&defaults_path)
343        .and_then(|v| v.get("ai-defaults").cloned())
344        .unwrap_or(serde_json::json!({}));
345
346    let project_path = joy_dir(root).join(PROJECT_FILE);
347    if let Some(overlay) =
348        read_yaml_value(&project_path).and_then(|v| v.get("ai-defaults").cloned())
349    {
350        deep_merge(&mut base, &overlay);
351    }
352
353    serde_json::from_value(base).unwrap_or_default()
354}
355
356/// Load the project acronym from project.yaml.
357pub fn load_acronym(root: &Path) -> Result<String, crate::error::JoyError> {
358    let project_path = joy_dir(root).join(PROJECT_FILE);
359    let project = read_project(&project_path)?;
360    project.acronym.ok_or_else(|| {
361        crate::error::JoyError::Other(
362            "project acronym not set -- run: joy project --acronym <ACRONYM>".to_string(),
363        )
364    })
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::model::Config;
371    use tempfile::tempdir;
372
373    #[test]
374    fn write_and_read_yaml_roundtrip() {
375        let dir = tempdir().unwrap();
376        let path = dir.path().join("test.yaml");
377        let config = Config::default();
378        write_yaml(&path, &config).unwrap();
379        let parsed: Config = read_yaml(&path).unwrap();
380        assert_eq!(config, parsed);
381    }
382
383    #[test]
384    fn is_initialized_empty_dir() {
385        let dir = tempdir().unwrap();
386        assert!(!is_initialized(dir.path()));
387    }
388
389    #[test]
390    fn is_initialized_with_defaults_file() {
391        let dir = tempdir().unwrap();
392        let joy = dir.path().join(JOY_DIR);
393        std::fs::create_dir_all(&joy).unwrap();
394        write_yaml(&joy.join(CONFIG_DEFAULTS_FILE), &Config::default()).unwrap();
395        write_yaml(
396            &joy.join(PROJECT_FILE),
397            &crate::model::project::Project::new("test".into(), None),
398        )
399        .unwrap();
400        assert!(is_initialized(dir.path()));
401    }
402
403    #[test]
404    fn find_project_root_not_found() {
405        let dir = tempdir().unwrap();
406        assert!(find_project_root(dir.path()).is_none());
407    }
408
409    #[test]
410    fn deep_merge_objects() {
411        let mut base = serde_json::json!({"a": 1, "b": {"c": 2, "d": 3}});
412        let overlay = serde_json::json!({"b": {"c": 99, "e": 4}, "f": 5});
413        deep_merge(&mut base, &overlay);
414        assert_eq!(
415            base,
416            serde_json::json!({"a": 1, "b": {"c": 99, "d": 3, "e": 4}, "f": 5})
417        );
418    }
419
420    #[test]
421    fn deep_merge_replaces_non_objects() {
422        let mut base = serde_json::json!({"a": [1, 2]});
423        let overlay = serde_json::json!({"a": [3]});
424        deep_merge(&mut base, &overlay);
425        assert_eq!(base, serde_json::json!({"a": [3]}));
426    }
427
428    #[test]
429    fn read_yaml_value_returns_none_for_empty_file() {
430        let dir = tempdir().unwrap();
431        let path = dir.path().join("empty.yaml");
432        std::fs::write(&path, "").unwrap();
433        assert!(read_yaml_value(&path).is_none());
434    }
435
436    #[test]
437    fn read_yaml_value_returns_none_for_whitespace_only() {
438        let dir = tempdir().unwrap();
439        let path = dir.path().join("blank.yaml");
440        std::fs::write(&path, "  \n\n").unwrap();
441        assert!(read_yaml_value(&path).is_none());
442    }
443
444    // -----------------------------------------------------------------------
445    // Mode defaults loading integration tests
446    // -----------------------------------------------------------------------
447
448    use crate::model::config::InteractionLevel;
449    use crate::model::item::Capability;
450
451    fn setup_project_dir(dir: &std::path::Path) {
452        let joy = dir.join(JOY_DIR);
453        std::fs::create_dir_all(&joy).unwrap();
454        let project = crate::model::project::Project::new("test".into(), Some("TST".into()));
455        write_yaml(&joy.join(PROJECT_FILE), &project).unwrap();
456    }
457
458    #[test]
459    fn load_mode_defaults_from_file() {
460        let dir = tempdir().unwrap();
461        setup_project_dir(dir.path());
462        let defaults_content = r#"
463modes:
464  default: interactive
465  implement: collaborative
466  review: pairing
467"#;
468        std::fs::write(
469            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
470            defaults_content,
471        )
472        .unwrap();
473
474        let defaults = load_mode_defaults(dir.path());
475        assert_eq!(defaults.default, InteractionLevel::Interactive);
476        assert_eq!(
477            defaults.capabilities[&Capability::Implement],
478            InteractionLevel::Collaborative
479        );
480        assert_eq!(
481            defaults.capabilities[&Capability::Review],
482            InteractionLevel::Pairing
483        );
484    }
485
486    #[test]
487    fn load_mode_defaults_missing_file_returns_default() {
488        let dir = tempdir().unwrap();
489        setup_project_dir(dir.path());
490        let defaults = load_mode_defaults(dir.path());
491        assert_eq!(defaults.default, InteractionLevel::Collaborative);
492        assert!(defaults.capabilities.is_empty());
493    }
494
495    #[test]
496    fn load_mode_defaults_project_yaml_overrides() {
497        let dir = tempdir().unwrap();
498        setup_project_dir(dir.path());
499
500        let defaults_content = r#"
501modes:
502  default: collaborative
503  implement: collaborative
504"#;
505        std::fs::write(
506            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
507            defaults_content,
508        )
509        .unwrap();
510
511        // project.yaml overrides implement to interactive
512        let project_content = r#"
513name: test
514acronym: TST
515language: en
516created: "2026-01-01T00:00:00+00:00"
517members: {}
518modes:
519  implement: interactive
520"#;
521        std::fs::write(dir.path().join(JOY_DIR).join(PROJECT_FILE), project_content).unwrap();
522
523        let defaults = load_mode_defaults(dir.path());
524        assert_eq!(
525            defaults.capabilities[&Capability::Implement],
526            InteractionLevel::Interactive
527        );
528    }
529
530    #[test]
531    fn load_raw_mode_defaults_ignores_project_overrides() {
532        let dir = tempdir().unwrap();
533        setup_project_dir(dir.path());
534
535        let defaults_content = r#"
536modes:
537  implement: collaborative
538"#;
539        std::fs::write(
540            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
541            defaults_content,
542        )
543        .unwrap();
544
545        let project_content = r#"
546name: test
547acronym: TST
548language: en
549created: "2026-01-01T00:00:00+00:00"
550members: {}
551modes:
552  implement: interactive
553"#;
554        std::fs::write(dir.path().join(JOY_DIR).join(PROJECT_FILE), project_content).unwrap();
555
556        let raw = load_raw_mode_defaults(dir.path());
557        assert_eq!(
558            raw.capabilities[&Capability::Implement],
559            InteractionLevel::Collaborative
560        );
561    }
562
563    #[test]
564    fn load_ai_defaults_from_file() {
565        let dir = tempdir().unwrap();
566        setup_project_dir(dir.path());
567
568        let defaults_content = r#"
569ai-defaults:
570  capabilities:
571    - implement
572    - review
573    - plan
574"#;
575        std::fs::write(
576            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
577            defaults_content,
578        )
579        .unwrap();
580
581        let defaults = load_ai_defaults(dir.path());
582        assert_eq!(defaults.capabilities.len(), 3);
583    }
584
585    #[test]
586    fn load_ai_defaults_project_override_replaces_capabilities() {
587        let dir = tempdir().unwrap();
588        setup_project_dir(dir.path());
589
590        let defaults_content = r#"
591ai-defaults:
592  capabilities:
593    - implement
594    - review
595    - plan
596"#;
597        std::fs::write(
598            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
599            defaults_content,
600        )
601        .unwrap();
602
603        let project_content = r#"
604name: test
605acronym: TST
606language: en
607created: "2026-01-01T00:00:00+00:00"
608members: {}
609ai-defaults:
610  capabilities:
611    - implement
612"#;
613        std::fs::write(dir.path().join(JOY_DIR).join(PROJECT_FILE), project_content).unwrap();
614
615        let defaults = load_ai_defaults(dir.path());
616        assert_eq!(defaults.capabilities, vec![Capability::Implement]);
617    }
618}