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    // Stamp the per-clone version marker so the first joy invocation
133    // after init does not re-trigger the auto-sync routine. The CLI
134    // caller is responsible for the cargo-pkg version string; we use
135    // the joy-core version here as a stable proxy.
136    let _ = set_last_sync_version(root, env!("CARGO_PKG_VERSION"));
137
138    Ok(InitResult {
139        project_dir: joy_dir,
140        git_initialized,
141        git_existed,
142    })
143}
144
145/// Onboard an existing project: set up local environment (hooks, etc.).
146pub fn onboard(root: &Path) -> Result<OnboardResult, JoyError> {
147    embedded::sync_files(root, CONFIG_FILES)?;
148    embedded::sync_files(root, PROJECT_FILES)?;
149    ensure_gitignore(root)?;
150    ensure_gitattributes(root)?;
151    register_merge_driver(root)?;
152    let result = install_hooks(root)?;
153    // Stamp the per-clone marker so the first joy invocation after
154    // onboard does not re-trigger the auto-sync routine.
155    let _ = set_last_sync_version(root, env!("CARGO_PKG_VERSION"));
156    Ok(result)
157}
158
159/// Sync hook files and set core.hooksPath.
160fn install_hooks(root: &Path) -> Result<OnboardResult, JoyError> {
161    let actions = embedded::sync_files(root, HOOK_FILES)?;
162    let hooks_installed = actions.iter().any(|a| a.action != "up to date");
163
164    // Set core.hooksPath if not already pointing to .joy/hooks
165    let vcs = default_vcs();
166    let current = vcs.config_get(root, "core.hooksPath").unwrap_or_default();
167    let already_set = current == ".joy/hooks";
168
169    if !already_set {
170        vcs.config_set(root, "core.hooksPath", ".joy/hooks")?;
171    }
172
173    Ok(OnboardResult {
174        hooks_installed,
175        hooks_already_set: already_set,
176    })
177}
178
179pub const GITIGNORE_BLOCK_START: &str = "### joy:start -- managed by joy, do not edit manually";
180pub const GITIGNORE_BLOCK_END: &str = "### joy:end";
181
182pub const GITIGNORE_BASE_ENTRIES: &[(&str, &str)] = &[
183    (".joy/config.yaml", "personal config"),
184    (".joy/credentials.yaml", "secrets"),
185    (".joy/hooks/", "git hooks"),
186    (".joy/project.defaults.yaml", "embedded project defaults"),
187];
188
189/// Update the joy-managed block in .gitignore with the given entries.
190/// Each entry is (path, comment). Replaces the block if it exists, appends otherwise.
191pub fn update_gitignore_block(root: &Path, entries: &[(&str, &str)]) -> Result<(), JoyError> {
192    let gitignore_path = root.join(".gitignore");
193
194    let mut lines = String::new();
195    for (path, _comment) in entries {
196        lines.push_str(path);
197        lines.push('\n');
198    }
199    let block = format!(
200        "{}\n{}{}",
201        GITIGNORE_BLOCK_START, lines, GITIGNORE_BLOCK_END
202    );
203
204    let content = if gitignore_path.is_file() {
205        let existing =
206            std::fs::read_to_string(&gitignore_path).map_err(|e| JoyError::ReadFile {
207                path: gitignore_path.clone(),
208                source: e,
209            })?;
210        if existing.contains(GITIGNORE_BLOCK_START) && existing.contains(GITIGNORE_BLOCK_END) {
211            let start = existing.find(GITIGNORE_BLOCK_START).unwrap();
212            let end = existing.find(GITIGNORE_BLOCK_END).unwrap() + GITIGNORE_BLOCK_END.len();
213            let mut updated = String::new();
214            updated.push_str(&existing[..start]);
215            updated.push_str(&block);
216            updated.push_str(&existing[end..]);
217            updated
218        } else {
219            let trimmed = existing.trim_end();
220            if trimmed.is_empty() {
221                format!("{}\n", block)
222            } else {
223                format!("{}\n\n{}\n", trimmed, block)
224            }
225        }
226    } else {
227        format!("{}\n", block)
228    };
229
230    // Idempotency: skip write + auto-stage when content already matches.
231    if gitignore_path.is_file() {
232        if let Ok(existing) = std::fs::read_to_string(&gitignore_path) {
233            if existing == content {
234                return Ok(());
235            }
236        }
237    }
238
239    std::fs::write(&gitignore_path, &content).map_err(|e| JoyError::WriteFile {
240        path: gitignore_path,
241        source: e,
242    })?;
243    crate::git_ops::auto_git_add(root, &[".gitignore"]);
244    Ok(())
245}
246
247fn ensure_gitignore(root: &Path) -> Result<(), JoyError> {
248    update_gitignore_block(root, GITIGNORE_BASE_ENTRIES)
249}
250
251pub const GITATTRIBUTES_BLOCK_START: &str = "### joy:start -- managed by joy, do not edit manually";
252pub const GITATTRIBUTES_BLOCK_END: &str = "### joy:end";
253
254/// Path-pattern -> Git attribute lines for the joy-managed
255/// `.gitattributes` block. The YAML driver covers every Joy YAML file
256/// (items, milestones, releases, project metadata). The log entry uses
257/// Git's built-in union driver as an interim until JOY-0112 (Merkle-DAG
258/// log) ships.
259pub const GITATTRIBUTES_BASE_ENTRIES: &[&str] = &[
260    ".joy/items/*.yaml merge=joy-yaml",
261    ".joy/milestones/*.yaml merge=joy-yaml",
262    ".joy/releases/*.yaml merge=joy-yaml",
263    ".joy/ai/agents/*.yaml merge=joy-yaml",
264    ".joy/ai/jobs/*.yaml merge=joy-yaml",
265    ".joy/project.yaml merge=joy-yaml",
266    ".joy/config.defaults.yaml merge=joy-yaml",
267    ".joy/logs/*.log merge=union",
268];
269
270pub const MERGE_DRIVER_NAME_KEY: &str = "merge.joy-yaml.name";
271pub const MERGE_DRIVER_NAME_VALUE: &str = "Joy YAML merge driver";
272pub const MERGE_DRIVER_CMD_KEY: &str = "merge.joy-yaml.driver";
273pub const MERGE_DRIVER_CMD_VALUE: &str =
274    "joy merge driver --base %O --current %A --other %B --path %P --ours-rev %X --theirs-rev %Y";
275
276/// Update the joy-managed block in .gitattributes with the given lines.
277/// Replaces the block if it exists, appends otherwise.
278pub fn update_gitattributes_block(root: &Path, lines: &[&str]) -> Result<(), JoyError> {
279    let path = root.join(".gitattributes");
280
281    let mut joined = String::new();
282    for line in lines {
283        joined.push_str(line);
284        joined.push('\n');
285    }
286    let block = format!(
287        "{}\n{}{}",
288        GITATTRIBUTES_BLOCK_START, joined, GITATTRIBUTES_BLOCK_END
289    );
290
291    let content = if path.is_file() {
292        let existing = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
293            path: path.clone(),
294            source: e,
295        })?;
296        if existing.contains(GITATTRIBUTES_BLOCK_START)
297            && existing.contains(GITATTRIBUTES_BLOCK_END)
298        {
299            let start = existing.find(GITATTRIBUTES_BLOCK_START).unwrap();
300            let end =
301                existing.find(GITATTRIBUTES_BLOCK_END).unwrap() + GITATTRIBUTES_BLOCK_END.len();
302            let mut updated = String::new();
303            updated.push_str(&existing[..start]);
304            updated.push_str(&block);
305            updated.push_str(&existing[end..]);
306            updated
307        } else {
308            let trimmed = existing.trim_end();
309            if trimmed.is_empty() {
310                format!("{}\n", block)
311            } else {
312                format!("{}\n\n{}\n", trimmed, block)
313            }
314        }
315    } else {
316        format!("{}\n", block)
317    };
318
319    // Idempotency: skip write + auto-stage when content already matches.
320    // The lazy-activation hook (called on every joy invocation) relies on
321    // this short-circuit to stay cheap and to not dirty the working tree.
322    if path.is_file() {
323        if let Ok(existing) = std::fs::read_to_string(&path) {
324            if existing == content {
325                return Ok(());
326            }
327        }
328    }
329
330    std::fs::write(&path, &content).map_err(|e| JoyError::WriteFile { path, source: e })?;
331    crate::git_ops::auto_git_add(root, &[".gitattributes"]);
332    Ok(())
333}
334
335fn ensure_gitattributes(root: &Path) -> Result<(), JoyError> {
336    update_gitattributes_block(root, GITATTRIBUTES_BASE_ENTRIES)
337}
338
339/// Best-effort registration check, called before every joy invocation
340/// that has a project root. Brings `.gitattributes` and the local git
341/// merge-driver config in line with the current binary, so users who
342/// upgraded joy without re-running `joy init` still get the merge
343/// driver. See JOY-0162.
344///
345/// Idempotent and silent: the file write is skipped when the block is
346/// already up to date, and `register_merge_driver` only writes when the
347/// stored values differ.
348pub fn ensure_lazy_activation(root: &Path) -> Result<(), JoyError> {
349    let vcs = default_vcs();
350    if !vcs.is_repo(root) {
351        return Ok(());
352    }
353    ensure_gitattributes(root)?;
354    register_merge_driver(root)?;
355    Ok(())
356}
357
358/// Per-clone git config key recording the joy version that last synced
359/// this repo. Compared against `env!("CARGO_PKG_VERSION")` to drive the
360/// auto-sync hook. See JOY-0164-B5.
361pub const LAST_SYNC_VERSION_KEY: &str = "joy.last-sync-version";
362
363/// Read the recorded last-sync version from this clone's git config.
364/// `None` if not a repo or the key is unset.
365///
366/// TODO: route through a `Vcs::config_get` trait method so non-Git
367/// backends can implement (ADR-010). Today it's a `GitVcs` inherent
368/// method, which constrains the abstraction.
369pub fn last_sync_version(root: &Path) -> Option<String> {
370    let vcs = default_vcs();
371    if !vcs.is_repo(root) {
372        return None;
373    }
374    vcs.config_get(root, LAST_SYNC_VERSION_KEY).ok()
375}
376
377/// Stamp the current binary version into this clone's git config.
378pub fn set_last_sync_version(root: &Path, version: &str) -> Result<(), JoyError> {
379    let vcs = default_vcs();
380    if !vcs.is_repo(root) {
381        return Ok(());
382    }
383    vcs.config_set(root, LAST_SYNC_VERSION_KEY, version)
384}
385
386/// One-shot core-side sync of a repo against the current binary:
387/// `ensure_lazy_activation` + stamp `joy.last-sync-version`. The full
388/// `joy update` orchestrator wraps this with the auth and AI refresh
389/// routines (see joy-cli's `commands::update::run_full_sync`).
390pub fn run_sync(root: &Path, current_version: &str) -> Result<(), JoyError> {
391    ensure_lazy_activation(root)?;
392    set_last_sync_version(root, current_version)
393}
394
395/// Register the joy-yaml merge driver in the local Git config. Idempotent:
396/// repeated calls overwrite with the same value. The config is per-clone
397/// (Git does not transmit it through clone), so this is also called from
398/// `onboard` to bring fresh clones up to date.
399fn register_merge_driver(root: &Path) -> Result<(), JoyError> {
400    let vcs = default_vcs();
401    if !vcs.is_repo(root) {
402        return Ok(());
403    }
404    if vcs.config_get(root, MERGE_DRIVER_NAME_KEY).ok().as_deref() != Some(MERGE_DRIVER_NAME_VALUE)
405    {
406        vcs.config_set(root, MERGE_DRIVER_NAME_KEY, MERGE_DRIVER_NAME_VALUE)?;
407    }
408    if vcs.config_get(root, MERGE_DRIVER_CMD_KEY).ok().as_deref() != Some(MERGE_DRIVER_CMD_VALUE) {
409        vcs.config_set(root, MERGE_DRIVER_CMD_KEY, MERGE_DRIVER_CMD_VALUE)?;
410    }
411    Ok(())
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use tempfile::tempdir;
418
419    #[test]
420    fn init_creates_directory_structure() {
421        let dir = tempdir().unwrap();
422        let result = init(InitOptions {
423            root: dir.path().to_path_buf(),
424            name: Some("Test Project".into()),
425            acronym: Some("TP".into()),
426            user: None,
427            language: None,
428        })
429        .unwrap();
430
431        assert!(result.project_dir.join("items").is_dir());
432        assert!(result.project_dir.join("milestones").is_dir());
433        assert!(result.project_dir.join("ai/agents").is_dir());
434        assert!(result.project_dir.join("ai/jobs").is_dir());
435        assert!(result.project_dir.join("logs").is_dir());
436        assert!(result.project_dir.join("config.defaults.yaml").is_file());
437        assert!(result.project_dir.join("project.yaml").is_file());
438    }
439
440    #[test]
441    fn init_writes_project_metadata() {
442        let dir = tempdir().unwrap();
443        init(InitOptions {
444            root: dir.path().to_path_buf(),
445            name: Some("My App".into()),
446            acronym: Some("MA".into()),
447            user: None,
448            language: None,
449        })
450        .unwrap();
451
452        let project: Project =
453            store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
454        assert_eq!(project.name, "My App");
455        assert_eq!(project.acronym.as_deref(), Some("MA"));
456    }
457
458    #[test]
459    fn init_derives_name_from_directory() {
460        let dir = tempdir().unwrap();
461        init(InitOptions {
462            root: dir.path().to_path_buf(),
463            name: None,
464            acronym: None,
465            user: None,
466            language: None,
467        })
468        .unwrap();
469
470        let project: Project =
471            store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
472        // tempdir names vary, just check it's not empty
473        assert!(!project.name.is_empty());
474        assert!(project.acronym.is_some());
475    }
476
477    #[test]
478    fn init_fails_if_already_initialized() {
479        let dir = tempdir().unwrap();
480        init(InitOptions {
481            root: dir.path().to_path_buf(),
482            name: Some("Test".into()),
483            acronym: None,
484            user: None,
485            language: None,
486        })
487        .unwrap();
488
489        let err = init(InitOptions {
490            root: dir.path().to_path_buf(),
491            name: Some("Test".into()),
492            acronym: None,
493            user: None,
494            language: None,
495        })
496        .unwrap_err();
497
498        assert!(matches!(err, JoyError::AlreadyInitialized(_)));
499    }
500
501    #[test]
502    fn init_creates_gitignore_with_credentials_entry() {
503        let dir = tempdir().unwrap();
504        init(InitOptions {
505            root: dir.path().to_path_buf(),
506            name: Some("Test".into()),
507            acronym: None,
508            user: None,
509            language: None,
510        })
511        .unwrap();
512
513        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
514        assert!(content.contains(".joy/credentials.yaml"));
515        assert!(content.contains(".joy/config.yaml"));
516    }
517
518    #[test]
519    fn init_does_not_duplicate_gitignore_block() {
520        let dir = tempdir().unwrap();
521        // First init creates the block
522        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        let first = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
531
532        // Re-running ensure_gitignore should not duplicate
533        super::ensure_gitignore(dir.path()).unwrap();
534        let second = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
535
536        assert_eq!(first, second);
537        assert_eq!(second.matches(GITIGNORE_BLOCK_START).count(), 1);
538    }
539
540    #[test]
541    fn init_writes_gitattributes_block_with_joy_yaml_and_union_log() {
542        let dir = tempdir().unwrap();
543        init(InitOptions {
544            root: dir.path().to_path_buf(),
545            name: Some("Test".into()),
546            acronym: None,
547            user: None,
548            language: None,
549        })
550        .unwrap();
551
552        let content = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
553        assert!(content.contains(GITATTRIBUTES_BLOCK_START));
554        assert!(content.contains(GITATTRIBUTES_BLOCK_END));
555        assert!(content.contains(".joy/items/*.yaml merge=joy-yaml"));
556        assert!(content.contains(".joy/milestones/*.yaml merge=joy-yaml"));
557        assert!(content.contains(".joy/releases/*.yaml merge=joy-yaml"));
558        assert!(content.contains(".joy/ai/agents/*.yaml merge=joy-yaml"));
559        assert!(content.contains(".joy/ai/jobs/*.yaml merge=joy-yaml"));
560        assert!(content.contains(".joy/project.yaml merge=joy-yaml"));
561        assert!(content.contains(".joy/config.defaults.yaml merge=joy-yaml"));
562        assert!(content.contains(".joy/logs/*.log merge=union"));
563    }
564
565    #[test]
566    fn init_does_not_duplicate_gitattributes_block() {
567        let dir = tempdir().unwrap();
568        init(InitOptions {
569            root: dir.path().to_path_buf(),
570            name: Some("Test".into()),
571            acronym: None,
572            user: None,
573            language: None,
574        })
575        .unwrap();
576        let first = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
577
578        super::ensure_gitattributes(dir.path()).unwrap();
579        let second = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
580
581        assert_eq!(first, second);
582        assert_eq!(second.matches(GITATTRIBUTES_BLOCK_START).count(), 1);
583    }
584
585    #[test]
586    fn init_registers_merge_driver_in_git_config() {
587        let dir = tempdir().unwrap();
588        init(InitOptions {
589            root: dir.path().to_path_buf(),
590            name: Some("Test".into()),
591            acronym: None,
592            user: None,
593            language: None,
594        })
595        .unwrap();
596
597        let vcs = default_vcs();
598        let name = vcs.config_get(dir.path(), MERGE_DRIVER_NAME_KEY).unwrap();
599        let cmd = vcs.config_get(dir.path(), MERGE_DRIVER_CMD_KEY).unwrap();
600        assert_eq!(name, MERGE_DRIVER_NAME_VALUE);
601        assert_eq!(cmd, MERGE_DRIVER_CMD_VALUE);
602        assert!(cmd.contains("--ours-rev %X"));
603        assert!(cmd.contains("--theirs-rev %Y"));
604    }
605
606    #[test]
607    fn init_initializes_git_if_needed() {
608        let dir = tempdir().unwrap();
609        let result = init(InitOptions {
610            root: dir.path().to_path_buf(),
611            name: Some("Test".into()),
612            acronym: None,
613            user: None,
614            language: None,
615        })
616        .unwrap();
617
618        assert!(result.git_initialized);
619        assert!(!result.git_existed);
620        assert!(dir.path().join(".git").is_dir());
621    }
622}