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 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
268pub 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 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
284pub 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
294pub 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
312pub 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 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 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}