1use 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
35pub fn find_project_root(start: &Path) -> Option<PathBuf> {
37 let mut current = start.to_path_buf();
38 loop {
39 if is_initialized(¤t) {
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
56pub 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 let mut result = new_map.clone();
74 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
98pub fn global_config_path() -> PathBuf {
101 config_base_dir()
102 .unwrap_or_else(|| PathBuf::from("."))
103 .join("joy")
104 .join("config.yaml")
105}
106
107fn 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
121pub fn local_config_path(root: &Path) -> PathBuf {
123 joy_dir(root).join(CONFIG_FILE)
124}
125
126pub 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
135pub(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
285pub 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
306fn 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 if value.is_null() {
312 return None;
313 }
314 Some(value)
315}
316
317pub 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 let mut merged: serde_json::Value =
331 serde_json::to_value(crate::model::Config::default()).unwrap_or_default();
332
333 if let Some(defaults) = read_yaml_value(&defaults_config_path(&root)) {
335 deep_merge(&mut merged, &defaults);
336 }
337
338 if let Some(global) = read_yaml_value(&global_config_path()) {
340 deep_merge(&mut merged, &global);
341 }
342
343 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
357pub 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
381pub 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 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
430pub 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
473pub 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
480pub 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 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
496pub 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
506pub 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
524pub 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 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 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}