Skip to main content

hoist_core/
templates.rs

1//! README generation templates
2
3use handlebars::Handlebars;
4use serde::Serialize;
5use thiserror::Error;
6
7use crate::resources::ResourceKind;
8
9/// Template errors
10#[derive(Debug, Error)]
11pub enum TemplateError {
12    #[error("Template error: {0}")]
13    Render(#[from] handlebars::RenderError),
14    #[error("Template registration error: {0}")]
15    Registration(#[from] Box<handlebars::TemplateError>),
16}
17
18/// Template context for main README
19#[derive(Debug, Serialize)]
20pub struct ProjectContext {
21    pub name: String,
22    pub description: Option<String>,
23    pub service_name: String,
24    pub resource_counts: Vec<ResourceCount>,
25    pub generated_at: String,
26}
27
28#[derive(Debug, Serialize)]
29pub struct ResourceCount {
30    pub kind: String,
31    pub directory: String,
32    pub count: usize,
33}
34
35/// Template context for resource-type README
36#[derive(Debug, Serialize)]
37pub struct ResourceTypeContext {
38    pub kind: String,
39    pub kind_plural: String,
40    pub resources: Vec<ResourceSummary>,
41    pub description: String,
42}
43
44#[derive(Debug, Serialize)]
45pub struct ResourceSummary {
46    pub name: String,
47    pub description: Option<String>,
48    pub dependencies: Vec<String>,
49}
50
51/// README generator
52pub struct ReadmeGenerator {
53    handlebars: Handlebars<'static>,
54}
55
56impl ReadmeGenerator {
57    pub fn new() -> Result<Self, TemplateError> {
58        let mut handlebars = Handlebars::new();
59        handlebars.set_strict_mode(true);
60
61        // Register templates
62        handlebars
63            .register_template_string("project", PROJECT_README_TEMPLATE)
64            .map_err(Box::new)?;
65        handlebars
66            .register_template_string("resource_type", RESOURCE_TYPE_README_TEMPLATE)
67            .map_err(Box::new)?;
68
69        Ok(Self { handlebars })
70    }
71
72    /// Generate main project README
73    pub fn generate_project_readme(&self, ctx: &ProjectContext) -> Result<String, TemplateError> {
74        Ok(self.handlebars.render("project", ctx)?)
75    }
76
77    /// Generate resource-type README
78    pub fn generate_resource_readme(
79        &self,
80        ctx: &ResourceTypeContext,
81    ) -> Result<String, TemplateError> {
82        Ok(self.handlebars.render("resource_type", ctx)?)
83    }
84}
85
86impl Default for ReadmeGenerator {
87    fn default() -> Self {
88        Self::new().expect("Failed to create ReadmeGenerator")
89    }
90}
91
92/// Get description for a resource kind
93pub fn resource_kind_description(kind: ResourceKind) -> &'static str {
94    match kind {
95        ResourceKind::Index => {
96            "Indexes define the schema for searchable content, including fields, analyzers, and scoring profiles."
97        }
98        ResourceKind::Indexer => {
99            "Indexers automate data ingestion from supported data sources into search indexes."
100        }
101        ResourceKind::DataSource => {
102            "Data sources define connections to external data stores for indexer ingestion."
103        }
104        ResourceKind::Skillset => {
105            "Skillsets define AI enrichment pipelines that process content during indexing."
106        }
107        ResourceKind::SynonymMap => {
108            "Synonym maps define equivalent terms to improve search relevance."
109        }
110        ResourceKind::Alias => {
111            "Aliases provide stable endpoint names that point to indexes for zero-downtime reindexing."
112        }
113        ResourceKind::KnowledgeBase => {
114            "Knowledge bases (preview) provide structured knowledge for AI agent interactions."
115        }
116        ResourceKind::KnowledgeSource => {
117            "Knowledge sources (preview) connect indexes to knowledge bases for agentic search."
118        }
119        ResourceKind::Agent => {
120            "Agents define Microsoft Foundry assistant configurations including instructions, tools, and knowledge."
121        }
122    }
123}
124
125const PROJECT_README_TEMPLATE: &str = r#"# {{name}}
126
127{{#if description}}
128{{description}}
129
130{{/if}}
131Azure AI Search configuration managed by [hoist](https://github.com/mklab-se/hoist).
132
133## Service
134
135- **Service name**: `{{service_name}}`
136
137## Resources
138
139| Type | Directory | Count |
140|------|-----------|-------|
141{{#each resource_counts}}
142| {{kind}} | [`{{directory}}/`](./{{directory}}/) | {{count}} |
143{{/each}}
144
145## Usage
146
147```bash
148# Pull latest configuration from Azure
149hoist pull
150
151# Show differences between local and remote
152hoist diff
153
154# Push local changes to Azure
155hoist push --dry-run
156hoist push
157```
158
159---
160
161*Generated by hoist on {{generated_at}}*
162"#;
163
164const RESOURCE_TYPE_README_TEMPLATE: &str = r#"# {{kind_plural}}
165
166{{description}}
167
168## Resources
169
170{{#each resources}}
171### {{name}}
172
173{{#if description}}
174{{description}}
175
176{{/if}}
177{{#if dependencies}}
178**Dependencies:**
179{{#each dependencies}}
180- {{this}}
181{{/each}}
182{{/if}}
183
184{{/each}}
185"#;
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::resources::ResourceKind;
191
192    #[test]
193    fn test_readme_generator_creation() {
194        let gen = ReadmeGenerator::new();
195        assert!(gen.is_ok());
196    }
197
198    #[test]
199    fn test_project_readme_with_description() {
200        let gen = ReadmeGenerator::new().unwrap();
201        let ctx = ProjectContext {
202            name: "my-search".to_string(),
203            description: Some("A search project for testing".to_string()),
204            service_name: "my-search-svc".to_string(),
205            resource_counts: vec![],
206            generated_at: "2025-01-01".to_string(),
207        };
208        let output = gen.generate_project_readme(&ctx).unwrap();
209        assert!(output.contains("my-search"));
210        assert!(output.contains("A search project for testing"));
211        assert!(output.contains("my-search-svc"));
212    }
213
214    #[test]
215    fn test_project_readme_without_description() {
216        let gen = ReadmeGenerator::new().unwrap();
217        let ctx = ProjectContext {
218            name: "my-search".to_string(),
219            description: None,
220            service_name: "my-search-svc".to_string(),
221            resource_counts: vec![],
222            generated_at: "2025-01-01".to_string(),
223        };
224        let output = gen.generate_project_readme(&ctx).unwrap();
225        assert!(output.contains("my-search"));
226        assert!(output.contains("my-search-svc"));
227    }
228
229    #[test]
230    fn test_project_readme_resource_counts() {
231        let gen = ReadmeGenerator::new().unwrap();
232        let ctx = ProjectContext {
233            name: "my-search".to_string(),
234            description: None,
235            service_name: "svc".to_string(),
236            resource_counts: vec![
237                ResourceCount {
238                    kind: "Index".to_string(),
239                    directory: "search-management/indexes".to_string(),
240                    count: 3,
241                },
242                ResourceCount {
243                    kind: "Skillset".to_string(),
244                    directory: "search-management/skillsets".to_string(),
245                    count: 1,
246                },
247            ],
248            generated_at: "2025-01-01".to_string(),
249        };
250        let output = gen.generate_project_readme(&ctx).unwrap();
251        assert!(output.contains("Index"));
252        assert!(output.contains("search-management/indexes"));
253        assert!(output.contains("3"));
254        assert!(output.contains("Skillset"));
255        assert!(output.contains("search-management/skillsets"));
256        assert!(output.contains("1"));
257    }
258
259    #[test]
260    fn test_resource_readme_with_dependencies() {
261        let gen = ReadmeGenerator::new().unwrap();
262        let ctx = ResourceTypeContext {
263            kind: "Indexer".to_string(),
264            kind_plural: "Indexers".to_string(),
265            resources: vec![ResourceSummary {
266                name: "my-indexer".to_string(),
267                description: Some("Indexes documents".to_string()),
268                dependencies: vec![
269                    "index/my-index".to_string(),
270                    "data-source/my-ds".to_string(),
271                ],
272            }],
273            description: "Indexers automate data ingestion.".to_string(),
274        };
275        let output = gen.generate_resource_readme(&ctx).unwrap();
276        assert!(output.contains("Indexers"));
277        assert!(output.contains("my-indexer"));
278        assert!(output.contains("Indexes documents"));
279        assert!(output.contains("index/my-index"));
280        assert!(output.contains("data-source/my-ds"));
281        assert!(output.contains("Dependencies"));
282    }
283
284    #[test]
285    fn test_resource_readme_empty_resources() {
286        let gen = ReadmeGenerator::new().unwrap();
287        let ctx = ResourceTypeContext {
288            kind: "Index".to_string(),
289            kind_plural: "Indexes".to_string(),
290            resources: vec![],
291            description: "Indexes define the schema.".to_string(),
292        };
293        let output = gen.generate_resource_readme(&ctx).unwrap();
294        assert!(output.contains("Indexes"));
295        assert!(output.contains("Indexes define the schema."));
296    }
297
298    #[test]
299    fn test_resource_kind_description_all_kinds() {
300        for kind in ResourceKind::all() {
301            let desc = resource_kind_description(*kind);
302            assert!(
303                !desc.is_empty(),
304                "Description for {:?} should not be empty",
305                kind
306            );
307        }
308    }
309}