use handlebars::Handlebars;
use serde::Serialize;
use thiserror::Error;
use crate::resources::ResourceKind;
#[derive(Debug, Error)]
pub enum TemplateError {
#[error("Template error: {0}")]
Render(#[from] handlebars::RenderError),
#[error("Template registration error: {0}")]
Registration(#[from] Box<handlebars::TemplateError>),
}
#[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,
}
#[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>,
}
pub struct ReadmeGenerator {
handlebars: Handlebars<'static>,
}
impl ReadmeGenerator {
pub fn new() -> Result<Self, TemplateError> {
let mut handlebars = Handlebars::new();
handlebars.set_strict_mode(true);
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 })
}
pub fn generate_project_readme(&self, ctx: &ProjectContext) -> Result<String, TemplateError> {
Ok(self.handlebars.render("project", ctx)?)
}
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")
}
}
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
);
}
}
}