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    // Idempotency: skip write + auto-stage when content already matches.
300    // The lazy-activation hook (called on every joy invocation) relies on
301    // this short-circuit to stay cheap and to not dirty the working tree.
302    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
319/// Best-effort registration check, called before every joy invocation
320/// that has a project root. Brings `.gitattributes` and the local git
321/// merge-driver config in line with the current binary, so users who
322/// upgraded joy without re-running `joy init` still get the merge
323/// driver. See JOY-0162.
324///
325/// Idempotent and silent: the file write is skipped when the block is
326/// already up to date, and `register_merge_driver` only writes when the
327/// stored values differ.
328pub 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
338/// Register the joy-yaml merge driver in the local Git config. Idempotent:
339/// repeated calls overwrite with the same value. The config is per-clone
340/// (Git does not transmit it through clone), so this is also called from
341/// `onboard` to bring fresh clones up to date.
342fn 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        // tempdir names vary, just check it's not empty
416        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        // First init creates the block
465        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        // Re-running ensure_gitignore should not duplicate
476        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}