acp/primer/
renderer.rs

1//! @acp:module "Primer Renderer"
2//! @acp:summary "RFC-0015: Template rendering for tiered primer output"
3//! @acp:domain cli
4//! @acp:layer output
5
6use anyhow::{anyhow, Result};
7
8use super::condition::ProjectState;
9use super::dynamic::get_dynamic_data;
10use super::types::*;
11
12/// Output format for primer
13#[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
36/// Render primer output from selected sections
37pub 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
45/// RFC-0015: Render primer output with tier information
46pub 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    // Add tier header for markdown format if tier is provided
59    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(&section.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
80/// Render a single section
81fn render_section(section: &Section, format: OutputFormat, state: &ProjectState) -> Result<String> {
82    // Get template with fallback chain
83    let template = match section.formats.get(format) {
84        Some(t) => t,
85        None => return Ok(String::new()), // No template available
86    };
87
88    if let Some(ref data_config) = section.data {
89        // Dynamic section with list data
90        let items = get_dynamic_data(data_config, state);
91        if items.is_empty() {
92            // Check empty behavior
93            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        // Static section
103        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    // Add header if present
115    if let Some(ref header) = template.header {
116        output.push_str(header);
117    }
118
119    // Render each item
120    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    // Add footer if present
131    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    // Simple conditional removal for {{#if ...}}...{{/if}} blocks with empty values
183    // This is a simplified version - a full Handlebars implementation would be more robust
184    result = remove_empty_conditionals(&result);
185
186    result
187}
188
189fn remove_empty_conditionals(s: &str) -> String {
190    // Very simple: if the result still has {{#if and {{/if}}, just strip them
191    let mut result = s.to_string();
192
193    // Remove empty {{#if reason}}...{{/if}} blocks
194    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            // Check if the conditional content is empty
198            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                // Keep the content, remove the conditional markers
205                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/// Dynamic item types for rendering
264#[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}