pub mod interactive;
pub mod migration;
pub mod templates;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::path::Path;
use anyhow::{Context, Result};
use colored::Colorize;
use tokio::fs;
use crate::cache::Cache;
use crate::config::Config;
use crate::generator::TypeGenerator;
use crate::parser;
pub struct InitOptions {
pub force: bool,
pub skip_examples: bool,
pub migrate: bool,
pub interactive: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ProjectState {
Empty,
ExistingProject,
HasWorkflows,
}
pub fn detect_project_state(root: &Path) -> Result<ProjectState> {
let has_workflows = root
.join(".github/workflows")
.read_dir()
.ok()
.map(|entries| {
entries.filter_map(|e| e.ok()).any(|e| {
let ext = e.path().extension().map(|s| s.to_owned());
ext.as_deref() == Some(OsStr::new("yml"))
|| ext.as_deref() == Some(OsStr::new("yaml"))
})
})
.unwrap_or(false);
if has_workflows {
return Ok(ProjectState::HasWorkflows);
}
let project_markers = [
"package.json",
"Cargo.toml",
"go.mod",
"pyproject.toml",
"Makefile",
"CMakeLists.txt",
"pom.xml",
"build.gradle",
".git",
];
let has_project_files = project_markers
.iter()
.any(|marker| root.join(marker).exists());
if has_project_files {
Ok(ProjectState::ExistingProject)
} else {
Ok(ProjectState::Empty)
}
}
pub async fn init_project(root: &Path, options: InitOptions) -> Result<()> {
println!("{} Initializing gaji project...\n", "🚀".green());
if options.interactive {
return interactive::interactive_init(root).await;
}
let project_state = detect_project_state(root)?;
match project_state {
ProjectState::Empty => init_new_project(root, &options).await?,
ProjectState::ExistingProject => init_existing_project(root, &options).await?,
ProjectState::HasWorkflows => init_with_workflows(root, &options).await?,
}
Ok(())
}
pub(crate) async fn init_new_project(root: &Path, options: &InitOptions) -> Result<()> {
println!("{} Creating new project structure...\n", "📦".cyan());
create_directories(root).await?;
ensure_gitignore(root, false).await?;
create_config(root)?;
if !options.skip_examples {
create_example_workflow(root).await?;
try_generate_initial_types(root).await;
}
println!("\n{} Project initialized!\n", "✨".green());
print_next_steps();
Ok(())
}
pub(crate) async fn init_existing_project(root: &Path, options: &InitOptions) -> Result<()> {
println!("{} Adding gaji to existing project...\n", "📦".cyan());
create_directories(root).await?;
if root.join("package.json").exists() {
update_package_json(root).await?;
handle_tsconfig(root, options).await?;
}
ensure_gitignore(root, true).await?;
create_config(root)?;
if !options.skip_examples {
let example_path = root.join("workflows/ci.ts");
if !example_path.exists() {
create_example_workflow(root).await?;
} else {
println!(
"{} workflows/ci.ts already exists, skipping example",
"⏭️ ".dimmed()
);
}
}
try_generate_initial_types(root).await;
println!("\n{} gaji added to your project!\n", "✨".green());
print_next_steps();
Ok(())
}
async fn init_with_workflows(root: &Path, options: &InitOptions) -> Result<()> {
println!(
"{} Adding gaji to project with existing workflows...\n",
"📦".cyan()
);
let existing_workflows = migration::discover_workflows(root)?;
if !existing_workflows.is_empty() {
println!("Found {} existing workflow(s):", existing_workflows.len());
for workflow in &existing_workflows {
println!(" - {}", workflow.display());
}
println!();
}
if options.migrate {
migration::migrate_workflows(root, &existing_workflows).await?;
} else if !existing_workflows.is_empty() {
println!(
"{} Tip: Run with --migrate to convert existing YAML workflows to TypeScript",
"💡".yellow()
);
println!(" gaji init --migrate\n");
}
init_existing_project(root, options).await?;
Ok(())
}
async fn create_directories(root: &Path) -> Result<()> {
fs::create_dir_all(root.join("workflows")).await?;
fs::create_dir_all(root.join("generated")).await?;
fs::create_dir_all(root.join(".github/workflows")).await?;
println!("{} Created project directories", "✓".green());
Ok(())
}
async fn update_package_json(root: &Path) -> Result<()> {
let path = root.join("package.json");
let content = fs::read_to_string(&path)
.await
.context("Failed to read package.json")?;
let mut package: serde_json::Value =
serde_json::from_str(&content).context("Failed to parse package.json")?;
if package.get("scripts").is_none() {
package["scripts"] = serde_json::json!({});
}
if let Some(scripts) = package["scripts"].as_object_mut() {
for &(key, value) in templates::GAJI_SCRIPTS {
scripts
.entry(key.to_string())
.or_insert(serde_json::Value::String(value.to_string()));
}
}
if package.get("devDependencies").is_none() {
package["devDependencies"] = serde_json::json!({});
}
if let Some(dev_deps) = package["devDependencies"].as_object_mut() {
for &(key, value) in templates::GAJI_DEV_DEPS {
dev_deps
.entry(key.to_string())
.or_insert(serde_json::Value::String(value.to_string()));
}
}
let formatted = serde_json::to_string_pretty(&package)?;
fs::write(&path, formatted + "\n").await?;
println!("{} Updated package.json", "✓".green());
Ok(())
}
async fn handle_tsconfig(root: &Path, options: &InitOptions) -> Result<()> {
let path = root.join("tsconfig.json");
if path.exists() {
if options.force {
let backup_path = root.join("tsconfig.json.backup");
fs::copy(&path, &backup_path).await?;
println!(
"{} Backed up tsconfig.json to tsconfig.json.backup",
"✓".green()
);
fs::write(&path, templates::TSCONFIG_TEMPLATE).await?;
println!("{} Created tsconfig.json", "✓".green());
} else {
println!(
"{} tsconfig.json already exists (use --force to overwrite)",
"⚠️ ".yellow()
);
println!(" Consider adding to your compilerOptions:");
println!(" \"include\": [\"workflows/**/*\"]");
}
} else {
fs::write(&path, templates::TSCONFIG_TEMPLATE).await?;
println!("{} Created tsconfig.json", "✓".green());
}
Ok(())
}
async fn ensure_gitignore(root: &Path, may_exist: bool) -> Result<()> {
let path = root.join(".gitignore");
if may_exist && path.exists() {
let content = fs::read_to_string(&path).await?;
if content.contains("# gaji generated files") {
println!("{} .gitignore already has gaji entries", "⏭️ ".dimmed());
return Ok(());
}
let mut new_content = content;
if !new_content.ends_with('\n') {
new_content.push('\n');
}
new_content.push_str(templates::GITIGNORE_SECTION);
fs::write(&path, new_content).await?;
println!("{} Updated .gitignore", "✓".green());
} else {
fs::write(&path, templates::GITIGNORE_SECTION.trim_start()).await?;
println!("{} Created .gitignore", "✓".green());
}
Ok(())
}
async fn create_example_workflow(root: &Path) -> Result<()> {
let path = root.join("workflows/ci.ts");
fs::write(&path, templates::EXAMPLE_WORKFLOW_TEMPLATE).await?;
println!("{} Created example workflow (workflows/ci.ts)", "✓".green());
Ok(())
}
fn create_config(root: &Path) -> Result<()> {
let config_path = root.join(".gaji.toml");
if config_path.exists() {
println!("{} .gaji.toml already exists", "⏭️ ".dimmed());
return Ok(());
}
let config = Config::default();
config.save_to(&config_path)?;
println!("{} Created .gaji.toml", "✓".green());
Ok(())
}
async fn try_generate_initial_types(root: &Path) {
match generate_initial_types(root).await {
Ok(()) => {}
Err(e) => {
eprintln!("{} Could not generate initial types: {}", "⚠️ ".yellow(), e);
eprintln!(" Run 'gaji dev' later to generate types.");
}
}
}
async fn generate_initial_types(root: &Path) -> Result<()> {
let workflows_path = root.join("workflows");
if !workflows_path.exists() {
return Ok(());
}
println!("\n{} Analyzing workflow files...", "🔍".cyan());
let results = parser::analyze_directory(&workflows_path).await?;
let mut all_refs = HashSet::new();
for refs in results.values() {
all_refs.extend(refs.clone());
}
if all_refs.is_empty() {
return Ok(());
}
println!(
"{} Generating types for {} action(s)...",
"📦".cyan(),
all_refs.len()
);
let config = Config::load()?;
let token = config.resolve_token();
let api_url = config.resolve_api_url();
let cache = Cache::load_or_create()?;
let generator = TypeGenerator::new(cache, root.join("generated"), token, api_url);
generator.generate_types_for_refs(&all_refs).await?;
println!("{} Types generated!", "✨".green());
Ok(())
}
fn print_next_steps() {
println!("Next steps:");
println!(" 1. Edit workflows/*.ts");
println!(" 2. Run: gaji dev");
println!(" 3. Run: gaji build");
println!();
println!(
"{} For private repos or GitHub Enterprise, create .gaji.local.toml:",
"💡".yellow()
);
println!(" [github]");
println!(" token = \"ghp_your_token_here\"");
println!(" # api_url = \"https://github.example.com\" # for GitHub Enterprise");
println!();
println!("Learn more: https://github.com/dodok8/gaji");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_empty_project() {
let temp = tempfile::TempDir::new().unwrap();
let state = detect_project_state(temp.path()).unwrap();
assert_eq!(state, ProjectState::Empty);
}
#[test]
fn test_detect_existing_project_with_package_json() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::write(temp.path().join("package.json"), "{}").unwrap();
let state = detect_project_state(temp.path()).unwrap();
assert_eq!(state, ProjectState::ExistingProject);
}
#[test]
fn test_detect_existing_project_with_cargo_toml() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::write(temp.path().join("Cargo.toml"), "[package]").unwrap();
let state = detect_project_state(temp.path()).unwrap();
assert_eq!(state, ProjectState::ExistingProject);
}
#[test]
fn test_detect_existing_project_with_go_mod() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::write(temp.path().join("go.mod"), "module example").unwrap();
let state = detect_project_state(temp.path()).unwrap();
assert_eq!(state, ProjectState::ExistingProject);
}
#[test]
fn test_detect_existing_project_with_git() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::create_dir(temp.path().join(".git")).unwrap();
let state = detect_project_state(temp.path()).unwrap();
assert_eq!(state, ProjectState::ExistingProject);
}
#[test]
fn test_detect_has_workflows() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join(".github/workflows")).unwrap();
std::fs::write(temp.path().join(".github/workflows/ci.yml"), "name: CI").unwrap();
let state = detect_project_state(temp.path()).unwrap();
assert_eq!(state, ProjectState::HasWorkflows);
}
#[test]
fn test_detect_has_workflows_takes_precedence() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::write(temp.path().join("Cargo.toml"), "[package]").unwrap();
std::fs::create_dir_all(temp.path().join(".github/workflows")).unwrap();
std::fs::write(temp.path().join(".github/workflows/ci.yml"), "name: CI").unwrap();
let state = detect_project_state(temp.path()).unwrap();
assert_eq!(state, ProjectState::HasWorkflows);
}
#[tokio::test]
async fn test_create_directories() {
let temp = tempfile::TempDir::new().unwrap();
create_directories(temp.path()).await.unwrap();
assert!(temp.path().join("workflows").is_dir());
assert!(temp.path().join("generated").is_dir());
assert!(temp.path().join(".github/workflows").is_dir());
}
#[tokio::test]
async fn test_update_package_json_preserves_existing() {
let temp = tempfile::TempDir::new().unwrap();
let existing = serde_json::json!({
"name": "my-app",
"scripts": {
"test": "jest",
"gha:dev": "custom-command"
}
});
std::fs::write(
temp.path().join("package.json"),
serde_json::to_string_pretty(&existing).unwrap(),
)
.unwrap();
update_package_json(temp.path()).await.unwrap();
let content = std::fs::read_to_string(temp.path().join("package.json")).unwrap();
let pkg: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(pkg["scripts"]["gha:dev"], "custom-command");
assert_eq!(pkg["scripts"]["gha:build"], "gaji build");
assert_eq!(pkg["scripts"]["test"], "jest");
assert_eq!(pkg["devDependencies"]["tsx"], "^4.0.0");
}
#[tokio::test]
async fn test_ensure_gitignore_create_new() {
let temp = tempfile::TempDir::new().unwrap();
ensure_gitignore(temp.path(), false).await.unwrap();
let content = std::fs::read_to_string(temp.path().join(".gitignore")).unwrap();
assert!(content.contains("# gaji generated files"));
assert!(content.contains("generated/"));
assert!(content.contains(".gaji-cache.json"));
}
#[tokio::test]
async fn test_ensure_gitignore_append() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::write(temp.path().join(".gitignore"), "node_modules/\n").unwrap();
ensure_gitignore(temp.path(), true).await.unwrap();
let content = std::fs::read_to_string(temp.path().join(".gitignore")).unwrap();
assert!(content.starts_with("node_modules/"));
assert!(content.contains("# gaji generated files"));
}
#[tokio::test]
async fn test_ensure_gitignore_idempotent() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::write(
temp.path().join(".gitignore"),
"node_modules/\n# gaji generated files\ngenerated/\n",
)
.unwrap();
ensure_gitignore(temp.path(), true).await.unwrap();
let content = std::fs::read_to_string(temp.path().join(".gitignore")).unwrap();
assert_eq!(content.matches("# gaji generated files").count(), 1);
}
#[tokio::test]
async fn test_create_example_workflow() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join("workflows")).unwrap();
create_example_workflow(temp.path()).await.unwrap();
let content = std::fs::read_to_string(temp.path().join("workflows/ci.ts")).unwrap();
assert!(content.contains("getAction"));
assert!(content.contains("actions/checkout@v5"));
assert!(content.contains("workflow.build"));
}
#[tokio::test]
async fn test_init_new_project_is_minimal() {
let temp = tempfile::TempDir::new().unwrap();
let options = InitOptions {
force: false,
skip_examples: true,
migrate: false,
interactive: false,
};
init_new_project(temp.path(), &options).await.unwrap();
assert!(temp.path().join("workflows").is_dir());
assert!(temp.path().join("generated").is_dir());
assert!(temp.path().join(".github/workflows").is_dir());
assert!(temp.path().join(".gaji.toml").is_file());
assert!(temp.path().join(".gitignore").is_file());
assert!(!temp.path().join("package.json").exists());
assert!(!temp.path().join("tsconfig.json").exists());
}
#[tokio::test]
async fn test_init_existing_project_with_package_json() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::write(
temp.path().join("package.json"),
r#"{"name": "my-app", "scripts": {}}"#,
)
.unwrap();
let options = InitOptions {
force: false,
skip_examples: true,
migrate: false,
interactive: false,
};
init_existing_project(temp.path(), &options).await.unwrap();
let content = std::fs::read_to_string(temp.path().join("package.json")).unwrap();
let pkg: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(pkg["scripts"]["gha:build"], "gaji build");
assert!(temp.path().join("tsconfig.json").is_file());
}
#[tokio::test]
async fn test_init_existing_project_without_package_json() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::write(temp.path().join("Cargo.toml"), "[package]").unwrap();
let options = InitOptions {
force: false,
skip_examples: true,
migrate: false,
interactive: false,
};
init_existing_project(temp.path(), &options).await.unwrap();
assert!(!temp.path().join("package.json").exists());
assert!(!temp.path().join("tsconfig.json").exists());
assert!(temp.path().join(".gaji.toml").is_file());
assert!(temp.path().join("workflows").is_dir());
}
#[test]
fn test_create_config() {
let temp = tempfile::TempDir::new().unwrap();
create_config(temp.path()).unwrap();
assert!(temp.path().join(".gaji.toml").is_file());
let content = std::fs::read_to_string(temp.path().join(".gaji.toml")).unwrap();
assert!(content.contains("workflows_dir"));
}
}