1use anyhow::{anyhow, Result};
7
8use super::condition::ProjectState;
9use super::dynamic::get_dynamic_data;
10use super::types::*;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum OutputFormat {
15 #[default]
16 Markdown,
17 Compact,
18 Json,
19 Text,
20}
21
22impl std::str::FromStr for OutputFormat {
23 type Err = anyhow::Error;
24
25 fn from_str(s: &str) -> Result<Self, Self::Err> {
26 match s.to_lowercase().as_str() {
27 "markdown" | "md" => Ok(OutputFormat::Markdown),
28 "compact" => Ok(OutputFormat::Compact),
29 "json" => Ok(OutputFormat::Json),
30 "text" | "txt" => Ok(OutputFormat::Text),
31 _ => Err(anyhow!("Unknown output format: {}", s)),
32 }
33 }
34}
35
36pub fn render_primer(
38 sections: &[SelectedSection],
39 format: OutputFormat,
40 project_state: &ProjectState,
41) -> Result<String> {
42 render_primer_with_tier(sections, format, project_state, None)
43}
44
45pub fn render_primer_with_tier(
47 sections: &[SelectedSection],
48 format: OutputFormat,
49 project_state: &ProjectState,
50 tier: Option<PrimerTier>,
51) -> Result<String> {
52 if format == OutputFormat::Json {
53 return render_json_with_tier(sections, tier);
54 }
55
56 let mut output = String::new();
57
58 if format == OutputFormat::Markdown {
60 if let Some(t) = tier {
61 output.push_str(&format!(
62 "<!-- ACP Primer: {} tier (~{} tokens) -->\n\n",
63 t.name(),
64 t.cli_tokens()
65 ));
66 }
67 }
68
69 for section in sections {
70 let rendered = render_section(§ion.section, format, project_state)?;
71 if !rendered.is_empty() {
72 output.push_str(&rendered);
73 output.push_str("\n\n");
74 }
75 }
76
77 Ok(output.trim().to_string())
78}
79
80fn render_section(section: &Section, format: OutputFormat, state: &ProjectState) -> Result<String> {
82 let template = match section.formats.get(format) {
84 Some(t) => t,
85 None => return Ok(String::new()), };
87
88 if let Some(ref data_config) = section.data {
89 let items = get_dynamic_data(data_config, state);
91 if items.is_empty() {
92 let empty_behavior = data_config.empty_behavior.as_deref().unwrap_or("exclude");
94 match empty_behavior {
95 "exclude" => return Ok(String::new()),
96 "placeholder" => return Ok(template.empty_template.clone().unwrap_or_default()),
97 _ => return Ok(String::new()),
98 }
99 }
100 render_list_template(template, &items)
101 } else {
102 render_static_template(template)
104 }
105}
106
107fn render_static_template(template: &FormatTemplate) -> Result<String> {
108 Ok(template.template.clone().unwrap_or_default())
109}
110
111fn render_list_template(template: &FormatTemplate, items: &[DynamicItem]) -> Result<String> {
112 let mut output = String::new();
113
114 if let Some(ref header) = template.header {
116 output.push_str(header);
117 }
118
119 let separator = template.separator.as_deref().unwrap_or("\n");
121 let item_template = template.item_template.as_deref().unwrap_or("{{item}}");
122
123 let rendered_items: Vec<String> = items
124 .iter()
125 .map(|item| render_item(item_template, item))
126 .collect();
127
128 output.push_str(&rendered_items.join(separator));
129
130 if let Some(ref footer) = template.footer {
132 output.push_str(footer);
133 }
134
135 Ok(output)
136}
137
138fn render_item(template: &str, item: &DynamicItem) -> String {
139 let mut result = template.to_string();
140
141 match item {
142 DynamicItem::ProtectedFile(file) => {
143 result = result.replace("{{path}}", &file.path);
144 result = result.replace("{{level}}", &file.level);
145 result = result.replace("{{#if reason}}", "");
146 result = result.replace("{{/if}}", "");
147 result = result.replace("{{reason}}", file.reason.as_deref().unwrap_or(""));
148 }
149 DynamicItem::Domain(domain) => {
150 result = result.replace("{{name}}", &domain.name);
151 result = result.replace("{{pattern}}", &domain.pattern);
152 result = result.replace(
153 "{{description}}",
154 domain.description.as_deref().unwrap_or(""),
155 );
156 }
157 DynamicItem::Layer(layer) => {
158 result = result.replace("{{name}}", &layer.name);
159 result = result.replace("{{pattern}}", &layer.pattern);
160 }
161 DynamicItem::EntryPoint(entry) => {
162 result = result.replace("{{name}}", &entry.name);
163 result = result.replace("{{path}}", &entry.path);
164 }
165 DynamicItem::Variable(var) => {
166 result = result.replace("{{name}}", &var.name);
167 result = result.replace("{{value}}", &var.value);
168 result = result.replace("{{description}}", var.description.as_deref().unwrap_or(""));
169 }
170 DynamicItem::Attempt(attempt) => {
171 result = result.replace("{{id}}", &attempt.id);
172 result = result.replace("{{problem}}", &attempt.problem);
173 result = result.replace("{{attemptCount}}", &attempt.attempt_count.to_string());
174 }
175 DynamicItem::Hack(hack) => {
176 result = result.replace("{{file}}", &hack.file);
177 result = result.replace("{{reason}}", &hack.reason);
178 result = result.replace("{{expires}}", hack.expires.as_deref().unwrap_or(""));
179 }
180 }
181
182 result = remove_empty_conditionals(&result);
185
186 result
187}
188
189fn remove_empty_conditionals(s: &str) -> String {
190 let mut result = s.to_string();
192
193 while let Some(start) = result.find("{{#if ") {
195 if let Some(end) = result[start..].find("{{/if}}") {
196 let block = &result[start..start + end + 7];
197 let content_start = block.find("}}").map(|i| i + 2).unwrap_or(0);
199 let content_end = block.rfind("{{/if}}").unwrap_or(block.len());
200 let content = &block[content_start..content_end];
201 if content.trim().is_empty() || content.contains(": {{") {
202 result = result.replace(block, "");
203 } else {
204 let clean_content = content.to_string();
206 result = result.replace(block, &clean_content);
207 }
208 } else {
209 break;
210 }
211 }
212
213 result
214}
215
216fn render_json_with_tier(sections: &[SelectedSection], tier: Option<PrimerTier>) -> Result<String> {
217 #[derive(serde::Serialize)]
218 struct JsonOutput {
219 #[serde(skip_serializing_if = "Option::is_none")]
220 tier: Option<TierInfo>,
221 total_tokens: u32,
222 sections_included: usize,
223 sections: Vec<JsonSection>,
224 }
225
226 #[derive(serde::Serialize)]
227 struct TierInfo {
228 name: String,
229 target_tokens: u32,
230 description: String,
231 }
232
233 #[derive(serde::Serialize)]
234 struct JsonSection {
235 id: String,
236 category: String,
237 tokens: u32,
238 value: f64,
239 }
240
241 let output = JsonOutput {
242 tier: tier.map(|t| TierInfo {
243 name: t.name().to_string(),
244 target_tokens: t.cli_tokens(),
245 description: t.description().to_string(),
246 }),
247 total_tokens: sections.iter().map(|s| s.tokens).sum(),
248 sections_included: sections.len(),
249 sections: sections
250 .iter()
251 .map(|s| JsonSection {
252 id: s.id.clone(),
253 category: s.section.category.clone(),
254 tokens: s.tokens,
255 value: s.value,
256 })
257 .collect(),
258 };
259
260 serde_json::to_string_pretty(&output).map_err(Into::into)
261}
262
263#[derive(Debug, Clone)]
265pub enum DynamicItem {
266 ProtectedFile(super::condition::ProtectedFile),
267 Domain(super::condition::DomainInfo),
268 Layer(super::condition::LayerInfo),
269 EntryPoint(super::condition::EntryPointInfo),
270 Variable(super::condition::VariableInfo),
271 Attempt(super::condition::AttemptInfo),
272 Hack(super::condition::HackInfo),
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn test_output_format_parse() {
281 assert_eq!(
282 "markdown".parse::<OutputFormat>().unwrap(),
283 OutputFormat::Markdown
284 );
285 assert_eq!(
286 "compact".parse::<OutputFormat>().unwrap(),
287 OutputFormat::Compact
288 );
289 assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
290 }
291
292 #[test]
293 fn test_render_static_template() {
294 let template = FormatTemplate {
295 template: Some("Hello, world!".to_string()),
296 ..Default::default()
297 };
298 assert_eq!(render_static_template(&template).unwrap(), "Hello, world!");
299 }
300}