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