kazam 1.13.2

Local infrastructure for coding agents: context, visibility, durable execution. One Rust binary, no cloud.
use anyhow::{bail, Result};
use std::fs;
use std::path::Path;

// ── Templates ────────────────────────────────────────────────────────────────

const VALIDATE_TEMPLATE: &str = r#"name: Validate kazam site
on:
  push:
    paths: ['**.yaml']
  pull_request:
    paths: ['**.yaml']

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install kazam
        run: cargo install --git https://github.com/tdiderich/kazam
      - name: Validate
        run: kazam validate .
"#;

const FRESHNESS_TEMPLATE: &str = r#"name: Freshness check
on:
  schedule:
    - cron: '0 9 * * 1'  # Every Monday at 9am UTC
  workflow_dispatch:

jobs:
  freshness:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install kazam
        run: cargo install --git https://github.com/tdiderich/kazam
      - name: Check freshness
        id: freshness
        run: |
          kazam freshness . > freshness.json
          echo "report<<EOF" >> $GITHUB_OUTPUT
          kazam freshness . --pretty >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT
      - name: Create issue if stale pages found
        if: steps.freshness.outputs.report != ''
        uses: actions/github-script@v7
        with:
          script: |
            const report = `${{ steps.freshness.outputs.report }}`;
            if (report.includes('overdue') || report.includes('due_soon')) {
              await github.rest.issues.create({
                owner: context.repo.owner,
                repo: context.repo.repo,
                title: `📚 Weekly freshness report - ${new Date().toISOString().split('T')[0]}`,
                body: '## Freshness Report\n\n```\n' + report + '\n```\n\nGenerated by `kazam freshness`.',
                labels: ['documentation', 'freshness']
              });
            }
"#;

const BUILD_TEMPLATE: &str = r#"name: Build kazam site
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install kazam
        run: cargo install --git https://github.com/tdiderich/kazam
      - name: Build
        run: kazam build . --out _site --release --json > build.log
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: _site/
"#;

// ── Template registry ─────────────────────────────────────────────────────────

struct Template {
    name: &'static str,
    description: &'static str,
    content: &'static str,
}

const TEMPLATES: &[Template] = &[
    Template {
        name: "validate",
        description: "Run kazam validate on every push/PR that touches YAML",
        content: VALIDATE_TEMPLATE,
    },
    Template {
        name: "freshness",
        description: "Weekly freshness report posted as a GitHub issue",
        content: FRESHNESS_TEMPLATE,
    },
    Template {
        name: "build",
        description: "Build site and upload artifact on push to main",
        content: BUILD_TEMPLATE,
    },
];

fn find_template(name: &str) -> Option<&'static Template> {
    TEMPLATES.iter().find(|t| t.name == name)
}

// ── Commands ──────────────────────────────────────────────────────────────────

pub fn list() -> Result<()> {
    println!("Available action templates:");
    for t in TEMPLATES {
        println!("  {:<12}— {}", t.name, t.description);
    }
    Ok(())
}

pub fn init(name: &str, project_dir: &Path) -> Result<()> {
    let Some(template) = find_template(name) else {
        let names: Vec<&str> = TEMPLATES.iter().map(|t| t.name).collect();
        bail!(
            "Unknown template '{}'. Available: {}",
            name,
            names.join(", ")
        );
    };

    let workflows_dir = project_dir.join(".github").join("workflows");
    fs::create_dir_all(&workflows_dir)?;

    let file_path = workflows_dir.join(format!("kazam-{}.yml", name));

    if file_path.exists() {
        println!("File already exists: {}", file_path.display());
        println!("Remove the file first if you want to regenerate it.");
        return Ok(());
    }

    fs::write(&file_path, template.content)?;
    println!("Created: {}", file_path.display());

    Ok(())
}

// ── Tests ─────────────────────────────────────────────────────────────────────

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

    fn tmp_dir(label: &str) -> std::path::PathBuf {
        let dir = std::env::temp_dir().join("kazam_actions_tests").join(label);
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn list_returns_all_three_templates() {
        assert_eq!(TEMPLATES.len(), 3);
        let names: Vec<&str> = TEMPLATES.iter().map(|t| t.name).collect();
        assert!(names.contains(&"validate"));
        assert!(names.contains(&"freshness"));
        assert!(names.contains(&"build"));
    }

    #[test]
    fn validate_template_contains_expected_strings() {
        let t = find_template("validate").expect("validate template missing");
        assert!(t.content.contains("kazam validate ."));
        assert!(t.content.contains("actions/checkout@v4"));
        assert!(t.content.contains("cargo install --git"));
    }

    #[test]
    fn freshness_template_contains_expected_strings() {
        let t = find_template("freshness").expect("freshness template missing");
        assert!(t.content.contains("kazam freshness ."));
        assert!(t.content.contains("schedule"));
        assert!(t.content.contains("workflow_dispatch"));
    }

    #[test]
    fn build_template_contains_expected_strings() {
        let t = find_template("build").expect("build template missing");
        assert!(t.content.contains("kazam build ."));
        assert!(t.content.contains("upload-pages-artifact"));
        assert!(t.content.contains("branches: [main]"));
    }

    #[test]
    fn init_creates_file() {
        let dir = tmp_dir("init_creates_file");
        init("validate", &dir).unwrap();
        let expected = dir.join(".github/workflows/kazam-validate.yml");
        assert!(expected.exists());
        let content = fs::read_to_string(&expected).unwrap();
        assert!(content.contains("kazam validate ."));
    }

    #[test]
    fn init_twice_does_not_overwrite() {
        let dir = tmp_dir("init_twice_does_not_overwrite");
        init("validate", &dir).unwrap();

        // Overwrite with sentinel content
        let file_path = dir.join(".github/workflows/kazam-validate.yml");
        fs::write(&file_path, "SENTINEL").unwrap();

        // Second init should not overwrite
        init("validate", &dir).unwrap();
        let content = fs::read_to_string(&file_path).unwrap();
        assert_eq!(content, "SENTINEL");
    }

    #[test]
    fn init_unknown_template_returns_error() {
        let dir = tmp_dir("init_unknown_template_returns_error");
        let result = init("nonexistent", &dir);
        assert!(result.is_err());
    }
}