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 {
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
110pub fn local_config_path(root: &Path) -> PathBuf {
112 joy_dir(root).join(CONFIG_FILE)
113}
114
115pub 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
128pub 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
149fn 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 if value.is_null() {
155 return None;
156 }
157 Some(value)
158}
159
160pub 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 let mut merged: serde_json::Value =
174 serde_json::to_value(crate::model::Config::default()).unwrap_or_default();
175
176 if let Some(defaults) = read_yaml_value(&defaults_config_path(&root)) {
178 deep_merge(&mut merged, &defaults);
179 }
180
181 if let Some(global) = read_yaml_value(&global_config_path()) {
183 deep_merge(&mut merged, &global);
184 }
185
186 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
200pub 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
224pub 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
262pub 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
305pub 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
312pub 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 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
328pub 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
338pub 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
356pub 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 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 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}