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}
35
36#[derive(Debug)]
37pub struct InitResult {
38 pub project_dir: PathBuf,
39 pub git_initialized: bool,
40 pub git_existed: bool,
41}
42
43pub struct OnboardResult {
44 pub hooks_installed: bool,
45 pub hooks_already_set: bool,
46}
47
48pub fn init(options: InitOptions) -> Result<InitResult, JoyError> {
49 let root = &options.root;
50 let joy_dir = store::joy_dir(root);
51
52 if store::is_initialized(root) {
53 return Err(JoyError::AlreadyInitialized(joy_dir));
54 }
55
56 let vcs = default_vcs();
58 let git_existed = vcs.is_repo(root);
59 let mut git_initialized = false;
60 if !git_existed {
61 vcs.init_repo(root)?;
62 git_initialized = true;
63 }
64
65 let dirs = [
67 store::ITEMS_DIR,
68 store::MILESTONES_DIR,
69 store::RELEASES_DIR,
70 store::AI_AGENTS_DIR,
71 store::AI_JOBS_DIR,
72 store::LOG_DIR,
73 ];
74 for dir in &dirs {
75 let path = joy_dir.join(dir);
76 std::fs::create_dir_all(&path).map_err(|e| JoyError::CreateDir {
77 path: path.clone(),
78 source: e,
79 })?;
80 }
81
82 let name = options.name.unwrap_or_else(|| {
84 root.file_name()
85 .and_then(|n| n.to_str())
86 .unwrap_or("project")
87 .to_string()
88 });
89 let acronym = options.acronym.unwrap_or_else(|| derive_acronym(&name));
90
91 embedded::sync_files(root, CONFIG_FILES)?;
93 embedded::sync_files(root, PROJECT_FILES)?;
94
95 let mut project = Project::new(name, Some(acronym));
96
97 if let Ok(email) = vcs.user_email() {
99 if !email.is_empty() {
100 project.members.insert(
101 email,
102 crate::model::project::Member::new(crate::model::project::MemberCapabilities::All),
103 );
104 }
105 }
106
107 store::write_yaml(&joy_dir.join(store::PROJECT_FILE), &project)?;
108 let project_rel = format!("{}/{}", store::JOY_DIR, store::PROJECT_FILE);
109 let defaults_rel = format!("{}/{}", store::JOY_DIR, store::CONFIG_DEFAULTS_FILE);
110 crate::git_ops::auto_git_add(root, &[&project_rel, &defaults_rel]);
111
112 ensure_gitignore(root)?;
114
115 install_hooks(root)?;
117
118 Ok(InitResult {
119 project_dir: joy_dir,
120 git_initialized,
121 git_existed,
122 })
123}
124
125pub fn onboard(root: &Path) -> Result<OnboardResult, JoyError> {
127 embedded::sync_files(root, CONFIG_FILES)?;
128 embedded::sync_files(root, PROJECT_FILES)?;
129 install_hooks(root)
130}
131
132fn install_hooks(root: &Path) -> Result<OnboardResult, JoyError> {
134 let actions = embedded::sync_files(root, HOOK_FILES)?;
135 let hooks_installed = actions.iter().any(|a| a.action != "up to date");
136
137 let vcs = default_vcs();
139 let current = vcs.config_get(root, "core.hooksPath").unwrap_or_default();
140 let already_set = current == ".joy/hooks";
141
142 if !already_set {
143 vcs.config_set(root, "core.hooksPath", ".joy/hooks")?;
144 }
145
146 Ok(OnboardResult {
147 hooks_installed,
148 hooks_already_set: already_set,
149 })
150}
151
152pub const GITIGNORE_BLOCK_START: &str = "### joy:start -- managed by joy, do not edit manually";
153pub const GITIGNORE_BLOCK_END: &str = "### joy:end";
154
155pub const GITIGNORE_BASE_ENTRIES: &[(&str, &str)] = &[
156 (".joy/config.yaml", "personal config"),
157 (".joy/credentials.yaml", "secrets"),
158 (".joy/hooks/", "git hooks"),
159 (".joy/project.defaults.yaml", "embedded project defaults"),
160];
161
162pub fn update_gitignore_block(root: &Path, entries: &[(&str, &str)]) -> Result<(), JoyError> {
165 let gitignore_path = root.join(".gitignore");
166
167 let mut lines = String::new();
168 for (path, _comment) in entries {
169 lines.push_str(path);
170 lines.push('\n');
171 }
172 let block = format!(
173 "{}\n{}{}",
174 GITIGNORE_BLOCK_START, lines, GITIGNORE_BLOCK_END
175 );
176
177 let content = if gitignore_path.is_file() {
178 let existing =
179 std::fs::read_to_string(&gitignore_path).map_err(|e| JoyError::ReadFile {
180 path: gitignore_path.clone(),
181 source: e,
182 })?;
183 if existing.contains(GITIGNORE_BLOCK_START) && existing.contains(GITIGNORE_BLOCK_END) {
184 let start = existing.find(GITIGNORE_BLOCK_START).unwrap();
185 let end = existing.find(GITIGNORE_BLOCK_END).unwrap() + GITIGNORE_BLOCK_END.len();
186 let mut updated = String::new();
187 updated.push_str(&existing[..start]);
188 updated.push_str(&block);
189 updated.push_str(&existing[end..]);
190 updated
191 } else {
192 let trimmed = existing.trim_end();
193 if trimmed.is_empty() {
194 format!("{}\n", block)
195 } else {
196 format!("{}\n\n{}\n", trimmed, block)
197 }
198 }
199 } else {
200 format!("{}\n", block)
201 };
202
203 std::fs::write(&gitignore_path, &content).map_err(|e| JoyError::WriteFile {
204 path: gitignore_path,
205 source: e,
206 })?;
207 crate::git_ops::auto_git_add(root, &[".gitignore"]);
208 Ok(())
209}
210
211fn ensure_gitignore(root: &Path) -> Result<(), JoyError> {
212 update_gitignore_block(root, GITIGNORE_BASE_ENTRIES)
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use tempfile::tempdir;
219
220 #[test]
221 fn init_creates_directory_structure() {
222 let dir = tempdir().unwrap();
223 let result = init(InitOptions {
224 root: dir.path().to_path_buf(),
225 name: Some("Test Project".into()),
226 acronym: Some("TP".into()),
227 })
228 .unwrap();
229
230 assert!(result.project_dir.join("items").is_dir());
231 assert!(result.project_dir.join("milestones").is_dir());
232 assert!(result.project_dir.join("ai/agents").is_dir());
233 assert!(result.project_dir.join("ai/jobs").is_dir());
234 assert!(result.project_dir.join("logs").is_dir());
235 assert!(result.project_dir.join("config.defaults.yaml").is_file());
236 assert!(result.project_dir.join("project.yaml").is_file());
237 }
238
239 #[test]
240 fn init_writes_project_metadata() {
241 let dir = tempdir().unwrap();
242 init(InitOptions {
243 root: dir.path().to_path_buf(),
244 name: Some("My App".into()),
245 acronym: Some("MA".into()),
246 })
247 .unwrap();
248
249 let project: Project =
250 store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
251 assert_eq!(project.name, "My App");
252 assert_eq!(project.acronym.as_deref(), Some("MA"));
253 }
254
255 #[test]
256 fn init_derives_name_from_directory() {
257 let dir = tempdir().unwrap();
258 init(InitOptions {
259 root: dir.path().to_path_buf(),
260 name: None,
261 acronym: None,
262 })
263 .unwrap();
264
265 let project: Project =
266 store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
267 assert!(!project.name.is_empty());
269 assert!(project.acronym.is_some());
270 }
271
272 #[test]
273 fn init_fails_if_already_initialized() {
274 let dir = tempdir().unwrap();
275 init(InitOptions {
276 root: dir.path().to_path_buf(),
277 name: Some("Test".into()),
278 acronym: None,
279 })
280 .unwrap();
281
282 let err = init(InitOptions {
283 root: dir.path().to_path_buf(),
284 name: Some("Test".into()),
285 acronym: None,
286 })
287 .unwrap_err();
288
289 assert!(matches!(err, JoyError::AlreadyInitialized(_)));
290 }
291
292 #[test]
293 fn init_creates_gitignore_with_credentials_entry() {
294 let dir = tempdir().unwrap();
295 init(InitOptions {
296 root: dir.path().to_path_buf(),
297 name: Some("Test".into()),
298 acronym: None,
299 })
300 .unwrap();
301
302 let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
303 assert!(content.contains(".joy/credentials.yaml"));
304 assert!(content.contains(".joy/config.yaml"));
305 }
306
307 #[test]
308 fn init_does_not_duplicate_gitignore_block() {
309 let dir = tempdir().unwrap();
310 init(InitOptions {
312 root: dir.path().to_path_buf(),
313 name: Some("Test".into()),
314 acronym: None,
315 })
316 .unwrap();
317 let first = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
318
319 super::ensure_gitignore(dir.path()).unwrap();
321 let second = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
322
323 assert_eq!(first, second);
324 assert_eq!(second.matches(GITIGNORE_BLOCK_START).count(), 1);
325 }
326
327 #[test]
328 fn init_initializes_git_if_needed() {
329 let dir = tempdir().unwrap();
330 let result = init(InitOptions {
331 root: dir.path().to_path_buf(),
332 name: Some("Test".into()),
333 acronym: None,
334 })
335 .unwrap();
336
337 assert!(result.git_initialized);
338 assert!(!result.git_existed);
339 assert!(dir.path().join(".git").is_dir());
340 }
341}