use std::path::{Path, PathBuf};
use crate::embedded::{self, EmbeddedFile};
use crate::error::JoyError;
use crate::model::project::{derive_acronym, Project};
use crate::store;
use crate::vcs::{default_vcs, Vcs};
pub const HOOK_FILES: &[EmbeddedFile] = &[EmbeddedFile {
content: include_str!("../data/hooks/commit-msg"),
target: "hooks/commit-msg",
executable: true,
}];
pub const CONFIG_FILES: &[EmbeddedFile] = &[EmbeddedFile {
content: include_str!("../data/config.defaults.yaml"),
target: "config.defaults.yaml",
executable: false,
}];
pub const PROJECT_FILES: &[EmbeddedFile] = &[EmbeddedFile {
content: include_str!("../data/project.defaults.yaml"),
target: "project.defaults.yaml",
executable: false,
}];
pub struct InitOptions {
pub root: PathBuf,
pub name: Option<String>,
pub acronym: Option<String>,
pub user: Option<String>,
pub language: Option<String>,
}
#[derive(Debug)]
pub struct InitResult {
pub project_dir: PathBuf,
pub git_initialized: bool,
pub git_existed: bool,
}
pub struct OnboardResult {
pub hooks_installed: bool,
pub hooks_already_set: bool,
}
pub fn init(options: InitOptions) -> Result<InitResult, JoyError> {
let root = &options.root;
let joy_dir = store::joy_dir(root);
if store::is_initialized(root) {
return Err(JoyError::AlreadyInitialized(joy_dir));
}
let vcs = default_vcs();
let git_existed = vcs.is_repo(root);
let mut git_initialized = false;
if !git_existed {
vcs.init_repo(root)?;
git_initialized = true;
}
let dirs = [
store::ITEMS_DIR,
store::MILESTONES_DIR,
store::RELEASES_DIR,
store::AI_AGENTS_DIR,
store::AI_JOBS_DIR,
store::LOG_DIR,
];
for dir in &dirs {
let path = joy_dir.join(dir);
std::fs::create_dir_all(&path).map_err(|e| JoyError::CreateDir {
path: path.clone(),
source: e,
})?;
}
let name = options.name.unwrap_or_else(|| {
root.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project")
.to_string()
});
let acronym = options.acronym.unwrap_or_else(|| derive_acronym(&name));
embedded::sync_files(root, CONFIG_FILES)?;
embedded::sync_files(root, PROJECT_FILES)?;
let mut project = Project::new(name, Some(acronym));
if let Some(lang) = options.language.filter(|s| !s.is_empty()) {
project.language = lang;
}
let creator_email = options
.user
.filter(|s| !s.is_empty())
.or_else(|| vcs.user_email().ok().filter(|s| !s.is_empty()));
if let Some(email) = creator_email {
project.members.insert(
email,
crate::model::project::Member::new(crate::model::project::MemberCapabilities::All),
);
}
store::write_yaml(&joy_dir.join(store::PROJECT_FILE), &project)?;
let project_rel = format!("{}/{}", store::JOY_DIR, store::PROJECT_FILE);
let defaults_rel = format!("{}/{}", store::JOY_DIR, store::CONFIG_DEFAULTS_FILE);
crate::git_ops::auto_git_add(root, &[&project_rel, &defaults_rel]);
ensure_gitignore(root)?;
ensure_gitattributes(root)?;
register_merge_driver(root)?;
install_hooks(root)?;
let _ = set_last_sync_version(root, env!("CARGO_PKG_VERSION"));
Ok(InitResult {
project_dir: joy_dir,
git_initialized,
git_existed,
})
}
pub fn onboard(root: &Path) -> Result<OnboardResult, JoyError> {
embedded::sync_files(root, CONFIG_FILES)?;
embedded::sync_files(root, PROJECT_FILES)?;
ensure_gitignore(root)?;
ensure_gitattributes(root)?;
register_merge_driver(root)?;
let result = install_hooks(root)?;
let _ = set_last_sync_version(root, env!("CARGO_PKG_VERSION"));
Ok(result)
}
fn install_hooks(root: &Path) -> Result<OnboardResult, JoyError> {
let actions = embedded::sync_files(root, HOOK_FILES)?;
let hooks_installed = actions.iter().any(|a| a.action != "up to date");
let vcs = default_vcs();
let current = vcs.config_get(root, "core.hooksPath").unwrap_or_default();
let already_set = current == ".joy/hooks";
if !already_set {
vcs.config_set(root, "core.hooksPath", ".joy/hooks")?;
}
Ok(OnboardResult {
hooks_installed,
hooks_already_set: already_set,
})
}
pub const GITIGNORE_BLOCK_START: &str = "### joy:start -- managed by joy, do not edit manually";
pub const GITIGNORE_BLOCK_END: &str = "### joy:end";
pub const GITIGNORE_BASE_ENTRIES: &[(&str, &str)] = &[
(".joy/config.yaml", "personal config"),
(".joy/credentials.yaml", "secrets"),
(".joy/hooks/", "git hooks"),
(".joy/project.defaults.yaml", "embedded project defaults"),
];
pub fn update_gitignore_block(root: &Path, entries: &[(&str, &str)]) -> Result<(), JoyError> {
let gitignore_path = root.join(".gitignore");
let mut lines = String::new();
for (path, _comment) in entries {
lines.push_str(path);
lines.push('\n');
}
let block = format!(
"{}\n{}{}",
GITIGNORE_BLOCK_START, lines, GITIGNORE_BLOCK_END
);
let content = if gitignore_path.is_file() {
let existing =
std::fs::read_to_string(&gitignore_path).map_err(|e| JoyError::ReadFile {
path: gitignore_path.clone(),
source: e,
})?;
if existing.contains(GITIGNORE_BLOCK_START) && existing.contains(GITIGNORE_BLOCK_END) {
let start = existing.find(GITIGNORE_BLOCK_START).unwrap();
let end = existing.find(GITIGNORE_BLOCK_END).unwrap() + GITIGNORE_BLOCK_END.len();
let mut updated = String::new();
updated.push_str(&existing[..start]);
updated.push_str(&block);
updated.push_str(&existing[end..]);
updated
} else {
let trimmed = existing.trim_end();
if trimmed.is_empty() {
format!("{}\n", block)
} else {
format!("{}\n\n{}\n", trimmed, block)
}
}
} else {
format!("{}\n", block)
};
if gitignore_path.is_file() {
if let Ok(existing) = std::fs::read_to_string(&gitignore_path) {
if existing == content {
return Ok(());
}
}
}
std::fs::write(&gitignore_path, &content).map_err(|e| JoyError::WriteFile {
path: gitignore_path,
source: e,
})?;
crate::git_ops::auto_git_add(root, &[".gitignore"]);
Ok(())
}
fn ensure_gitignore(root: &Path) -> Result<(), JoyError> {
update_gitignore_block(root, GITIGNORE_BASE_ENTRIES)
}
pub const GITATTRIBUTES_BLOCK_START: &str = "### joy:start -- managed by joy, do not edit manually";
pub const GITATTRIBUTES_BLOCK_END: &str = "### joy:end";
pub const GITATTRIBUTES_BASE_ENTRIES: &[&str] = &[
".joy/items/*.yaml merge=joy-yaml",
".joy/milestones/*.yaml merge=joy-yaml",
".joy/releases/*.yaml merge=joy-yaml",
".joy/ai/agents/*.yaml merge=joy-yaml",
".joy/ai/jobs/*.yaml merge=joy-yaml",
".joy/project.yaml merge=joy-yaml",
".joy/config.defaults.yaml merge=joy-yaml",
".joy/logs/*.log merge=union",
];
pub const MERGE_DRIVER_NAME_KEY: &str = "merge.joy-yaml.name";
pub const MERGE_DRIVER_NAME_VALUE: &str = "Joy YAML merge driver";
pub const MERGE_DRIVER_CMD_KEY: &str = "merge.joy-yaml.driver";
pub const MERGE_DRIVER_CMD_VALUE: &str =
"joy merge driver --base %O --current %A --other %B --path %P --ours-rev %X --theirs-rev %Y";
pub fn update_gitattributes_block(root: &Path, lines: &[&str]) -> Result<(), JoyError> {
let path = root.join(".gitattributes");
let mut joined = String::new();
for line in lines {
joined.push_str(line);
joined.push('\n');
}
let block = format!(
"{}\n{}{}",
GITATTRIBUTES_BLOCK_START, joined, GITATTRIBUTES_BLOCK_END
);
let content = if path.is_file() {
let existing = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
path: path.clone(),
source: e,
})?;
if existing.contains(GITATTRIBUTES_BLOCK_START)
&& existing.contains(GITATTRIBUTES_BLOCK_END)
{
let start = existing.find(GITATTRIBUTES_BLOCK_START).unwrap();
let end =
existing.find(GITATTRIBUTES_BLOCK_END).unwrap() + GITATTRIBUTES_BLOCK_END.len();
let mut updated = String::new();
updated.push_str(&existing[..start]);
updated.push_str(&block);
updated.push_str(&existing[end..]);
updated
} else {
let trimmed = existing.trim_end();
if trimmed.is_empty() {
format!("{}\n", block)
} else {
format!("{}\n\n{}\n", trimmed, block)
}
}
} else {
format!("{}\n", block)
};
if path.is_file() {
if let Ok(existing) = std::fs::read_to_string(&path) {
if existing == content {
return Ok(());
}
}
}
std::fs::write(&path, &content).map_err(|e| JoyError::WriteFile { path, source: e })?;
crate::git_ops::auto_git_add(root, &[".gitattributes"]);
Ok(())
}
fn ensure_gitattributes(root: &Path) -> Result<(), JoyError> {
update_gitattributes_block(root, GITATTRIBUTES_BASE_ENTRIES)
}
pub fn ensure_lazy_activation(root: &Path) -> Result<(), JoyError> {
let vcs = default_vcs();
if !vcs.is_repo(root) {
return Ok(());
}
ensure_gitattributes(root)?;
register_merge_driver(root)?;
Ok(())
}
pub const LAST_SYNC_VERSION_KEY: &str = "joy.last-sync-version";
pub fn last_sync_version(root: &Path) -> Option<String> {
let vcs = default_vcs();
if !vcs.is_repo(root) {
return None;
}
vcs.config_get(root, LAST_SYNC_VERSION_KEY).ok()
}
pub fn set_last_sync_version(root: &Path, version: &str) -> Result<(), JoyError> {
let vcs = default_vcs();
if !vcs.is_repo(root) {
return Ok(());
}
vcs.config_set(root, LAST_SYNC_VERSION_KEY, version)
}
pub fn run_sync(root: &Path, current_version: &str) -> Result<(), JoyError> {
ensure_lazy_activation(root)?;
set_last_sync_version(root, current_version)
}
fn register_merge_driver(root: &Path) -> Result<(), JoyError> {
let vcs = default_vcs();
if !vcs.is_repo(root) {
return Ok(());
}
if vcs.config_get(root, MERGE_DRIVER_NAME_KEY).ok().as_deref() != Some(MERGE_DRIVER_NAME_VALUE)
{
vcs.config_set(root, MERGE_DRIVER_NAME_KEY, MERGE_DRIVER_NAME_VALUE)?;
}
if vcs.config_get(root, MERGE_DRIVER_CMD_KEY).ok().as_deref() != Some(MERGE_DRIVER_CMD_VALUE) {
vcs.config_set(root, MERGE_DRIVER_CMD_KEY, MERGE_DRIVER_CMD_VALUE)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn init_creates_directory_structure() {
let dir = tempdir().unwrap();
let result = init(InitOptions {
root: dir.path().to_path_buf(),
name: Some("Test Project".into()),
acronym: Some("TP".into()),
user: None,
language: None,
})
.unwrap();
assert!(result.project_dir.join("items").is_dir());
assert!(result.project_dir.join("milestones").is_dir());
assert!(result.project_dir.join("ai/agents").is_dir());
assert!(result.project_dir.join("ai/jobs").is_dir());
assert!(result.project_dir.join("logs").is_dir());
assert!(result.project_dir.join("config.defaults.yaml").is_file());
assert!(result.project_dir.join("project.yaml").is_file());
}
#[test]
fn init_writes_project_metadata() {
let dir = tempdir().unwrap();
init(InitOptions {
root: dir.path().to_path_buf(),
name: Some("My App".into()),
acronym: Some("MA".into()),
user: None,
language: None,
})
.unwrap();
let project: Project =
store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
assert_eq!(project.name, "My App");
assert_eq!(project.acronym.as_deref(), Some("MA"));
}
#[test]
fn init_derives_name_from_directory() {
let dir = tempdir().unwrap();
init(InitOptions {
root: dir.path().to_path_buf(),
name: None,
acronym: None,
user: None,
language: None,
})
.unwrap();
let project: Project =
store::read_yaml(&store::joy_dir(dir.path()).join(store::PROJECT_FILE)).unwrap();
assert!(!project.name.is_empty());
assert!(project.acronym.is_some());
}
#[test]
fn init_fails_if_already_initialized() {
let dir = tempdir().unwrap();
init(InitOptions {
root: dir.path().to_path_buf(),
name: Some("Test".into()),
acronym: None,
user: None,
language: None,
})
.unwrap();
let err = init(InitOptions {
root: dir.path().to_path_buf(),
name: Some("Test".into()),
acronym: None,
user: None,
language: None,
})
.unwrap_err();
assert!(matches!(err, JoyError::AlreadyInitialized(_)));
}
#[test]
fn init_creates_gitignore_with_credentials_entry() {
let dir = tempdir().unwrap();
init(InitOptions {
root: dir.path().to_path_buf(),
name: Some("Test".into()),
acronym: None,
user: None,
language: None,
})
.unwrap();
let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(content.contains(".joy/credentials.yaml"));
assert!(content.contains(".joy/config.yaml"));
}
#[test]
fn init_does_not_duplicate_gitignore_block() {
let dir = tempdir().unwrap();
init(InitOptions {
root: dir.path().to_path_buf(),
name: Some("Test".into()),
acronym: None,
user: None,
language: None,
})
.unwrap();
let first = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
super::ensure_gitignore(dir.path()).unwrap();
let second = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert_eq!(first, second);
assert_eq!(second.matches(GITIGNORE_BLOCK_START).count(), 1);
}
#[test]
fn init_writes_gitattributes_block_with_joy_yaml_and_union_log() {
let dir = tempdir().unwrap();
init(InitOptions {
root: dir.path().to_path_buf(),
name: Some("Test".into()),
acronym: None,
user: None,
language: None,
})
.unwrap();
let content = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
assert!(content.contains(GITATTRIBUTES_BLOCK_START));
assert!(content.contains(GITATTRIBUTES_BLOCK_END));
assert!(content.contains(".joy/items/*.yaml merge=joy-yaml"));
assert!(content.contains(".joy/milestones/*.yaml merge=joy-yaml"));
assert!(content.contains(".joy/releases/*.yaml merge=joy-yaml"));
assert!(content.contains(".joy/ai/agents/*.yaml merge=joy-yaml"));
assert!(content.contains(".joy/ai/jobs/*.yaml merge=joy-yaml"));
assert!(content.contains(".joy/project.yaml merge=joy-yaml"));
assert!(content.contains(".joy/config.defaults.yaml merge=joy-yaml"));
assert!(content.contains(".joy/logs/*.log merge=union"));
}
#[test]
fn init_does_not_duplicate_gitattributes_block() {
let dir = tempdir().unwrap();
init(InitOptions {
root: dir.path().to_path_buf(),
name: Some("Test".into()),
acronym: None,
user: None,
language: None,
})
.unwrap();
let first = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
super::ensure_gitattributes(dir.path()).unwrap();
let second = std::fs::read_to_string(dir.path().join(".gitattributes")).unwrap();
assert_eq!(first, second);
assert_eq!(second.matches(GITATTRIBUTES_BLOCK_START).count(), 1);
}
#[test]
fn init_registers_merge_driver_in_git_config() {
let dir = tempdir().unwrap();
init(InitOptions {
root: dir.path().to_path_buf(),
name: Some("Test".into()),
acronym: None,
user: None,
language: None,
})
.unwrap();
let vcs = default_vcs();
let name = vcs.config_get(dir.path(), MERGE_DRIVER_NAME_KEY).unwrap();
let cmd = vcs.config_get(dir.path(), MERGE_DRIVER_CMD_KEY).unwrap();
assert_eq!(name, MERGE_DRIVER_NAME_VALUE);
assert_eq!(cmd, MERGE_DRIVER_CMD_VALUE);
assert!(cmd.contains("--ours-rev %X"));
assert!(cmd.contains("--theirs-rev %Y"));
}
#[test]
fn init_initializes_git_if_needed() {
let dir = tempdir().unwrap();
let result = init(InitOptions {
root: dir.path().to_path_buf(),
name: Some("Test".into()),
acronym: None,
user: None,
language: None,
})
.unwrap();
assert!(result.git_initialized);
assert!(!result.git_existed);
assert!(dir.path().join(".git").is_dir());
}
}