1use handlebars::Handlebars;
4use serde::Serialize;
5use thiserror::Error;
6
7use crate::resources::ResourceKind;
8
9#[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#[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#[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
51pub 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 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 pub fn generate_project_readme(&self, ctx: &ProjectContext) -> Result<String, TemplateError> {
74 Ok(self.handlebars.render("project", ctx)?)
75 }
76
77 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
92pub 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}