Skip to main content

joy_core/
init.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4use 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    /// Override the creator member email. Falls back to git config user.email.
35    pub user: Option<String>,
36    /// Project language code (ISO 639-1, e.g. "en", "de"). Defaults to "en".
37    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    // Detect or initialize git
61    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    // Create directory structure
70    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    // Derive project name and acronym
87    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    // Write config and project defaults (embedded files)
96    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    // Register the project creator as a member with all capabilities.
105    // Prefer an explicit override, fall back to git config user.email.
106    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 .joy/credentials.yaml is in .gitignore
123    ensure_gitignore(root)?;
124
125    // Register the YAML / log merge driver in .gitattributes and git config.
126    ensure_gitattributes(root)?;
127    register_merge_driver(root)?;
128
129    // Install hooks
130    install_hooks(root)?;
131
132    Ok(InitResult {
133        project_dir: joy_dir,
134        git_initialized,
135        git_existed,
136    })
137}
138
139/// Onboard an existing project: set up local environment (hooks, etc.).
140pub 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
148/// Sync hook files and set core.hooksPath.
149fn 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    // Set core.hooksPath if not already pointing to .joy/hooks
154    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
178/// Update the joy-managed block in .gitignore with the given entries.
179/// Each entry is (path, comment). Replaces the block if it exists, appends otherwise.
180pub 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
234/// Path-pattern -> Git attribute lines for the joy-managed
235/// `.gitattributes` block. The YAML driver covers every Joy YAML file
236/// (items, milestones, releases, project metadata). The log entry uses
237/// Git's built-in union driver as an interim until JOY-0112 (Merkle-DAG
238/// log) ships.
239pub 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
256/// Update the joy-managed block in .gitattributes with the given lines.
257/// Replaces the block if it exists, appends otherwise.
258pub 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
308/// Register the joy-yaml merge driver in the local Git config. Idempotent:
309/// repeated calls overwrite with the same value. The config is per-clone
310/// (Git does not transmit it through clone), so this is also called from
311/// `onboard` to bring fresh clones up to date.
312fn 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        // tempdir names vary, just check it's not empty
386        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        // First init creates the block
435        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        // Re-running ensure_gitignore should not duplicate
446        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}