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 Ok(InitResult {
133 project_dir: joy_dir,
134 git_initialized,
135 git_existed,
136 })
137}
138
139pub fn onboard(root: &Path) -> Result<OnboardResult, JoyError> {
141 embedded::sync_files(root, CONFIG_FILES)?;
142 embedded::sync_files(root, PROJECT_FILES)?;
143 ensure_gitattributes(root)?;
144 register_merge_driver(root)?;
145 install_hooks(root)
146}
147
148fn install_hooks(root: &Path) -> Result<OnboardResult, JoyError> {
150 let actions = embedded::sync_files(root, HOOK_FILES)?;
151 let hooks_installed = actions.iter().any(|a| a.action != "up to date");
152
153 let vcs = default_vcs();
155 let current = vcs.config_get(root, "core.hooksPath").unwrap_or_default();
156 let already_set = current == ".joy/hooks";
157
158 if !already_set {
159 vcs.config_set(root, "core.hooksPath", ".joy/hooks")?;
160 }
161
162 Ok(OnboardResult {
163 hooks_installed,
164 hooks_already_set: already_set,
165 })
166}
167
168pub const GITIGNORE_BLOCK_START: &str = "### joy:start -- managed by joy, do not edit manually";
169pub const GITIGNORE_BLOCK_END: &str = "### joy:end";
170
171pub const GITIGNORE_BASE_ENTRIES: &[(&str, &str)] = &[
172 (".joy/config.yaml", "personal config"),
173 (".joy/credentials.yaml", "secrets"),
174 (".joy/hooks/", "git hooks"),
175 (".joy/project.defaults.yaml", "embedded project defaults"),
176];
177
178pub fn update_gitignore_block(root: &Path, entries: &[(&str, &str)]) -> Result<(), JoyError> {
181 let gitignore_path = root.join(".gitignore");
182
183 let mut lines = String::new();
184 for (path, _comment) in entries {
185 lines.push_str(path);
186 lines.push('\n');
187 }
188 let block = format!(
189 "{}\n{}{}",
190 GITIGNORE_BLOCK_START, lines, GITIGNORE_BLOCK_END
191 );
192
193 let content = if gitignore_path.is_file() {
194 let existing =
195 std::fs::read_to_string(&gitignore_path).map_err(|e| JoyError::ReadFile {
196 path: gitignore_path.clone(),
197 source: e,
198 })?;
199 if existing.contains(GITIGNORE_BLOCK_START) && existing.contains(GITIGNORE_BLOCK_END) {
200 let start = existing.find(GITIGNORE_BLOCK_START).unwrap();
201 let end = existing.find(GITIGNORE_BLOCK_END).unwrap() + GITIGNORE_BLOCK_END.len();
202 let mut updated = String::new();
203 updated.push_str(&existing[..start]);
204 updated.push_str(&block);
205 updated.push_str(&existing[end..]);
206 updated
207 } else {
208 let trimmed = existing.trim_end();
209 if trimmed.is_empty() {
210 format!("{}\n", block)
211 } else {
212 format!("{}\n\n{}\n", trimmed, block)
213 }
214 }
215 } else {
216 format!("{}\n", block)
217 };
218
219 std::fs::write(&gitignore_path, &content).map_err(|e| JoyError::WriteFile {
220 path: gitignore_path,
221 source: e,
222 })?;
223 crate::git_ops::auto_git_add(root, &[".gitignore"]);
224 Ok(())
225}
226
227fn ensure_gitignore(root: &Path) -> Result<(), JoyError> {
228 update_gitignore_block(root, GITIGNORE_BASE_ENTRIES)
229}
230
231pub const GITATTRIBUTES_BLOCK_START: &str = "### joy:start -- managed by joy, do not edit manually";
232pub const GITATTRIBUTES_BLOCK_END: &str = "### joy:end";
233
234pub const GITATTRIBUTES_BASE_ENTRIES: &[&str] = &[
240 ".joy/items/*.yaml merge=joy-yaml",
241 ".joy/milestones/*.yaml merge=joy-yaml",
242 ".joy/releases/*.yaml merge=joy-yaml",
243 ".joy/ai/agents/*.yaml merge=joy-yaml",
244 ".joy/ai/jobs/*.yaml merge=joy-yaml",
245 ".joy/project.yaml merge=joy-yaml",
246 ".joy/config.defaults.yaml merge=joy-yaml",
247 ".joy/logs/*.log merge=union",
248];
249
250pub const MERGE_DRIVER_NAME_KEY: &str = "merge.joy-yaml.name";
251pub const MERGE_DRIVER_NAME_VALUE: &str = "Joy YAML merge driver";
252pub const MERGE_DRIVER_CMD_KEY: &str = "merge.joy-yaml.driver";
253pub const MERGE_DRIVER_CMD_VALUE: &str =
254 "joy merge driver --base %O --current %A --other %B --path %P --ours-rev %X --theirs-rev %Y";
255
256pub fn update_gitattributes_block(root: &Path, lines: &[&str]) -> Result<(), JoyError> {
259 let path = root.join(".gitattributes");
260
261 let mut joined = String::new();
262 for line in lines {
263 joined.push_str(line);
264 joined.push('\n');
265 }
266 let block = format!(
267 "{}\n{}{}",
268 GITATTRIBUTES_BLOCK_START, joined, GITATTRIBUTES_BLOCK_END
269 );
270
271 let content = if path.is_file() {
272 let existing = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
273 path: path.clone(),
274 source: e,
275 })?;
276 if existing.contains(GITATTRIBUTES_BLOCK_START)
277 && existing.contains(GITATTRIBUTES_BLOCK_END)
278 {
279 let start = existing.find(GITATTRIBUTES_BLOCK_START).unwrap();
280 let end =
281 existing.find(GITATTRIBUTES_BLOCK_END).unwrap() + GITATTRIBUTES_BLOCK_END.len();
282 let mut updated = String::new();
283 updated.push_str(&existing[..start]);
284 updated.push_str(&block);
285 updated.push_str(&existing[end..]);
286 updated
287 } else {
288 let trimmed = existing.trim_end();
289 if trimmed.is_empty() {
290 format!("{}\n", block)
291 } else {
292 format!("{}\n\n{}\n", trimmed, block)
293 }
294 }
295 } else {
296 format!("{}\n", block)
297 };
298
299 std::fs::write(&path, &content).map_err(|e| JoyError::WriteFile { path, source: e })?;
300 crate::git_ops::auto_git_add(root, &[".gitattributes"]);
301 Ok(())
302}
303
304fn ensure_gitattributes(root: &Path) -> Result<(), JoyError> {
305 update_gitattributes_block(root, GITATTRIBUTES_BASE_ENTRIES)
306}
307
308fn register_merge_driver(root: &Path) -> Result<(), JoyError> {
313 let vcs = default_vcs();
314 if !vcs.is_repo(root) {
315 return Ok(());
316 }
317 if vcs.config_get(root, MERGE_DRIVER_NAME_KEY).ok().as_deref() != Some(MERGE_DRIVER_NAME_VALUE)
318 {
319 vcs.config_set(root, MERGE_DRIVER_NAME_KEY, MERGE_DRIVER_NAME_VALUE)?;
320 }
321 if vcs.config_get(root, MERGE_DRIVER_CMD_KEY).ok().as_deref() != Some(MERGE_DRIVER_CMD_VALUE) {
322 vcs.config_set(root, MERGE_DRIVER_CMD_KEY, MERGE_DRIVER_CMD_VALUE)?;
323 }
324 Ok(())
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use tempfile::tempdir;
331
332 #[test]
333 fn init_creates_directory_structure() {
334 let dir = tempdir().unwrap();
335 let result = init(InitOptions {
336 root: dir.path().to_path_buf(),
337 name: Some("Test Project".into()),
338 acronym: Some("TP".into()),
339 user: None,
340 language: None,
341 })
342 .unwrap();
343
344 assert!(result.project_dir.join("items").is_dir());
345 assert!(result.project_dir.join("milestones").is_dir());
346 assert!(result.project_dir.join("ai/agents").is_dir());
347 assert!(result.project_dir.join("ai/jobs").is_dir());
348 assert!(result.project_dir.join("logs").is_dir());
349 assert!(result.project_dir.join("config.defaults.yaml").is_file());
350 assert!(result.project_dir.join("project.yaml").is_file());
351 }
352
353 #[test]
354 fn init_writes_project_metadata() {
355 let dir = tempdir().unwrap();
356 init(InitOptions {
357 root: dir.path().to_path_buf(),
358 name: Some("My App".into()),
359 acronym: Some("MA".into()),
360 user: None,
361 language: None,
362 })
363 .unwrap();
364
365 let project: Project =
366 store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
367 assert_eq!(project.name, "My App");
368 assert_eq!(project.acronym.as_deref(), Some("MA"));
369 }
370
371 #[test]
372 fn init_derives_name_from_directory() {
373 let dir = tempdir().unwrap();
374 init(InitOptions {
375 root: dir.path().to_path_buf(),
376 name: None,
377 acronym: None,
378 user: None,
379 language: None,
380 })
381 .unwrap();
382
383 let project: Project =
384 store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
385 assert!(!project.name.is_empty());
387 assert!(project.acronym.is_some());
388 }
389
390 #[test]
391 fn init_fails_if_already_initialized() {
392 let dir = tempdir().unwrap();
393 init(InitOptions {
394 root: dir.path().to_path_buf(),
395 name: Some("Test".into()),
396 acronym: None,
397 user: None,
398 language: None,
399 })
400 .unwrap();
401
402 let err = init(InitOptions {
403 root: dir.path().to_path_buf(),
404 name: Some("Test".into()),
405 acronym: None,
406 user: None,
407 language: None,
408 })
409 .unwrap_err();
410
411 assert!(matches!(err, JoyError::AlreadyInitialized(_)));
412 }
413
414 #[test]
415 fn init_creates_gitignore_with_credentials_entry() {
416 let dir = tempdir().unwrap();
417 init(InitOptions {
418 root: dir.path().to_path_buf(),
419 name: Some("Test".into()),
420 acronym: None,
421 user: None,
422 language: None,
423 })
424 .unwrap();
425
426 let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
427 assert!(content.contains(".joy/credentials.yaml"));
428 assert!(content.contains(".joy/config.yaml"));
429 }
430
431 #[test]
432 fn init_does_not_duplicate_gitignore_block() {
433 let dir = tempdir().unwrap();
434 init(InitOptions {
436 root: dir.path().to_path_buf(),
437 name: Some("Test".into()),
438 acronym: None,
439 user: None,
440 language: None,
441 })
442 .unwrap();
443 let first = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
444
445 super::ensure_gitignore(dir.path()).unwrap();
447 let second = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
448
449 assert_eq!(first, second);
450 assert_eq!(second.matches(GITIGNORE_BLOCK_START).count(), 1);
451 }
452
453 #[test]
454 fn init_writes_gitattributes_block_with_joy_yaml_and_union_log() {
455 let dir = tempdir().unwrap();
456 init(InitOptions {
457 root: dir.path().to_path_buf(),
458 name: Some("Test".into()),
459 acronym: None,
460 user: None,
461 language: None,
462 })
463 .unwrap();
464
465 let content = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
466 assert!(content.contains(GITATTRIBUTES_BLOCK_START));
467 assert!(content.contains(GITATTRIBUTES_BLOCK_END));
468 assert!(content.contains(".joy/items/*.yaml merge=joy-yaml"));
469 assert!(content.contains(".joy/milestones/*.yaml merge=joy-yaml"));
470 assert!(content.contains(".joy/releases/*.yaml merge=joy-yaml"));
471 assert!(content.contains(".joy/ai/agents/*.yaml merge=joy-yaml"));
472 assert!(content.contains(".joy/ai/jobs/*.yaml merge=joy-yaml"));
473 assert!(content.contains(".joy/project.yaml merge=joy-yaml"));
474 assert!(content.contains(".joy/config.defaults.yaml merge=joy-yaml"));
475 assert!(content.contains(".joy/logs/*.log merge=union"));
476 }
477
478 #[test]
479 fn init_does_not_duplicate_gitattributes_block() {
480 let dir = tempdir().unwrap();
481 init(InitOptions {
482 root: dir.path().to_path_buf(),
483 name: Some("Test".into()),
484 acronym: None,
485 user: None,
486 language: None,
487 })
488 .unwrap();
489 let first = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
490
491 super::ensure_gitattributes(dir.path()).unwrap();
492 let second = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
493
494 assert_eq!(first, second);
495 assert_eq!(second.matches(GITATTRIBUTES_BLOCK_START).count(), 1);
496 }
497
498 #[test]
499 fn init_registers_merge_driver_in_git_config() {
500 let dir = tempdir().unwrap();
501 init(InitOptions {
502 root: dir.path().to_path_buf(),
503 name: Some("Test".into()),
504 acronym: None,
505 user: None,
506 language: None,
507 })
508 .unwrap();
509
510 let vcs = default_vcs();
511 let name = vcs.config_get(dir.path(), MERGE_DRIVER_NAME_KEY).unwrap();
512 let cmd = vcs.config_get(dir.path(), MERGE_DRIVER_CMD_KEY).unwrap();
513 assert_eq!(name, MERGE_DRIVER_NAME_VALUE);
514 assert_eq!(cmd, MERGE_DRIVER_CMD_VALUE);
515 assert!(cmd.contains("--ours-rev %X"));
516 assert!(cmd.contains("--theirs-rev %Y"));
517 }
518
519 #[test]
520 fn init_initializes_git_if_needed() {
521 let dir = tempdir().unwrap();
522 let result = 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
531 assert!(result.git_initialized);
532 assert!(!result.git_existed);
533 assert!(dir.path().join(".git").is_dir());
534 }
535}