1use std::path::{Path, PathBuf};
5
6use crate::embedded::{self, EmbeddedFile};
7use crate::error::JoyError;
8use crate::model::project::{derive_acronym, Project};
9use crate::store;
10use crate::vcs::{default_vcs, Vcs};
11
12pub const HOOK_FILES: &[EmbeddedFile] = &[
13 EmbeddedFile {
14 content: include_str!("../data/hooks/commit-msg"),
15 target: "hooks/commit-msg",
16 executable: true,
17 },
18 EmbeddedFile {
19 content: include_str!("../data/hooks/prepare-commit-msg"),
20 target: "hooks/prepare-commit-msg",
21 executable: true,
22 },
23];
24
25pub const CONFIG_FILES: &[EmbeddedFile] = &[EmbeddedFile {
26 content: include_str!("../data/config.defaults.yaml"),
27 target: "config.defaults.yaml",
28 executable: false,
29}];
30
31pub const PROJECT_FILES: &[EmbeddedFile] = &[EmbeddedFile {
32 content: include_str!("../data/project.defaults.yaml"),
33 target: "project.defaults.yaml",
34 executable: false,
35}];
36
37pub const LEGACY_AI_ARTIFACTS: &[&str] = &[
50 ".joy/ai/instructions.md",
51 ".joy/ai/instructions",
52 ".joy/ai/skills",
53 ".joy/capabilities",
54];
55
56pub struct InitOptions {
57 pub root: PathBuf,
58 pub name: Option<String>,
59 pub acronym: Option<String>,
60 pub user: Option<String>,
62 pub language: Option<String>,
64}
65
66#[derive(Debug)]
67pub struct InitResult {
68 pub project_dir: PathBuf,
69 pub git_initialized: bool,
70 pub git_existed: bool,
71}
72
73pub struct OnboardResult {
74 pub hooks_installed: bool,
75 pub hooks_already_set: bool,
76}
77
78pub fn init(options: InitOptions) -> Result<InitResult, JoyError> {
79 let root = &options.root;
80 let joy_dir = store::joy_dir(root);
81
82 if store::is_initialized(root) {
83 return Err(JoyError::AlreadyInitialized(joy_dir));
84 }
85
86 let vcs = default_vcs();
88 let git_existed = vcs.is_repo(root);
89 let mut git_initialized = false;
90 if !git_existed {
91 vcs.init_repo(root)?;
92 git_initialized = true;
93 }
94
95 let dirs = [
97 store::ITEMS_DIR,
98 store::MILESTONES_DIR,
99 store::RELEASES_DIR,
100 store::AI_AGENTS_DIR,
101 store::AI_JOBS_DIR,
102 store::LOG_DIR,
103 ];
104 for dir in &dirs {
105 let path = joy_dir.join(dir);
106 std::fs::create_dir_all(&path).map_err(|e| JoyError::CreateDir {
107 path: path.clone(),
108 source: e,
109 })?;
110 }
111
112 let name = options.name.unwrap_or_else(|| {
114 root.file_name()
115 .and_then(|n| n.to_str())
116 .unwrap_or("project")
117 .to_string()
118 });
119 let acronym = options.acronym.unwrap_or_else(|| derive_acronym(&name));
120
121 embedded::sync_files(root, CONFIG_FILES)?;
123 embedded::sync_files(root, PROJECT_FILES)?;
124
125 let mut project = Project::new(name, Some(acronym));
126 if let Some(lang) = options.language.filter(|s| !s.is_empty()) {
127 project.language = lang;
128 }
129
130 let creator_email = options
133 .user
134 .filter(|s| !s.is_empty())
135 .or_else(|| vcs.user_email().ok().filter(|s| !s.is_empty()));
136 if let Some(email) = creator_email {
137 project.members.insert(
138 email,
139 crate::model::project::Member::new(crate::model::project::MemberCapabilities::All),
140 );
141 }
142
143 store::write_yaml(&joy_dir.join(store::PROJECT_FILE), &project)?;
144 let project_rel = format!("{}/{}", store::JOY_DIR, store::PROJECT_FILE);
145 let defaults_rel = format!("{}/{}", store::JOY_DIR, store::CONFIG_DEFAULTS_FILE);
146 crate::git_ops::auto_git_add(root, &[&project_rel, &defaults_rel]);
147
148 ensure_gitignore(root)?;
150
151 ensure_gitattributes(root)?;
153 register_merge_driver(root)?;
154
155 install_hooks(root)?;
157
158 let _ = set_last_sync_version(root, env!("CARGO_PKG_VERSION"));
163
164 Ok(InitResult {
165 project_dir: joy_dir,
166 git_initialized,
167 git_existed,
168 })
169}
170
171pub fn onboard(root: &Path) -> Result<OnboardResult, JoyError> {
173 embedded::sync_files(root, CONFIG_FILES)?;
174 embedded::sync_files(root, PROJECT_FILES)?;
175 ensure_gitignore(root)?;
176 ensure_gitattributes(root)?;
177 register_merge_driver(root)?;
178 let result = install_hooks(root)?;
179 let _ = set_last_sync_version(root, env!("CARGO_PKG_VERSION"));
182 Ok(result)
183}
184
185fn install_hooks(root: &Path) -> Result<OnboardResult, JoyError> {
187 let actions = embedded::sync_files(root, HOOK_FILES)?;
188 let hooks_installed = actions.iter().any(|a| a.action != "up to date");
189
190 let vcs = default_vcs();
192 let current = vcs.config_get(root, "core.hooksPath").unwrap_or_default();
193 let already_set = current == ".joy/hooks";
194
195 if !already_set {
196 vcs.config_set(root, "core.hooksPath", ".joy/hooks")?;
197 }
198
199 Ok(OnboardResult {
200 hooks_installed,
201 hooks_already_set: already_set,
202 })
203}
204
205pub const GITIGNORE_BLOCK_START: &str = "### joy:start -- managed by joy, do not edit manually";
206pub const GITIGNORE_BLOCK_END: &str = "### joy:end";
207
208pub const GITIGNORE_BASE_ENTRIES: &[(&str, &str)] = &[
209 (".joy/config.yaml", "personal config"),
210 (".joy/credentials.yaml", "secrets"),
211 (".joy/hooks/", "git hooks"),
212 (".joy/project.defaults.yaml", "embedded project defaults"),
213];
214
215pub fn update_gitignore_block(root: &Path, entries: &[(&str, &str)]) -> Result<(), JoyError> {
218 let gitignore_path = root.join(".gitignore");
219
220 let mut lines = String::new();
221 for (path, _comment) in entries {
222 lines.push_str(path);
223 lines.push('\n');
224 }
225 let block = format!(
226 "{}\n{}{}",
227 GITIGNORE_BLOCK_START, lines, GITIGNORE_BLOCK_END
228 );
229
230 let content = if gitignore_path.is_file() {
231 let existing =
232 std::fs::read_to_string(&gitignore_path).map_err(|e| JoyError::ReadFile {
233 path: gitignore_path.clone(),
234 source: e,
235 })?;
236 if existing.contains(GITIGNORE_BLOCK_START) && existing.contains(GITIGNORE_BLOCK_END) {
237 let start = existing.find(GITIGNORE_BLOCK_START).unwrap();
238 let end = existing.find(GITIGNORE_BLOCK_END).unwrap() + GITIGNORE_BLOCK_END.len();
239 let mut updated = String::new();
240 updated.push_str(&existing[..start]);
241 updated.push_str(&block);
242 updated.push_str(&existing[end..]);
243 updated
244 } else {
245 let trimmed = existing.trim_end();
246 if trimmed.is_empty() {
247 format!("{}\n", block)
248 } else {
249 format!("{}\n\n{}\n", trimmed, block)
250 }
251 }
252 } else {
253 format!("{}\n", block)
254 };
255
256 if gitignore_path.is_file() {
258 if let Ok(existing) = std::fs::read_to_string(&gitignore_path) {
259 if existing == content {
260 return Ok(());
261 }
262 }
263 }
264
265 std::fs::write(&gitignore_path, &content).map_err(|e| JoyError::WriteFile {
266 path: gitignore_path,
267 source: e,
268 })?;
269 crate::git_ops::auto_git_add(root, &[".gitignore"]);
270 Ok(())
271}
272
273fn ensure_gitignore(root: &Path) -> Result<(), JoyError> {
274 update_gitignore_block(root, GITIGNORE_BASE_ENTRIES)
275}
276
277pub const GITATTRIBUTES_BLOCK_START: &str = "### joy:start -- managed by joy, do not edit manually";
278pub const GITATTRIBUTES_BLOCK_END: &str = "### joy:end";
279
280pub const GITATTRIBUTES_BASE_ENTRIES: &[&str] = &[
286 ".joy/items/*.yaml merge=joy-yaml",
287 ".joy/milestones/*.yaml merge=joy-yaml",
288 ".joy/releases/*.yaml merge=joy-yaml",
289 ".joy/ai/agents/*.yaml merge=joy-yaml",
290 ".joy/ai/jobs/*.yaml merge=joy-yaml",
291 ".joy/project.yaml merge=joy-yaml",
292 ".joy/config.defaults.yaml merge=joy-yaml",
293 ".joy/logs/*.log merge=union",
294];
295
296pub const MERGE_DRIVER_NAME_KEY: &str = "merge.joy-yaml.name";
297pub const MERGE_DRIVER_NAME_VALUE: &str = "Joy YAML merge driver";
298pub const MERGE_DRIVER_CMD_KEY: &str = "merge.joy-yaml.driver";
299pub const MERGE_DRIVER_CMD_VALUE: &str =
300 "joy merge driver --base %O --current %A --other %B --path %P --ours-rev %X --theirs-rev %Y";
301
302pub fn update_gitattributes_block(root: &Path, lines: &[&str]) -> Result<(), JoyError> {
305 let path = root.join(".gitattributes");
306
307 let mut joined = String::new();
308 for line in lines {
309 joined.push_str(line);
310 joined.push('\n');
311 }
312 let block = format!(
313 "{}\n{}{}",
314 GITATTRIBUTES_BLOCK_START, joined, GITATTRIBUTES_BLOCK_END
315 );
316
317 let content = if path.is_file() {
318 let existing = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
319 path: path.clone(),
320 source: e,
321 })?;
322 if existing.contains(GITATTRIBUTES_BLOCK_START)
323 && existing.contains(GITATTRIBUTES_BLOCK_END)
324 {
325 let start = existing.find(GITATTRIBUTES_BLOCK_START).unwrap();
326 let end =
327 existing.find(GITATTRIBUTES_BLOCK_END).unwrap() + GITATTRIBUTES_BLOCK_END.len();
328 let mut updated = String::new();
329 updated.push_str(&existing[..start]);
330 updated.push_str(&block);
331 updated.push_str(&existing[end..]);
332 updated
333 } else {
334 let trimmed = existing.trim_end();
335 if trimmed.is_empty() {
336 format!("{}\n", block)
337 } else {
338 format!("{}\n\n{}\n", trimmed, block)
339 }
340 }
341 } else {
342 format!("{}\n", block)
343 };
344
345 if path.is_file() {
349 if let Ok(existing) = std::fs::read_to_string(&path) {
350 if existing == content {
351 return Ok(());
352 }
353 }
354 }
355
356 std::fs::write(&path, &content).map_err(|e| JoyError::WriteFile { path, source: e })?;
357 crate::git_ops::auto_git_add(root, &[".gitattributes"]);
358 Ok(())
359}
360
361fn ensure_gitattributes(root: &Path) -> Result<(), JoyError> {
362 update_gitattributes_block(root, GITATTRIBUTES_BASE_ENTRIES)
363}
364
365pub fn ensure_lazy_activation(root: &Path) -> Result<(), JoyError> {
375 let vcs = default_vcs();
376 if !vcs.is_repo(root) {
377 return Ok(());
378 }
379 ensure_gitattributes(root)?;
380 register_merge_driver(root)?;
381 Ok(())
382}
383
384pub const LAST_SYNC_VERSION_KEY: &str = "joy.last-sync-version";
388
389pub fn last_sync_version(root: &Path) -> Option<String> {
396 let vcs = default_vcs();
397 if !vcs.is_repo(root) {
398 return None;
399 }
400 vcs.config_get(root, LAST_SYNC_VERSION_KEY).ok()
401}
402
403pub fn set_last_sync_version(root: &Path, version: &str) -> Result<(), JoyError> {
405 let vcs = default_vcs();
406 if !vcs.is_repo(root) {
407 return Ok(());
408 }
409 vcs.config_set(root, LAST_SYNC_VERSION_KEY, version)
410}
411
412pub fn run_sync(root: &Path, current_version: &str) -> Result<(), JoyError> {
417 ensure_lazy_activation(root)?;
418 set_last_sync_version(root, current_version)
419}
420
421fn register_merge_driver(root: &Path) -> Result<(), JoyError> {
426 let vcs = default_vcs();
427 if !vcs.is_repo(root) {
428 return Ok(());
429 }
430 if vcs.config_get(root, MERGE_DRIVER_NAME_KEY).ok().as_deref() != Some(MERGE_DRIVER_NAME_VALUE)
431 {
432 vcs.config_set(root, MERGE_DRIVER_NAME_KEY, MERGE_DRIVER_NAME_VALUE)?;
433 }
434 if vcs.config_get(root, MERGE_DRIVER_CMD_KEY).ok().as_deref() != Some(MERGE_DRIVER_CMD_VALUE) {
435 vcs.config_set(root, MERGE_DRIVER_CMD_KEY, MERGE_DRIVER_CMD_VALUE)?;
436 }
437 Ok(())
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443 use tempfile::tempdir;
444
445 #[test]
446 fn init_creates_directory_structure() {
447 let dir = tempdir().unwrap();
448 let result = init(InitOptions {
449 root: dir.path().to_path_buf(),
450 name: Some("Test Project".into()),
451 acronym: Some("TP".into()),
452 user: None,
453 language: None,
454 })
455 .unwrap();
456
457 assert!(result.project_dir.join("items").is_dir());
458 assert!(result.project_dir.join("milestones").is_dir());
459 assert!(result.project_dir.join("ai/agents").is_dir());
460 assert!(result.project_dir.join("ai/jobs").is_dir());
461 assert!(result.project_dir.join("logs").is_dir());
462 assert!(result.project_dir.join("config.defaults.yaml").is_file());
463 assert!(result.project_dir.join("project.yaml").is_file());
464 }
465
466 #[test]
467 fn init_writes_project_metadata() {
468 let dir = tempdir().unwrap();
469 init(InitOptions {
470 root: dir.path().to_path_buf(),
471 name: Some("My App".into()),
472 acronym: Some("MA".into()),
473 user: None,
474 language: None,
475 })
476 .unwrap();
477
478 let project: Project =
479 store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
480 assert_eq!(project.name, "My App");
481 assert_eq!(project.acronym.as_deref(), Some("MA"));
482 }
483
484 #[test]
485 fn init_derives_name_from_directory() {
486 let dir = tempdir().unwrap();
487 init(InitOptions {
488 root: dir.path().to_path_buf(),
489 name: None,
490 acronym: None,
491 user: None,
492 language: None,
493 })
494 .unwrap();
495
496 let project: Project =
497 store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
498 assert!(!project.name.is_empty());
500 assert!(project.acronym.is_some());
501 }
502
503 #[test]
504 fn init_fails_if_already_initialized() {
505 let dir = tempdir().unwrap();
506 init(InitOptions {
507 root: dir.path().to_path_buf(),
508 name: Some("Test".into()),
509 acronym: None,
510 user: None,
511 language: None,
512 })
513 .unwrap();
514
515 let err = init(InitOptions {
516 root: dir.path().to_path_buf(),
517 name: Some("Test".into()),
518 acronym: None,
519 user: None,
520 language: None,
521 })
522 .unwrap_err();
523
524 assert!(matches!(err, JoyError::AlreadyInitialized(_)));
525 }
526
527 #[test]
528 fn init_creates_gitignore_with_credentials_entry() {
529 let dir = tempdir().unwrap();
530 init(InitOptions {
531 root: dir.path().to_path_buf(),
532 name: Some("Test".into()),
533 acronym: None,
534 user: None,
535 language: None,
536 })
537 .unwrap();
538
539 let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
540 assert!(content.contains(".joy/credentials.yaml"));
541 assert!(content.contains(".joy/config.yaml"));
542 }
543
544 #[test]
545 fn init_does_not_duplicate_gitignore_block() {
546 let dir = tempdir().unwrap();
547 init(InitOptions {
549 root: dir.path().to_path_buf(),
550 name: Some("Test".into()),
551 acronym: None,
552 user: None,
553 language: None,
554 })
555 .unwrap();
556 let first = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
557
558 super::ensure_gitignore(dir.path()).unwrap();
560 let second = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
561
562 assert_eq!(first, second);
563 assert_eq!(second.matches(GITIGNORE_BLOCK_START).count(), 1);
564 }
565
566 #[test]
567 fn init_writes_gitattributes_block_with_joy_yaml_and_union_log() {
568 let dir = tempdir().unwrap();
569 init(InitOptions {
570 root: dir.path().to_path_buf(),
571 name: Some("Test".into()),
572 acronym: None,
573 user: None,
574 language: None,
575 })
576 .unwrap();
577
578 let content = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
579 assert!(content.contains(GITATTRIBUTES_BLOCK_START));
580 assert!(content.contains(GITATTRIBUTES_BLOCK_END));
581 assert!(content.contains(".joy/items/*.yaml merge=joy-yaml"));
582 assert!(content.contains(".joy/milestones/*.yaml merge=joy-yaml"));
583 assert!(content.contains(".joy/releases/*.yaml merge=joy-yaml"));
584 assert!(content.contains(".joy/ai/agents/*.yaml merge=joy-yaml"));
585 assert!(content.contains(".joy/ai/jobs/*.yaml merge=joy-yaml"));
586 assert!(content.contains(".joy/project.yaml merge=joy-yaml"));
587 assert!(content.contains(".joy/config.defaults.yaml merge=joy-yaml"));
588 assert!(content.contains(".joy/logs/*.log merge=union"));
589 }
590
591 #[test]
592 fn init_does_not_duplicate_gitattributes_block() {
593 let dir = tempdir().unwrap();
594 init(InitOptions {
595 root: dir.path().to_path_buf(),
596 name: Some("Test".into()),
597 acronym: None,
598 user: None,
599 language: None,
600 })
601 .unwrap();
602 let first = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
603
604 super::ensure_gitattributes(dir.path()).unwrap();
605 let second = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
606
607 assert_eq!(first, second);
608 assert_eq!(second.matches(GITATTRIBUTES_BLOCK_START).count(), 1);
609 }
610
611 #[test]
612 fn init_registers_merge_driver_in_git_config() {
613 let dir = tempdir().unwrap();
614 init(InitOptions {
615 root: dir.path().to_path_buf(),
616 name: Some("Test".into()),
617 acronym: None,
618 user: None,
619 language: None,
620 })
621 .unwrap();
622
623 let vcs = default_vcs();
624 let name = vcs.config_get(dir.path(), MERGE_DRIVER_NAME_KEY).unwrap();
625 let cmd = vcs.config_get(dir.path(), MERGE_DRIVER_CMD_KEY).unwrap();
626 assert_eq!(name, MERGE_DRIVER_NAME_VALUE);
627 assert_eq!(cmd, MERGE_DRIVER_CMD_VALUE);
628 assert!(cmd.contains("--ours-rev %X"));
629 assert!(cmd.contains("--theirs-rev %Y"));
630 }
631
632 #[test]
633 fn init_initializes_git_if_needed() {
634 let dir = tempdir().unwrap();
635 let result = init(InitOptions {
636 root: dir.path().to_path_buf(),
637 name: Some("Test".into()),
638 acronym: None,
639 user: None,
640 language: None,
641 })
642 .unwrap();
643
644 assert!(result.git_initialized);
645 assert!(!result.git_existed);
646 assert!(dir.path().join(".git").is_dir());
647 }
648}