gaji 0.3.2

Type-safe GitHub Actions workflows in TypeScript
Documentation
pub mod templates;
pub mod types;

use std::collections::HashSet;
use std::path::{Path, PathBuf};

use anyhow::Result;
use indicatif::{ProgressBar, ProgressStyle};
use tokio::fs;

use crate::cache::Cache;
use crate::fetcher::GitHubFetcher;

use self::templates::{BASE_TYPES_TEMPLATE, JOB_WORKFLOW_RUNTIME_TEMPLATE};
use self::types::generate_type_definition;

pub struct TypeGenerator {
    fetcher: GitHubFetcher,
    output_dir: PathBuf,
}

impl TypeGenerator {
    pub fn new(
        cache: Cache,
        output_dir: PathBuf,
        token: Option<String>,
        api_url: Option<String>,
    ) -> Self {
        Self::with_cache_ttl(cache, output_dir, token, api_url, 30)
    }

    pub fn with_cache_ttl(
        cache: Cache,
        output_dir: PathBuf,
        token: Option<String>,
        api_url: Option<String>,
        cache_ttl_days: u64,
    ) -> Self {
        Self {
            fetcher: GitHubFetcher::new(cache, token, api_url, cache_ttl_days),
            output_dir,
        }
    }

    pub async fn generate_types_for_refs(
        &self,
        action_refs: &HashSet<String>,
    ) -> Result<Vec<PathBuf>> {
        fs::create_dir_all(&self.output_dir).await?;

        let mut generated_files = Vec::new();

        // Generate base types first
        let base_path = self.generate_base_types().await?;
        generated_files.push(base_path);

        let mut action_infos = Vec::new();

        let pb = ProgressBar::new(action_refs.len() as u64);
        pb.set_style(
            ProgressStyle::default_bar()
                .template("   {spinner:.green} [{bar:30.cyan/dim}] {pos}/{len} {msg}")
                .unwrap()
                .progress_chars("━━─"),
        );

        for action_ref in action_refs {
            pb.set_message(action_ref.clone());
            match self.generate_type_for_ref(action_ref).await {
                Ok((path, info)) => {
                    generated_files.push(path);
                    action_infos.push(info);
                }
                Err(e) => {
                    pb.suspend(|| {
                        eprintln!("Failed to generate types for {}: {}", action_ref, e);
                    });
                }
            }
            pb.inc(1);
        }

        pb.finish_and_clear();

        // Remove old index.ts if it exists (replaced by index.d.ts + index.js)
        let old_index_ts = self.output_dir.join("index.ts");
        if old_index_ts.exists() {
            let _ = fs::remove_file(&old_index_ts).await;
        }

        // Generate index.d.ts (type declarations) and index.js (runtime)
        self.generate_index_dts(&action_infos).await?;
        self.generate_index_js(&action_infos).await?;

        Ok(generated_files)
    }

    async fn generate_base_types(&self) -> Result<PathBuf> {
        let content = BASE_TYPES_TEMPLATE.to_string();

        let file_path = self.output_dir.join("base.d.ts");
        fs::write(&file_path, content).await?;

        Ok(file_path)
    }

    async fn generate_type_for_ref(&self, action_ref: &str) -> Result<(PathBuf, ActionTypeInfo)> {
        let metadata = self.fetcher.fetch_action_metadata(action_ref).await?;
        let type_def = generate_type_definition(action_ref, &metadata);

        let interface_name = action_ref_to_interface_name(action_ref);
        let module_name = action_ref_to_module_name(action_ref);
        let has_outputs = metadata
            .outputs
            .as_ref()
            .map(|outputs| !outputs.is_empty())
            .unwrap_or(false);

        let filename = action_ref_to_filename(action_ref);
        let file_path = self.output_dir.join(&filename);

        fs::write(&file_path, type_def).await?;

        Ok((
            file_path,
            ActionTypeInfo {
                action_ref: action_ref.to_string(),
                interface_name,
                module_name,
                has_outputs,
            },
        ))
    }

