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 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(§ion.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
59fn render_section(section: &Section, format: OutputFormat, state: &ProjectState) -> Result<String> {
61 let template = match section.formats.get(format) {
63 Some(t) => t,
64 None => return Ok(String::new()), };
66
67 if let Some(ref data_config) = section.data {
68 let items = get_dynamic_data(data_config, state);
70 if items.is_empty() {
71 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 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 if let Some(ref header) = template.header {
95 output.push_str(header);
96 }
97
98 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 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 result = remove_empty_conditionals(&result);
164
165 result
166}
167
168fn remove_empty_conditionals(s: &str) -> String {
169 let mut result = s.to_string();
171
172 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 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 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#[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}