acp/primer/
renderer.rs

1//! @acp:module "Primer Renderer"
2//! @acp:summary "Template rendering for 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    if format == OutputFormat::Json {
43        return render_json(sections);
44    }
45
46    let mut output = String::new();
47
48    for section in sections {
49        let rendered = render_section(&section.section, format, project_state)?;
50        if !rendered.is_empty() {
51            output.push_str(&rendered);
52            output.push_str("\n\n");
53        }
54    }
55
56    Ok(output.trim().to_string())
57}
58
59/// Render a single section
60fn render_section(section: &Section, format: OutputFormat, state: &ProjectState) -> Result<String> {
61    // Get template with fallback chain
62    let template = match section.formats.get(format) {
63        Some(t) => t,
64        None => return Ok(String::new()), // No template available
65    };
66
67    if let Some(ref data_config) = section.data {
68        // Dynamic section with list data
69        let items = get_dynamic_data(data_config, state);
70        if items.is_empty() {
71            // Check empty behavior
72            let empty_behavior = data_config.empty_behavior.as_deref().unwrap_or("exclude");
73            match empty_behavior {
74                "exclude" => return Ok(String::new()),
75                "placeholder" => return Ok(template.empty_template.clone().unwrap_or_default()),
76                _ => return Ok(String::new()),
77            }
78        }
79        render_list_template(template, &items)
80    } else {
81        // Static section
82        render_static_template(template)
83    }
84}
85
86fn render_static_template(template: &FormatTemplate) -> Result<String> {
87    Ok(template.template.clone().unwrap_or_default())
88}
89
90fn render_list_template(template: &FormatTemplate, items: &[DynamicItem]) -> Result<String> {
91    let mut output = String::new();
92
93    // Add header if present
94    if let Some(ref header) = template.header {
95        output.push_str(header);
96    }
97
98    // Render each item
99    let separator = template.separator.as_deref().unwrap_or("\n");
100    let item_template = template.item_template.as_deref().unwrap_or("{{item}}");
101
102    let rendered_items: Vec<String> = items
103        .iter()
104        .map(|item| render_item(item_template, item))
105        .collect();
106
107    output.push_str(&rendered_items.join(separator));
108
109    // Add footer if present
110    if let Some(ref footer) = template.footer {
111        output.push_str(footer);
112    }
113
114    Ok(output)
115}
116
117fn render_item(template: &str, item: &DynamicItem) -> String {
118    let mut result = template.to_string();
119
120    match item {
121        DynamicItem::ProtectedFile(file) => {
122            result = result.replace("{{path}}", &file.path);
123            result = result.replace("{{level}}", &file.level);
124            result = result.replace("{{#if reason}}", "");
125            result = result.replace("{{/if}}", "");
126            result = result.replace("{{reason}}", file.reason.as_deref().unwrap_or(""));
127        }
128        DynamicItem::Domain(domain) => {
129            result = result.replace("{{name}}", &domain.name);
130            result = result.replace("{{pattern}}", &domain.pattern);
131            result = result.replace(
132                "{{description}}",
133                domain.description.as_deref().unwrap_or(""),
134            );
135        }
136        DynamicItem::Layer(layer) => {
137            result = result.replace("{{name}}", &layer.name);
138            result = result.replace("{{pattern}}", &layer.pattern);
139        }
140        DynamicItem::EntryPoint(entry) => {
141            result = result.replace("{{name}}", &entry.name);
142            result = result.replace("{{path}}", &entry.path);
143        }
144        DynamicItem::Variable(var) => {
145            result = result.replace("{{name}}", &var.name);
146            result = result.replace("{{value}}", &var.value);
147            result = result.replace("{{description}}", var.description.as_deref().unwrap_or(""));
148        }
149        DynamicItem::Attempt(attempt) => {
150            result = result.replace("{{id}}", &attempt.id);
151            result = result.replace("{{problem}}", &attempt.problem);
152            result = result.replace("{{attemptCount}}", &attempt.attempt_count.to_string());
153        }
154        DynamicItem::Hack(hack) => {
155            result = result.replace("{{file}}", &hack.file);
156            result = result.replace("{{reason}}", &hack.reason);
157            result = result.replace("{{expires}}", hack.expires.as_deref().unwrap_or(""));
158        }
159    }
160
161    // Simple conditional removal for {{#if ...}}...{{/if}} blocks with empty values
162    // This is a simplified version - a full Handlebars implementation would be more robust
163    result = remove_empty_conditionals(&result);
164
165    result
166}
167
168fn remove_empty_conditionals(s: &str) -> String {
169    // Very simple: if the result still has {{#if and {{/if}}, just strip them
170    let mut result = s.to_string();
171
172    // Remove empty {{#if reason}}...{{/if}} blocks
173    while let Some(start) = result.find("{{#if ") {
174        if let Some(end) = result[start..].find("{{/if}}") {
175            let block = &result[start..start + end + 7];
176            // Check if the conditional content is empty
177            let content_start = block.find("}}").map(|i| i + 2).unwrap_or(0);
178            let content_end = block.rfind("{{/if}}").unwrap_or(block.len());
179            let content = &block[content_start..content_end];
180            if content.trim().is_empty() || content.contains(": {{") {
181                result = result.replace(block, "");
182            } else {
183                // Keep the content, remove the conditional markers
184                let clean_content = content.to_string();
185                result = result.replace(block, &clean_content);
186            }
187        } else {
188            break;
189        }
190    }
191
192    result
193}
194
195fn render_json(sections: &[SelectedSection]) -> Result<String> {
196    #[derive(serde::Serialize)]
197    struct JsonOutput {
198        total_tokens: u32,
199        sections_included: usize,
200        sections: Vec<JsonSection>,
201    }
202
203    #[derive(serde::Serialize)]
204    struct JsonSection {
205        id: String,
206        category: String,
207        tokens: u32,
208        value: f64,
209    }
210
211    let output = JsonOutput {
212        total_tokens: sections.iter().map(|s| s.tokens).sum(),
213        sections_included: sections.len(),
214        sections: sections
215            .iter()
216            .map(|s| JsonSection {
217                id: s.id.clone(),
218                category: s.section.category.clone(),
219                tokens: s.tokens,
220                value: s.value,
221            })
222            .collect(),
223    };
224
225    serde_json::to_string_pretty(&output).map_err(Into::into)
226}
227
228/// Dynamic item types for rendering
229#[derive(Debug, Clone)]
230pub enum DynamicItem {
231    ProtectedFile(super::condition::ProtectedFile),
232    Domain(super::condition::DomainInfo),
233    Layer(super::condition::LayerInfo),
234    EntryPoint(super::condition::EntryPointInfo),
235    Variable(super::condition::VariableInfo),
236    Attempt(super::condition::AttemptInfo),
237    Hack(super::condition::HackInfo),
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_output_format_parse() {
246        assert_eq!(
247            "markdown".parse::<OutputFormat>().unwrap(),
248            OutputFormat::Markdown
249        );
250        assert_eq!(
251            "compact".parse::<OutputFormat>().unwrap(),
252            OutputFormat::Compact
253        );
254        assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
255    }
256
257    #[test]
258    fn test_render_static_template() {
259        let template = FormatTemplate {
260            template: Some("Hello, world!".to_string()),
261            ..Default::default()
262        };
263        assert_eq!(render_static_template(&template).unwrap(), "Hello, world!");
264    }
265}