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}
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    // Detect or initialize git
57    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    // Create directory structure
66    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    // Derive project name and acronym
83    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    // Write config and project defaults (embedded files)
92    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    // Register the project creator as a member with all capabilities
98    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 .joy/credentials.yaml is in .gitignore
113    ensure_gitignore(root)?;
114
115    // Install hooks
116    install_hooks(root)?;
117
118    Ok(InitResult {
119        project_dir: joy_dir,
120        git_initialized,
121        git_existed,
122    })
123}
124
125/// Onboard an existing project: set up local environment (hooks, etc.).
126pub 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
132/// Sync hook files and set core.hooksPath.
133fn 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    // Set core.hooksPath if not already pointing to .joy/hooks
138    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
162/// Update the joy-managed block in .gitignore with the given entries.
163/// Each entry is (path, comment). Replaces the block if it exists, appends otherwise.
164pub 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        // tempdir names vary, just check it's not empty
268        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        // First init creates the block
311        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        // Re-running ensure_gitignore should not duplicate
320        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}