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 install_hooks(root)?;
127
128 Ok(InitResult {
129 project_dir: joy_dir,
130 git_initialized,
131 git_existed,
132 })
133}
134
135pub fn onboard(root: &Path) -> Result<OnboardResult, JoyError> {
137 embedded::sync_files(root, CONFIG_FILES)?;
138 embedded::sync_files(root, PROJECT_FILES)?;
139 install_hooks(root)
140}
141
142fn install_hooks(root: &Path) -> Result<OnboardResult, JoyError> {
144 let actions = embedded::sync_files(root, HOOK_FILES)?;
145 let hooks_installed = actions.iter().any(|a| a.action != "up to date");
146
147 let vcs = default_vcs();
149 let current = vcs.config_get(root, "core.hooksPath").unwrap_or_default();
150 let already_set = current == ".joy/hooks";
151
152 if !already_set {
153 vcs.config_set(root, "core.hooksPath", ".joy/hooks")?;
154 }
155
156 Ok(OnboardResult {
157 hooks_installed,
158 hooks_already_set: already_set,
159 })
160}
161
162pub const GITIGNORE_BLOCK_START: &str = "### joy:start -- managed by joy, do not edit manually";
163pub const GITIGNORE_BLOCK_END: &str = "### joy:end";
164
165pub const GITIGNORE_BASE_ENTRIES: &[(&str, &str)] = &[
166 (".joy/config.yaml", "personal config"),
167 (".joy/credentials.yaml", "secrets"),
168 (".joy/hooks/", "git hooks"),
169 (".joy/project.defaults.yaml", "embedded project defaults"),
170];
171
172pub fn update_gitignore_block(root: &Path, entries: &[(&str, &str)]) -> Result<(), JoyError> {
175 let gitignore_path = root.join(".gitignore");
176
177 let mut lines = String::new();
178 for (path, _comment) in entries {
179 lines.push_str(path);
180 lines.push('\n');
181 }
182 let block = format!(
183 "{}\n{}{}",
184 GITIGNORE_BLOCK_START, lines, GITIGNORE_BLOCK_END
185 );
186
187 let content = if gitignore_path.is_file() {
188 let existing =
189 std::fs::read_to_string(&gitignore_path).map_err(|e| JoyError::ReadFile {
190 path: gitignore_path.clone(),
191 source: e,
192 })?;
193 if existing.contains(GITIGNORE_BLOCK_START) && existing.contains(GITIGNORE_BLOCK_END) {
194 let start = existing.find(GITIGNORE_BLOCK_START).unwrap();
195 let end = existing.find(GITIGNORE_BLOCK_END).unwrap() + GITIGNORE_BLOCK_END.len();
196 let mut updated = String::new();
197 updated.push_str(&existing[..start]);
198 updated.push_str(&block);
199 updated.push_str(&existing[end..]);
200 updated
201 } else {
202 let trimmed = existing.trim_end();
203 if trimmed.is_empty() {
204 format!("{}\n", block)
205 } else {
206 format!("{}\n\n{}\n", trimmed, block)
207 }
208 }
209 } else {
210 format!("{}\n", block)
211 };
212
213 std::fs::write(&gitignore_path, &content).map_err(|e| JoyError::WriteFile {
214 path: gitignore_path,
215 source: e,
216 })?;
217 crate::git_ops::auto_git_add(root, &[".gitignore"]);
218 Ok(())
219}
220
221fn ensure_gitignore(root: &Path) -> Result<(), JoyError> {
222 update_gitignore_block(root, GITIGNORE_BASE_ENTRIES)
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use tempfile::tempdir;
229
230 #[test]
231 fn init_creates_directory_structure() {
232 let dir = tempdir().unwrap();
233 let result = init(InitOptions {
234 root: dir.path().to_path_buf(),
235 name: Some("Test Project".into()),
236 acronym: Some("TP".into()),
237 user: None,
238 language: None,
239 })
240 .unwrap();
241
242 assert!(result.project_dir.join("items").is_dir());
243 assert!(result.project_dir.join("milestones").is_dir());
244 assert!(result.project_dir.join("ai/agents").is_dir());
245 assert!(result.project_dir.join("ai/jobs").is_dir());
246 assert!(result.project_dir.join("logs").is_dir());
247 assert!(result.project_dir.join("config.defaults.yaml").is_file());
248 assert!(result.project_dir.join("project.yaml").is_file());
249 }
250
251 #[test]
252 fn init_writes_project_metadata() {
253 let dir = tempdir().unwrap();
254 init(InitOptions {
255 root: dir.path().to_path_buf(),
256 name: Some("My App".into()),
257 acronym: Some("MA".into()),
258 user: None,
259 language: None,
260 })
261 .unwrap();
262
263 let project: Project =
264 store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
265 assert_eq!(project.name, "My App");
266 assert_eq!(project.acronym.as_deref(), Some("MA"));
267 }
268
269 #[test]
270 fn init_derives_name_from_directory() {
271 let dir = tempdir().unwrap();
272 init(InitOptions {
273 root: dir.path().to_path_buf(),
274 name: None,
275 acronym: None,
276 user: None,
277 language: None,
278 })
279 .unwrap();
280
281 let project: Project =
282 store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
283 assert!(!project.name.is_empty());
285 assert!(project.acronym.is_some());
286 }
287
288 #[test]
289 fn init_fails_if_already_initialized() {
290 let dir = tempdir().unwrap();
291 init(InitOptions {
292 root: dir.path().to_path_buf(),
293 name: Some("Test".into()),
294 acronym: None,
295 user: None,
296 language: None,
297 })
298 .unwrap();
299
300 let err = init(InitOptions {
301 root: dir.path().to_path_buf(),
302 name: Some("Test".into()),
303 acronym: None,
304 user: None,
305 language: None,
306 })
307 .unwrap_err();
308
309 assert!(matches!(err, JoyError::AlreadyInitialized(_)));
310 }
311
312 #[test]
313 fn init_creates_gitignore_with_credentials_entry() {
314 let dir = tempdir().unwrap();
315 init(InitOptions {
316 root: dir.path().to_path_buf(),
317 name: Some("Test".into()),
318 acronym: None,
319 user: None,
320 language: None,
321 })
322 .unwrap();
323
324 let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
325 assert!(content.contains(".joy/credentials.yaml"));
326 assert!(content.contains(".joy/config.yaml"));
327 }
328
329 #[test]
330 fn init_does_not_duplicate_gitignore_block() {
331 let dir = tempdir().unwrap();
332 init(InitOptions {
334 root: dir.path().to_path_buf(),
335 name: Some("Test".into()),
336 acronym: None,
337 user: None,
338 language: None,
339 })
340 .unwrap();
341 let first = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
342
343 super::ensure_gitignore(dir.path()).unwrap();
345 let second = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
346
347 assert_eq!(first, second);
348 assert_eq!(second.matches(GITIGNORE_BLOCK_START).count(), 1);
349 }
350
351 #[test]
352 fn init_initializes_git_if_needed() {
353 let dir = tempdir().unwrap();
354 let result = init(InitOptions {
355 root: dir.path().to_path_buf(),
356 name: Some("Test".into()),
357 acronym: None,
358 user: None,
359 language: None,
360 })
361 .unwrap();
362
363 assert!(result.git_initialized);
364 assert!(!result.git_existed);
365 assert!(dir.path().join(".git").is_dir());
366 }
367}