1use std::path::PathBuf;
10
11use anyhow::Result;
12use serde::Serialize;
13
14use crate::cache::Cache;
15use crate::primer::{
16 self, load_primer_config, render_primer, select_sections, CliOverrides, OutputFormat,
17 ProjectState,
18};
19
20#[derive(Debug, Clone)]
22pub struct PrimerOptions {
23 pub budget: u32,
25 pub capabilities: Vec<String>,
27 pub cache: Option<PathBuf>,
29 pub primer_config: Option<PathBuf>,
31 pub format: OutputFormat,
33 pub json: bool,
35 pub preset: Option<String>,
37 pub include: Vec<String>,
39 pub exclude: Vec<String>,
41 pub categories: Vec<String>,
43 pub no_dynamic: bool,
45 pub explain: bool,
47 pub list_sections: bool,
49 pub list_presets: bool,
51 pub preview: bool,
53}
54
55impl Default for PrimerOptions {
56 fn default() -> Self {
57 Self {
58 budget: 200,
59 capabilities: vec![],
60 cache: None,
61 primer_config: None,
62 format: OutputFormat::Markdown,
63 json: false,
64 preset: None,
65 include: vec![],
66 exclude: vec![],
67 categories: vec![],
68 no_dynamic: false,
69 explain: false,
70 list_sections: false,
71 list_presets: false,
72 preview: false,
73 }
74 }
75}
76
77#[derive(Debug, Clone, Serialize)]
79pub struct PrimerOutput {
80 pub total_tokens: u32,
81 pub tier: String,
82 pub sections_included: usize,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub selection_reasoning: Option<Vec<SelectionReason>>,
85 pub content: String,
86}
87
88#[derive(Debug, Clone, Serialize)]
89pub struct SelectionReason {
90 pub section_id: String,
91 pub phase: String,
92 pub value: f64,
93 pub tokens: u32,
94}
95
96pub fn execute_primer(options: PrimerOptions) -> Result<()> {
98 if options.list_presets {
100 println!("Available presets:\n");
101 for (name, description, weights) in primer::scoring::list_presets() {
102 println!(" {} - {}", console::style(name).bold(), description);
103 println!(
104 " safety={:.1} efficiency={:.1} accuracy={:.1} base={:.1}\n",
105 weights.safety, weights.efficiency, weights.accuracy, weights.base
106 );
107 }
108 return Ok(());
109 }
110
111 if options.list_sections {
112 let cli_overrides = CliOverrides::default();
113 let config = load_primer_config(options.primer_config.as_deref(), &cli_overrides)?;
114
115 println!("Available sections ({}):\n", config.sections.len());
116 for section in primer::selector::list_sections(&config) {
117 let required = if section.required { " [required]" } else { "" };
118 let caps = if section.capabilities.is_empty() {
119 String::new()
120 } else {
121 format!(" ({})", section.capabilities.join(","))
122 };
123 println!(
124 " {:30} {:15} ~{} tokens{}{}",
125 section.id, section.category, section.tokens, required, caps
126 );
127 }
128 return Ok(());
129 }
130
131 let primer = generate_primer(&options)?;
133
134 if options.json {
135 println!("{}", serde_json::to_string_pretty(&primer)?);
136 } else if options.preview {
137 println!(
138 "Preview: {} tokens, {} sections",
139 primer.total_tokens, primer.sections_included
140 );
141 if let Some(reasons) = &primer.selection_reasoning {
142 println!("\nSelection:");
143 for reason in reasons {
144 println!(
145 " [{:12}] {:30} value={:.1} tokens={}",
146 reason.phase, reason.section_id, reason.value, reason.tokens
147 );
148 }
149 }
150 } else {
151 println!("{}", primer.content);
152 }
153
154 Ok(())
155}
156
157pub fn generate_primer(options: &PrimerOptions) -> Result<PrimerOutput> {
159 let cli_overrides = CliOverrides {
161 include: options.include.clone(),
162 exclude: options.exclude.clone(),
163 preset: options.preset.clone(),
164 categories: options.categories.clone(),
165 no_dynamic: options.no_dynamic,
166 };
167
168 let config = load_primer_config(options.primer_config.as_deref(), &cli_overrides)?;
170
171 let project_state = if let Some(ref cache_path) = options.cache {
173 if cache_path.exists() {
174 let cache = Cache::from_json(cache_path)?;
175 ProjectState::from_cache(&cache)
176 } else {
177 ProjectState::default()
178 }
179 } else {
180 ProjectState::default()
181 };
182
183 let selected = select_sections(
185 &config,
186 options.budget,
187 &options.capabilities,
188 &project_state,
189 );
190
191 let total_tokens: u32 = selected.iter().map(|s| s.tokens).sum();
193
194 let selection_reasoning = if options.explain || options.preview {
196 Some(
197 selected
198 .iter()
199 .map(|s| SelectionReason {
200 section_id: s.id.clone(),
201 phase: determine_phase(&s.section),
202 value: s.value,
203 tokens: s.tokens,
204 })
205 .collect(),
206 )
207 } else {
208 None
209 };
210
211 let tier = get_tier_name(options.budget);
213
214 let content = render_primer(&selected, options.format, &project_state)?;
216
217 Ok(PrimerOutput {
218 total_tokens,
219 tier,
220 sections_included: selected.len(),
221 selection_reasoning,
222 content,
223 })
224}
225
226fn determine_phase(section: &primer::types::Section) -> String {
227 if section.required {
228 "required".to_string()
229 } else if section.required_if.is_some() {
230 "conditional".to_string()
231 } else if section.value.safety >= 80 {
232 "safety".to_string()
233 } else {
234 "value".to_string()
235 }
236}
237
238fn get_tier_name(budget: u32) -> String {
239 match budget {
240 0..=79 => "survival".to_string(),
241 80..=149 => "essential".to_string(),
242 150..=299 => "operational".to_string(),
243 300..=499 => "informed".to_string(),
244 500..=999 => "complete".to_string(),
245 _ => "expert".to_string(),
246 }
247}
248
249#[derive(Debug, Clone, Copy, Default)]
255pub enum PrimerFormat {
256 #[default]
257 Text,
258 Json,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum Tier {
264 Minimal,
265 Standard,
266 Full,
267}
268
269impl Tier {
270 pub fn from_budget(remaining: u32) -> Self {
272 if remaining < 80 {
273 Tier::Minimal
274 } else if remaining < 300 {
275 Tier::Standard
276 } else {
277 Tier::Full
278 }
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn test_tier_from_budget() {
288 assert_eq!(Tier::from_budget(50), Tier::Minimal);
289 assert_eq!(Tier::from_budget(79), Tier::Minimal);
290 assert_eq!(Tier::from_budget(80), Tier::Standard);
291 assert_eq!(Tier::from_budget(200), Tier::Standard);
292 assert_eq!(Tier::from_budget(299), Tier::Standard);
293 assert_eq!(Tier::from_budget(300), Tier::Full);
294 assert_eq!(Tier::from_budget(500), Tier::Full);
295 }
296
297 #[test]
298 fn test_generate_minimal_primer() {
299 let options = PrimerOptions {
300 budget: 60,
301 ..Default::default()
302 };
303
304 let result = generate_primer(&options).unwrap();
305 assert!(result.total_tokens <= 80 || result.sections_included >= 1);
306 assert_eq!(result.tier, "survival");
307 }
308
309 #[test]
310 fn test_generate_standard_primer() {
311 let options = PrimerOptions {
312 budget: 200,
313 ..Default::default()
314 };
315
316 let result = generate_primer(&options).unwrap();
317 assert_eq!(result.tier, "operational");
318 assert!(result.sections_included >= 1);
319 }
320
321 #[test]
322 fn test_critical_commands_always_included() {
323 let options = PrimerOptions {
324 budget: 30,
325 ..Default::default()
326 };
327
328 let result = generate_primer(&options).unwrap();
329 assert!(result.sections_included >= 1);
331 }
332
333 #[test]
334 fn test_capability_filtering() {
335 let options = PrimerOptions {
336 budget: 200,
337 capabilities: vec!["mcp".to_string()],
338 ..Default::default()
339 };
340
341 let result = generate_primer(&options).unwrap();
342 assert!(result.sections_included >= 1);
344 }
345}