    /// Generate index.d.ts - type declarations only
    async fn generate_index_dts(&self, action_infos: &[ActionTypeInfo]) -> Result<()> {
        let mut sorted_infos = action_infos.to_vec();
        sorted_infos.sort_by(|left, right| left.action_ref.cmp(&right.action_ref));

        let mut content = String::new();
        content.push_str("// Auto-generated by gaji\n// Do not edit manually\n\n");

        // Type imports
        content.push_str("import type { JobStep, JobDefinition, WorkflowConfig, WorkflowDefinition, Step, Permissions, Service, Container, WorkflowTrigger, ScheduleTrigger, WorkflowDispatchInput, WorkflowOn, ActionInputDefinition, ActionOutputDefinition, JavaScriptActionConfig, JavaScriptActionRuns } from './base';\n");

        for info in &sorted_infos {
            content.push_str(&format!(
                "import type {{ {}Inputs }} from './{}';",
                info.interface_name, info.module_name
            ));
            content.push('\n');
        }

        content.push('\n');

        // getAction overloads (declare — no body in .d.ts)
        for info in &sorted_infos {
            content.push_str(&format!(
                r#"export declare function getAction(
    ref: '{}'
): (config?: {{
    name?: string;
    with?: {}Inputs;
    id?: string;
    if?: string;
    env?: Record<string, string>;
}}) => JobStep;
"#,
                info.action_ref, info.interface_name
            ));
        }

        // Base getAction overload (declaration only, no body)
        content.push_str(
            r#"
export declare function getAction<T extends string>(
    ref: T
): (config?: {
    name?: string;
    with?: Record<string, unknown>;
    id?: string;
    if?: string;
    env?: Record<string, string>;
}) => JobStep;
"#,
        );

        // Job class declaration
        content.push_str(r#"
export declare class Job {
    constructor(runsOn: string | string[], options?: Partial<JobDefinition>);
    addStep(step: JobStep): this;
    needs(deps: string | string[]): this;
    env(env: Record<string, string>): this;
    when(condition: string): this;
    permissions(perms: Permissions): this;
    outputs(outputs: Record<string, string>): this;
    strategy(s: { matrix?: Record<string, unknown>; 'fail-fast'?: boolean; 'max-parallel'?: number }): this;
    continueOnError(v: boolean): this;
    timeoutMinutes(m: number): this;
    toJSON(): JobDefinition;
}

export declare class CompositeJob extends Job {
    constructor(runsOn: string | string[], options?: Partial<JobDefinition>);
}

export declare class Workflow {
    constructor(config: WorkflowConfig);
    addJob(id: string, job: Job | CompositeJob | CallJob): this;
    static fromObject(def: WorkflowDefinition, id?: string): Workflow;
    toJSON(): WorkflowDefinition;
    build(id?: string): void;
}

export declare class CompositeAction {
    constructor(config: { name: string; description: string; inputs?: Record<string, unknown>; outputs?: Record<string, unknown> });
    addStep(step: JobStep): this;
    toJSON(): object;
    build(id?: string): void;
}

export declare class JavaScriptAction {
    constructor(config: JavaScriptActionConfig, runs: JavaScriptActionRuns);
    toJSON(): object;
    build(id?: string): void;
}

export declare class CallJob {
    constructor(uses: string);
    with(inputs: Record<string, unknown>): this;
    secrets(s: Record<string, unknown> | 'inherit'): this;
    needs(deps: string | string[]): this;
    when(condition: string): this;
    permissions(perms: Permissions): this;
    toJSON(): object;
}

export declare class CallAction {
    constructor(uses: string);
    static from(action: CompositeAction | JavaScriptAction): CallAction;
    toJSON(): JobStep;
}
"#);

        // Type re-exports
        content.push_str("\nexport type { JobStep, Step, JobDefinition, Service, Container, Permissions, WorkflowTrigger, ScheduleTrigger, WorkflowDispatchInput, WorkflowOn, WorkflowConfig, WorkflowDefinition, ActionInputDefinition, ActionOutputDefinition, JavaScriptActionConfig, JavaScriptActionRuns } from './base';\n");
        for info in &sorted_infos {
            if info.has_outputs {
                content.push_str(&format!(
                    "export type {{ {}Inputs, {}Outputs }} from './{}';",
                    info.interface_name, info.interface_name, info.module_name
                ));
            } else {
                content.push_str(&format!(
                    "export type {{ {}Inputs }} from './{}';",
                    info.interface_name, info.module_name
                ));
            }
            content.push('\n');
        }

        let path = self.output_dir.join("index.d.ts");
        fs::write(path, content).await?;

        Ok(())
    }

