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/// Load the full project metadata from project.yaml.
263pub fn load_project(root: &Path) -> Result<crate::model::project::Project, crate::error::JoyError> {
264    let project_path = joy_dir(root).join(PROJECT_FILE);
265    read_yaml(&project_path)
266}
267
268/// Load mode defaults by merging project.defaults.yaml with project.yaml modes section.
269pub fn load_mode_defaults(root: &Path) -> crate::model::project::ModeDefaults {
270    let defaults_path = project_defaults_path(root);
271    let mut base = read_yaml_value(&defaults_path)
272        .and_then(|v| v.get("modes").cloned())
273        .unwrap_or(serde_json::json!({}));
274
275    // Overlay from project.yaml modes section
276    let project_path = joy_dir(root).join(PROJECT_FILE);
277    if let Some(overlay) = read_yaml_value(&project_path).and_then(|v| v.get("modes").cloned()) {
278        deep_merge(&mut base, &overlay);
279    }
280
281    serde_json::from_value(base).unwrap_or_default()
282}
283
284/// Load the raw mode defaults from project.defaults.yaml (before project.yaml merge).
285/// Used for source tracking in resolve_mode().
286pub fn load_raw_mode_defaults(root: &Path) -> crate::model::project::ModeDefaults {
287    let path = project_defaults_path(root);
288    read_yaml_value(&path)
289        .and_then(|v| v.get("modes").cloned())
290        .and_then(|v| serde_json::from_value(v).ok())
291        .unwrap_or_default()
292}
293
294/// Load AI defaults (capabilities granted to AI members) from project.defaults.yaml,
295/// with project.yaml ai-defaults overlay.
296pub fn load_ai_defaults(root: &Path) -> crate::model::project::AiDefaults {
297    let defaults_path = project_defaults_path(root);
298    let mut base = read_yaml_value(&defaults_path)
299        .and_then(|v| v.get("ai-defaults").cloned())
300        .unwrap_or(serde_json::json!({}));
301
302    let project_path = joy_dir(root).join(PROJECT_FILE);
303    if let Some(overlay) =
304        read_yaml_value(&project_path).and_then(|v| v.get("ai-defaults").cloned())
305    {
306        deep_merge(&mut base, &overlay);
307    }
308
309    serde_json::from_value(base).unwrap_or_default()
310}
311
312/// Load the project acronym from project.yaml.
313pub fn load_acronym(root: &Path) -> Result<String, crate::error::JoyError> {
314    let project_path = joy_dir(root).join(PROJECT_FILE);
315    let project: crate::model::project::Project = read_yaml(&project_path)?;
316    project.acronym.ok_or_else(|| {
317        crate::error::JoyError::Other(
318            "project acronym not set -- run: joy project --acronym <ACRONYM>".to_string(),
319        )
320    })
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::model::Config;
327    use tempfile::tempdir;
328
329    #[test]
330    fn write_and_read_yaml_roundtrip() {
331        let dir = tempdir().unwrap();
332        let path = dir.path().join("test.yaml");
333        let config = Config::default();
334        write_yaml(&path, &config).unwrap();
335        let parsed: Config = read_yaml(&path).unwrap();
336        assert_eq!(config, parsed);
337    }
338
339    #[test]
340    fn is_initialized_empty_dir() {
341        let dir = tempdir().unwrap();
342        assert!(!is_initialized(dir.path()));
343    }
344
345    #[test]
346    fn is_initialized_with_defaults_file() {
347        let dir = tempdir().unwrap();
348        let joy = dir.path().join(JOY_DIR);
349        std::fs::create_dir_all(&joy).unwrap();
350        write_yaml(&joy.join(CONFIG_DEFAULTS_FILE), &Config::default()).unwrap();
351        write_yaml(
352            &joy.join(PROJECT_FILE),
353            &crate::model::project::Project::new("test".into(), None),
354        )
355        .unwrap();
356        assert!(is_initialized(dir.path()));
357    }
358
359    #[test]
360    fn find_project_root_not_found() {
361        let dir = tempdir().unwrap();
362        assert!(find_project_root(dir.path()).is_none());
363    }
364
365    #[test]
366    fn deep_merge_objects() {
367        let mut base = serde_json::json!({"a": 1, "b": {"c": 2, "d": 3}});
368        let overlay = serde_json::json!({"b": {"c": 99, "e": 4}, "f": 5});
369        deep_merge(&mut base, &overlay);
370        assert_eq!(
371            base,
372            serde_json::json!({"a": 1, "b": {"c": 99, "d": 3, "e": 4}, "f": 5})
373        );
374    }
375
376    #[test]
377    fn deep_merge_replaces_non_objects() {
378        let mut base = serde_json::json!({"a": [1, 2]});
379        let overlay = serde_json::json!({"a": [3]});
380        deep_merge(&mut base, &overlay);
381        assert_eq!(base, serde_json::json!({"a": [3]}));
382    }
383
384    #[test]
385    fn read_yaml_value_returns_none_for_empty_file() {
386        let dir = tempdir().unwrap();
387        let path = dir.path().join("empty.yaml");
388        std::fs::write(&path, "").unwrap();
389        assert!(read_yaml_value(&path).is_none());
390    }
391
392    #[test]
393    fn read_yaml_value_returns_none_for_whitespace_only() {
394        let dir = tempdir().unwrap();
395        let path = dir.path().join("blank.yaml");
396        std::fs::write(&path, "  \n\n").unwrap();
397        assert!(read_yaml_value(&path).is_none());
398    }
399
400    // -----------------------------------------------------------------------
401    // Mode defaults loading integration tests
402    // -----------------------------------------------------------------------
403
404    use crate::model::config::InteractionLevel;
405    use crate::model::item::Capability;
406
407    fn setup_project_dir(dir: &std::path::Path) {
408        let joy = dir.join(JOY_DIR);
409        std::fs::create_dir_all(&joy).unwrap();
410        let project = crate::model::project::Project::new("test".into(), Some("TST".into()));
411        write_yaml(&joy.join(PROJECT_FILE), &project).unwrap();
412    }
413
414    #[test]
415    fn load_mode_defaults_from_file() {
416        let dir = tempdir().unwrap();
417        setup_project_dir(dir.path());
418        let defaults_content = r#"
419modes:
420  default: interactive
421  implement: collaborative
422  review: pairing
423"#;
424        std::fs::write(
425            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
426            defaults_content,
427        )
428        .unwrap();
429
430        let defaults = load_mode_defaults(dir.path());
431        assert_eq!(defaults.default, InteractionLevel::Interactive);
432        assert_eq!(
433            defaults.capabilities[&Capability::Implement],
434            InteractionLevel::Collaborative
435        );
436        assert_eq!(
437            defaults.capabilities[&Capability::Review],
438            InteractionLevel::Pairing
439        );
440    }
441
442    #[test]
443    fn load_mode_defaults_missing_file_returns_default() {
444        let dir = tempdir().unwrap();
445        setup_project_dir(dir.path());
446        let defaults = load_mode_defaults(dir.path());
447        assert_eq!(defaults.default, InteractionLevel::Collaborative);
448        assert!(defaults.capabilities.is_empty());
449    }
450
451    #[test]
452    fn load_mode_defaults_project_yaml_overrides() {
453        let dir = tempdir().unwrap();
454        setup_project_dir(dir.path());
455
456        let defaults_content = r#"
457modes:
458  default: collaborative
459  implement: collaborative
460"#;
461        std::fs::write(
462            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
463            defaults_content,
464        )
465        .unwrap();
466
467        // project.yaml overrides implement to interactive
468        let project_content = r#"
469name: test
470acronym: TST
471language: en
472created: "2026-01-01T00:00:00+00:00"
473members: {}
474modes:
475  implement: interactive
476"#;
477        std::fs::write(dir.path().join(JOY_DIR).join(PROJECT_FILE), project_content).unwrap();
478
479        let defaults = load_mode_defaults(dir.path());
480        assert_eq!(
481            defaults.capabilities[&Capability::Implement],
482            InteractionLevel::Interactive
483        );
484    }
485
486    #[test]
487    fn load_raw_mode_defaults_ignores_project_overrides() {
488        let dir = tempdir().unwrap();
489        setup_project_dir(dir.path());
490
491        let defaults_content = r#"
492modes:
493  implement: collaborative
494"#;
495        std::fs::write(
496            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
497            defaults_content,
498        )
499        .unwrap();
500
501        let project_content = r#"
502name: test
503acronym: TST
504language: en
505created: "2026-01-01T00:00:00+00:00"
506members: {}
507modes:
508  implement: interactive
509"#;
510        std::fs::write(dir.path().join(JOY_DIR).join(PROJECT_FILE), project_content).unwrap();
511
512        let raw = load_raw_mode_defaults(dir.path());
513        assert_eq!(
514            raw.capabilities[&Capability::Implement],
515            InteractionLevel::Collaborative
516        );
517    }
518
519    #[test]
520    fn load_ai_defaults_from_file() {
521        let dir = tempdir().unwrap();
522        setup_project_dir(dir.path());
523
524        let defaults_content = r#"
525ai-defaults:
526  capabilities:
527    - implement
528    - review
529    - plan
530"#;
531        std::fs::write(
532            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
533            defaults_content,
534        )
535        .unwrap();
536
537        let defaults = load_ai_defaults(dir.path());
538        assert_eq!(defaults.capabilities.len(), 3);
539    }
540
541    #[test]
542    fn load_ai_defaults_project_override_replaces_capabilities() {
543        let dir = tempdir().unwrap();
544        setup_project_dir(dir.path());
545
546        let defaults_content = r#"
547ai-defaults:
548  capabilities:
549    - implement
550    - review
551    - plan
552"#;
553        std::fs::write(
554            dir.path().join(JOY_DIR).join(PROJECT_DEFAULTS_FILE),
555            defaults_content,
556        )
557        .unwrap();
558
559        let project_content = r#"
560name: test
561acronym: TST
562language: en
563created: "2026-01-01T00:00:00+00:00"
564members: {}
565ai-defaults:
566  capabilities:
567    - implement
568"#;
569        std::fs::write(dir.path().join(JOY_DIR).join(PROJECT_FILE), project_content).unwrap();
570
571        let defaults = load_ai_defaults(dir.path());
572        assert_eq!(defaults.capabilities, vec![Capability::Implement]);
573    }
574}