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 if path.is_file() {
303 if let Ok(existing) = std::fs::read_to_string(&path) {
304 if existing == content {
305 return Ok(());
306 }
307 }
308 }
309
310 std::fs::write(&path, &content).map_err(|e| JoyError::WriteFile { path, source: e })?;
311 crate::git_ops::auto_git_add(root, &[".gitattributes"]);
312 Ok(())
313}
314
315fn ensure_gitattributes(root: &Path) -> Result<(), JoyError> {
316 update_gitattributes_block(root, GITATTRIBUTES_BASE_ENTRIES)
317}
318
319pub fn ensure_lazy_activation(root: &Path) -> Result<(), JoyError> {
329 let vcs = default_vcs();
330 if !vcs.is_repo(root) {
331 return Ok(());
332 }
333 ensure_gitattributes(root)?;
334 register_merge_driver(root)?;
335 Ok(())
336}
337
338fn register_merge_driver(root: &Path) -> Result<(), JoyError> {
343 let vcs = default_vcs();
344 if !vcs.is_repo(root) {
345 return Ok(());
346 }
347 if vcs.config_get(root, MERGE_DRIVER_NAME_KEY).ok().as_deref() != Some(MERGE_DRIVER_NAME_VALUE)
348 {
349 vcs.config_set(root, MERGE_DRIVER_NAME_KEY, MERGE_DRIVER_NAME_VALUE)?;
350 }
351 if vcs.config_get(root, MERGE_DRIVER_CMD_KEY).ok().as_deref() != Some(MERGE_DRIVER_CMD_VALUE) {
352 vcs.config_set(root, MERGE_DRIVER_CMD_KEY, MERGE_DRIVER_CMD_VALUE)?;
353 }
354 Ok(())
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use tempfile::tempdir;
361
362 #[test]
363 fn init_creates_directory_structure() {
364 let dir = tempdir().unwrap();
365 let result = init(InitOptions {
366 root: dir.path().to_path_buf(),
367 name: Some("Test Project".into()),
368 acronym: Some("TP".into()),
369 user: None,
370 language: None,
371 })
372 .unwrap();
373
374 assert!(result.project_dir.join("items").is_dir());
375 assert!(result.project_dir.join("milestones").is_dir());
376 assert!(result.project_dir.join("ai/agents").is_dir());
377 assert!(result.project_dir.join("ai/jobs").is_dir());
378 assert!(result.project_dir.join("logs").is_dir());
379 assert!(result.project_dir.join("config.defaults.yaml").is_file());
380 assert!(result.project_dir.join("project.yaml").is_file());
381 }
382
383 #[test]
384 fn init_writes_project_metadata() {
385 let dir = tempdir().unwrap();
386 init(InitOptions {
387 root: dir.path().to_path_buf(),
388 name: Some("My App".into()),
389 acronym: Some("MA".into()),
390 user: None,
391 language: None,
392 })
393 .unwrap();
394
395 let project: Project =
396 store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
397 assert_eq!(project.name, "My App");
398 assert_eq!(project.acronym.as_deref(), Some("MA"));
399 }
400
401 #[test]
402 fn init_derives_name_from_directory() {
403 let dir = tempdir().unwrap();
404 init(InitOptions {
405 root: dir.path().to_path_buf(),
406 name: None,
407 acronym: None,
408 user: None,
409 language: None,
410 })
411 .unwrap();
412
413 let project: Project =
414 store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
415 assert!(!project.name.is_empty());
417 assert!(project.acronym.is_some());
418 }
419
420 #[test]
421 fn init_fails_if_already_initialized() {
422 let dir = tempdir().unwrap();
423 init(InitOptions {
424 root: dir.path().to_path_buf(),
425 name: Some("Test".into()),
426 acronym: None,
427 user: None,
428 language: None,
429 })
430 .unwrap();
431
432 let err = init(InitOptions {
433 root: dir.path().to_path_buf(),
434 name: Some("Test".into()),
435 acronym: None,
436 user: None,
437 language: None,
438 })
439 .unwrap_err();
440
441 assert!(matches!(err, JoyError::AlreadyInitialized(_)));
442 }
443
444 #[test]
445 fn init_creates_gitignore_with_credentials_entry() {
446 let dir = tempdir().unwrap();
447 init(InitOptions {
448 root: dir.path().to_path_buf(),
449 name: Some("Test".into()),
450 acronym: None,
451 user: None,
452 language: None,
453 })
454 .unwrap();
455
456 let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
457 assert!(content.contains(".joy/credentials.yaml"));
458 assert!(content.contains(".joy/config.yaml"));
459 }
460
461 #[test]
462 fn init_does_not_duplicate_gitignore_block() {
463 let dir = tempdir().unwrap();
464 init(InitOptions {
466 root: dir.path().to_path_buf(),
467 name: Some("Test".into()),
468 acronym: None,
469 user: None,
470 language: None,
471 })
472 .unwrap();
473 let first = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
474
475 super::ensure_gitignore(dir.path()).unwrap();
477 let second = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
478
479 assert_eq!(first, second);
480 assert_eq!(second.matches(GITIGNORE_BLOCK_START).count(), 1);
481 }
482
483 #[test]
484 fn init_writes_gitattributes_block_with_joy_yaml_and_union_log() {
485 let dir = tempdir().unwrap();
486 init(InitOptions {
487 root: dir.path().to_path_buf(),
488 name: Some("Test".into()),
489 acronym: None,
490 user: None,
491 language: None,
492 })
493 .unwrap();
494
495 let content = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
496 assert!(content.contains(GITATTRIBUTES_BLOCK_START));
497 assert!(content.contains(GITATTRIBUTES_BLOCK_END));
498 assert!(content.contains(".joy/items/*.yaml merge=joy-yaml"));
499 assert!(content.contains(".joy/milestones/*.yaml merge=joy-yaml"));
500 assert!(content.contains(".joy/releases/*.yaml merge=joy-yaml"));
501 assert!(content.contains(".joy/ai/agents/*.yaml merge=joy-yaml"));
502 assert!(content.contains(".joy/ai/jobs/*.yaml merge=joy-yaml"));
503 assert!(content.contains(".joy/project.yaml merge=joy-yaml"));
504 assert!(content.contains(".joy/config.defaults.yaml merge=joy-yaml"));
505 assert!(content.contains(".joy/logs/*.log merge=union"));
506 }
507
508 #[test]
509 fn init_does_not_duplicate_gitattributes_block() {
510 let dir = tempdir().unwrap();
511 init(InitOptions {
512 root: dir.path().to_path_buf(),
513 name: Some("Test".into()),
514 acronym: None,
515 user: None,
516 language: None,
517 })
518 .unwrap();
519 let first = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
520
521 super::ensure_gitattributes(dir.path()).unwrap();
522 let second = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
523
524 assert_eq!(first, second);
525 assert_eq!(second.matches(GITATTRIBUTES_BLOCK_START).count(), 1);
526 }
527
528 #[test]
529 fn init_registers_merge_driver_in_git_config() {
530 let dir = tempdir().unwrap();
531 init(InitOptions {
532 root: dir.path().to_path_buf(),
533 name: Some("Test".into()),
534 acronym: None,
535 user: None,
536 language: None,
537 })
538 .unwrap();
539
540 let vcs = default_vcs();
541 let name = vcs.config_get(dir.path(), MERGE_DRIVER_NAME_KEY).unwrap();
542 let cmd = vcs.config_get(dir.path(), MERGE_DRIVER_CMD_KEY).unwrap();
543 assert_eq!(name, MERGE_DRIVER_NAME_VALUE);
544 assert_eq!(cmd, MERGE_DRIVER_CMD_VALUE);
545 assert!(cmd.contains("--ours-rev %X"));
546 assert!(cmd.contains("--theirs-rev %Y"));
547 }
548
549 #[test]
550 fn init_initializes_git_if_needed() {
551 let dir = tempdir().unwrap();
552 let result = init(InitOptions {
553 root: dir.path().to_path_buf(),
554 name: Some("Test".into()),
555 acronym: None,
556 user: None,
557 language: None,
558 })
559 .unwrap();
560
561 assert!(result.git_initialized);
562 assert!(!result.git_existed);
563 assert!(dir.path().join(".git").is_dir());
564 }
565}