    /// Generate index.js - runtime implementation
    async fn generate_index_js(&self, action_infos: &[ActionTypeInfo]) -> Result<()> {
        let mut sorted_infos = action_infos.to_vec();
        sorted_infos.sort_by(|left, right| left.action_ref.cmp(&right.action_ref));

        let mut content = String::new();
        content.push_str("// Auto-generated by gaji\n// Do not edit manually\n\n");

        // getAction runtime implementation
        content.push_str(
            r#"export function getAction(ref) {
    return function(config) {
        if (config === undefined) config = {};
        var step = {
            uses: ref,
        };
        if (config.name !== undefined) step.name = config.name;
        if (config.with !== undefined) step.with = config.with;
        if (config.id !== undefined) step.id = config.id;
        if (config["if"] !== undefined) step["if"] = config["if"];
        if (config.env !== undefined) step.env = config.env;
        return step;
    };
}
"#,
        );

        // Job/Workflow/CompositeAction/CallJob/CallAction runtime classes
        content.push_str(JOB_WORKFLOW_RUNTIME_TEMPLATE);
        content.push('\n');

        let path = self.output_dir.join("index.js");
        fs::write(path, content).await?;

        Ok(())
    }
}

#[derive(Clone)]
struct ActionTypeInfo {
    action_ref: String,
    interface_name: String,
    module_name: String,
    has_outputs: bool,
}

pub fn action_ref_to_filename(action_ref: &str) -> String {
    action_ref.replace(['/', '@', '.'], "-") + ".d.ts"
}

pub fn action_ref_to_interface_name(action_ref: &str) -> String {
    // "actions/checkout@v5" -> "ActionsCheckoutV5"
    action_ref
        .split(['/', '@', '-', '.'])
        .filter(|s| !s.is_empty())
        .map(|s| {
            let mut chars = s.chars();
            match chars.next() {
                None => String::new(),
                Some(first) => first.to_uppercase().chain(chars).collect(),
            }
        })
        .collect()
}

fn action_ref_to_module_name(action_ref: &str) -> String {
    action_ref_to_filename(action_ref)
        .trim_end_matches(".d.ts")
        .to_string()
}

pub async fn ensure_generated_dir(path: &Path) -> Result<PathBuf> {
    let dir = path.to_path_buf();
    fs::create_dir_all(&dir).await?;
    Ok(dir)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_action_ref_to_filename() {
        assert_eq!(
            action_ref_to_filename("actions/checkout@v5"),
            "actions-checkout-v5.d.ts"
        );
        assert_eq!(
            action_ref_to_filename("owner/repo/path@main"),
            "owner-repo-path-main.d.ts"
        );
    }

    #[test]
    fn test_action_ref_to_interface_name() {
        assert_eq!(
            action_ref_to_interface_name("actions/checkout@v5"),
            "ActionsCheckoutV5"
        );
        assert_eq!(
            action_ref_to_interface_name("actions/setup-node@v4"),
            "ActionsSetupNodeV4"
        );
    }
}