acp/commands/
primer.rs

1//! @acp:module "Primer Command"
2//! @acp:summary "Generate AI bootstrap primers with value-based section selection"
3//! @acp:domain cli
4//! @acp:layer handler
5//!
6//! RFC-0004: Tiered Interface Primers
7//! Generates token-efficient bootstrap text for AI agents.
8
9use 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/// Options for the primer command
21#[derive(Debug, Clone)]
22pub struct PrimerOptions {
23    /// Token budget for the primer
24    pub budget: u32,
25    /// Required capabilities (e.g., "shell", "mcp")
26    pub capabilities: Vec<String>,
27    /// Cache file path (for project state)
28    pub cache: Option<PathBuf>,
29    /// Custom primer config file
30    pub primer_config: Option<PathBuf>,
31    /// Output format
32    pub format: OutputFormat,
33    /// Output as JSON metadata
34    pub json: bool,
35    /// Weight preset (safe, efficient, accurate, balanced)
36    pub preset: Option<String>,
37    /// Force include section IDs
38    pub include: Vec<String>,
39    /// Exclude section IDs
40    pub exclude: Vec<String>,
41    /// Filter by category IDs
42    pub categories: Vec<String>,
43    /// Disable dynamic value modifiers
44    pub no_dynamic: bool,
45    /// Show selection reasoning
46    pub explain: bool,
47    /// List available sections
48    pub list_sections: bool,
49    /// List available presets
50    pub list_presets: bool,
51    /// Preview selection without rendering
52    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/// Generated primer output
78#[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
96/// Execute the primer command
97pub fn execute_primer(options: PrimerOptions) -> Result<()> {
98    // Handle list modes first
99    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    // Generate primer
132    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
157/// Generate primer content based on budget and capabilities
158pub fn generate_primer(options: &PrimerOptions) -> Result<PrimerOutput> {
159    // Build CLI overrides
160    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    // Load primer config with 3-layer merge
169    let config = load_primer_config(options.primer_config.as_deref(), &cli_overrides)?;
170
171    // Load project state from cache
172    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    // Select sections based on budget and capabilities
184    let selected = select_sections(
185        &config,
186        options.budget,
187        &options.capabilities,
188        &project_state,
189    );
190
191    // Calculate totals
192    let total_tokens: u32 = selected.iter().map(|s| s.tokens).sum();
193
194    // Build selection reasoning if explain mode
195    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    // Determine tier name based on budget
212    let tier = get_tier_name(options.budget);
213
214    // Render output
215    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// ============================================================================
250// Legacy types for backward compatibility with existing tests
251// ============================================================================
252
253/// Output format for primer (legacy)
254#[derive(Debug, Clone, Copy, Default)]
255pub enum PrimerFormat {
256    #[default]
257    Text,
258    Json,
259}
260
261/// Tier level for content selection (legacy)
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum Tier {
264    Minimal,
265    Standard,
266    Full,
267}
268
269impl Tier {
270    /// Determine tier based on remaining budget
271    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        // Required sections should still be included
330        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        // With MCP capability, should get MCP sections only
343        assert!(result.sections_included >= 1);
344    }
345}