rigg-core 0.17.0

Core resource types, configuration, and JSON normalization for rigg
Documentation
//! README generation templates

use handlebars::Handlebars;
use serde::Serialize;
use thiserror::Error;

use crate::resources::ResourceKind;

/// Template errors
#[derive(Debug, Error)]
pub enum TemplateError {
    #[error("Template error: {0}")]
    Render(#[from] handlebars::RenderError),
    #[error("Template registration error: {0}")]
    Registration(#[from] Box<handlebars::TemplateError>),
}

/// Template context for main README
#[derive(Debug, Serialize)]
pub struct ProjectContext {
    pub name: String,
    pub description: Option<String>,
    pub service_name: String,
    pub resource_counts: Vec<ResourceCount>,
    pub generated_at: String,
}

#[derive(Debug, Serialize)]
pub struct ResourceCount {
    pub kind: String,
    pub directory: String,
    pub count: usize,
}

/// Template context for resource-type README
#[derive(Debug, Serialize)]
pub struct ResourceTypeContext {
    pub kind: String,
    pub kind_plural: String,
    pub resources: Vec<ResourceSummary>,
    pub description: String,
}

#[derive(Debug, Serialize)]
pub struct ResourceSummary {
    pub name: String,
    pub description: Option<String>,
    pub dependencies: Vec<String>,
}

/// README generator
pub struct ReadmeGenerator {
    handlebars: Handlebars<'static>,
}

impl ReadmeGenerator {
    pub fn new() -> Result<Self, TemplateError> {
        let mut handlebars = Handlebars::new();
        handlebars.set_strict_mode(true);

        // Register templates
        handlebars
            .register_template_string("project", PROJECT_README_TEMPLATE)
            .map_err(Box::new)?;
        handlebars
            .register_template_string("resource_type", RESOURCE_TYPE_README_TEMPLATE)
            .map_err(Box::new)?;

        Ok(Self { handlebars })
    }

    /// Generate main project README
    pub fn generate_project_readme(&self, ctx: &ProjectContext) -> Result<String, TemplateError> {
        Ok(self.handlebars.render("project", ctx)?)
    }

    /// Generate resource-type README
    pub fn generate_resource_readme(
        &self,
        ctx: &ResourceTypeContext,
    ) -> Result<String, TemplateError> {
        Ok(self.handlebars.render("resource_type", ctx)?)
    }
}

impl Default for ReadmeGenerator {
    fn default() -> Self {
        Self::new().expect("Failed to create ReadmeGenerator")
    }
}

/// Get description for a resource kind
pub fn resource_kind_description(kind: ResourceKind) -> &'static str {
    match kind {
        ResourceKind::Index => {
            "Indexes define the schema for searchable content, including fields, analyzers, and scoring profiles."
        }
        ResourceKind::Indexer => {
            "Indexers automate data ingestion from supported data sources into search indexes."
        }
        ResourceKind::DataSource => {
            "Data sources define connections to external data stores for indexer ingestion."
        }
        ResourceKind::Skillset => {
            "Skillsets define AI enrichment pipelines that process content during indexing."
        }
        ResourceKind::SynonymMap => {
            "Synonym maps define equivalent terms to improve search relevance."
        }
        ResourceKind::Alias => {
            "Aliases provide stable endpoint names that point to indexes for zero-downtime reindexing."
        }
        ResourceKind::KnowledgeBase => {
            "Knowledge bases (preview) provide structured knowledge for AI agent interactions."
        }
        ResourceKind::KnowledgeSource => {
            "Knowledge sources (preview) connect indexes to knowledge bases for agentic search."
        }
        ResourceKind::Agent => {
            "Agents define Microsoft Foundry assistant configurations including instructions, tools, and knowledge."
        }
    }
}

const PROJECT_README_TEMPLATE: &str = r#"# {{name}}

{{#if description}}
{{description}}

{{/if}}
Azure AI Search configuration managed by [rigg](https://github.com/mklab-se/rigg).

## Service

- **Service name**: `{{service_name}}`

## Resources

| Type | Directory | Count |
|------|-----------|-------|
{{#each resource_counts}}
| {{kind}} | [`{{directory}}/`](./{{directory}}/) | {{count}} |
{{/each}}

## Usage

```bash
# Pull latest configuration from Azure
rigg pull

# Show differences between local and remote
rigg diff

# Push local changes to Azure
rigg push --dry-run
rigg push
```

---

*Generated by rigg on {{generated_at}}*
"#;

const RESOURCE_TYPE_README_TEMPLATE: &str = r#"# {{kind_plural}}

{{description}}

## Resources

{{#each resources}}
### {{name}}

{{#if description}}
{{description}}

{{/if}}
{{#if dependencies}}
**Dependencies:**
{{#each dependencies}}
- {{this}}
{{/each}}
{{/if}}

{{/each}}
"#;

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

    #[test]
    fn test_readme_generator_creation() {
        let generator = ReadmeGenerator::new();
        assert!(generator.is_ok());
    }

    #[test]
    fn test_project_readme_with_description() {
        let generator = ReadmeGenerator::new().unwrap();
        let ctx = ProjectContext {
            name: "my-search".to_string(),
            description: Some("A search project for testing".to_string()),
            service_name: "my-search-svc".to_string(),
            resource_counts: vec![],
            generated_at: "2025-01-01".to_string(),
        };
        let output = generator.generate_project_readme(&ctx).unwrap();
        assert!(output.contains("my-search"));
        assert!(output.contains("A search project for testing"));
        assert!(output.contains("my-search-svc"));
    }

    #[test]
    fn test_project_readme_without_description() {
        let generator = ReadmeGenerator::new().unwrap();
        let ctx = ProjectContext {
            name: "my-search".to_string(),
            description: None,
            service_name: "my-search-svc".to_string(),
            resource_counts: vec![],
            generated_at: "2025-01-01".to_string(),
        };
        let output = generator.generate_project_readme(&ctx).unwrap();
        assert!(output.contains("my-search"));
        assert!(output.contains("my-search-svc"));
    }

    #[test]
    fn test_project_readme_resource_counts() {
        let generator = ReadmeGenerator::new().unwrap();
        let ctx = ProjectContext {
            name: "my-search".to_string(),
            description: None,
            service_name: "svc".to_string(),
            resource_counts: vec![
                ResourceCount {
                    kind: "Index".to_string(),
                    directory: "indexes".to_string(),
                    count: 3,
                },
                ResourceCount {
                    kind: "Skillset".to_string(),
                    directory: "skillsets".to_string(),
                    count: 1,
                },
            ],
            generated_at: "2025-01-01".to_string(),
        };
        let output = generator.generate_project_readme(&ctx).unwrap();
        assert!(output.contains("Index"));
        assert!(output.contains("indexes"));
        assert!(output.contains("3"));
        assert!(output.contains("Skillset"));
        assert!(output.contains("skillsets"));
        assert!(output.contains("1"));
    }

    #[test]
    fn test_resource_readme_with_dependencies() {
        let generator = ReadmeGenerator::new().unwrap();
        let ctx = ResourceTypeContext {
            kind: "Indexer".to_string(),
            kind_plural: "Indexers".to_string(),
            resources: vec![ResourceSummary {
                name: "my-indexer".to_string(),
                description: Some("Indexes documents".to_string()),
                dependencies: vec![
                    "index/my-index".to_string(),
                    "data-source/my-ds".to_string(),
                ],
            }],
            description: "Indexers automate data ingestion.".to_string(),
        };
        let output = generator.generate_resource_readme(&ctx).unwrap();
        assert!(output.contains("Indexers"));
        assert!(output.contains("my-indexer"));
        assert!(output.contains("Indexes documents"));
        assert!(output.contains("index/my-index"));
        assert!(output.contains("data-source/my-ds"));
        assert!(output.contains("Dependencies"));
    }

    #[test]
    fn test_resource_readme_empty_resources() {
        let generator = ReadmeGenerator::new().unwrap();
        let ctx = ResourceTypeContext {
            kind: "Index".to_string(),
            kind_plural: "Indexes".to_string(),
            resources: vec![],
            description: "Indexes define the schema.".to_string(),
        };
        let output = generator.generate_resource_readme(&ctx).unwrap();
        assert!(output.contains("Indexes"));
        assert!(output.contains("Indexes define the schema."));
    }

    #[test]
    fn test_resource_kind_description_all_kinds() {
        for kind in ResourceKind::all() {
            let desc = resource_kind_description(*kind);
            assert!(
                !desc.is_empty(),
                "Description for {:?} should not be empty",
                kind
            );
        }
    }
}