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