Skip to main content

retro_core/projection/
mod.rs

1pub mod claude_md;
2pub mod global_agent;
3pub mod skill;
4
5use crate::analysis::backend::AnalysisBackend;
6use crate::config::Config;
7use crate::db;
8use crate::errors::CoreError;
9use crate::models::{
10    ApplyAction, ApplyPlan, ApplyTrack, ClaudeMdEdit, ClaudeMdEditType, Pattern, PatternStatus,
11    Projection, ProjectionStatus, SuggestedTarget,
12};
13use crate::util::backup_file;
14use chrono::Utc;
15use rusqlite::Connection;
16use std::path::Path;
17
18/// Returns true if the content string is a JSON edit action (starts with `{` and contains `"edit_type"`).
19pub fn is_edit_action(content: &str) -> bool {
20    let trimmed = content.trim();
21    trimmed.starts_with('{') && trimmed.contains("\"edit_type\"")
22}
23
24/// Parse a JSON edit from an action's content field.
25///
26/// The JSON format is:
27/// ```json
28/// {"edit_type":"reword","original":"old text","replacement":"new text","target_section":null,"reasoning":"why"}
29/// ```
30///
31/// Maps fields: `original` → `original_text`, `replacement` → `suggested_content`.
32pub fn parse_edit(content: &str) -> Option<ClaudeMdEdit> {
33    let trimmed = content.trim();
34    let v: serde_json::Value = serde_json::from_str(trimmed).ok()?;
35    let obj = v.as_object()?;
36
37    let edit_type_str = obj.get("edit_type")?.as_str()?;
38    let edit_type = match edit_type_str {
39        "add" => ClaudeMdEditType::Add,
40        "remove" => ClaudeMdEditType::Remove,
41        "reword" => ClaudeMdEditType::Reword,
42        "move" => ClaudeMdEditType::Move,
43        _ => return None,
44    };
45
46    let original_text = obj
47        .get("original")
48        .and_then(|v| v.as_str())
49        .unwrap_or("")
50        .to_string();
51
52    let suggested_content = obj
53        .get("replacement")
54        .and_then(|v| v.as_str())
55        .map(String::from);
56
57    let target_section = obj
58        .get("target_section")
59        .and_then(|v| v.as_str())
60        .map(String::from);
61
62    let reasoning = obj
63        .get("reasoning")
64        .and_then(|v| v.as_str())
65        .unwrap_or("")
66        .to_string();
67
68    Some(ClaudeMdEdit {
69        edit_type,
70        original_text,
71        suggested_content,
72        target_section,
73        reasoning,
74    })
75}
76
77/// Build an apply plan: select qualifying patterns and generate projected content.
78/// For skills and global agents, this calls the AI backend.
79/// For CLAUDE.md rules, no AI is needed (uses suggested_content directly).
80pub fn build_apply_plan(
81    conn: &Connection,
82    config: &Config,
83    backend: &dyn AnalysisBackend,
84    project: Option<&str>,
85) -> Result<ApplyPlan, CoreError> {
86    let patterns = get_qualifying_patterns(conn, config, project)?;
87
88    if patterns.is_empty() {
89        return Ok(ApplyPlan {
90            actions: Vec::new(),
91        });
92    }
93
94    let mut actions = Vec::new();
95
96    // Group patterns by target type
97    let claude_md_patterns: Vec<&Pattern> = patterns
98        .iter()
99        .filter(|p| p.suggested_target == SuggestedTarget::ClaudeMd)
100        .collect();
101    let skill_patterns: Vec<&Pattern> = patterns
102        .iter()
103        .filter(|p| p.suggested_target == SuggestedTarget::Skill)
104        .collect();
105    let agent_patterns: Vec<&Pattern> = patterns
106        .iter()
107        .filter(|p| p.suggested_target == SuggestedTarget::GlobalAgent)
108        .collect();
109
110    // CLAUDE.md rules — no AI needed, use suggested_content directly
111    if !claude_md_patterns.is_empty() {
112        let claude_md_path = match project {
113            Some(proj) => format!("{proj}/CLAUDE.md"),
114            None => "CLAUDE.md".to_string(),
115        };
116
117        for p in &claude_md_patterns {
118            actions.push(ApplyAction {
119                pattern_id: p.id.clone(),
120                pattern_description: p.description.clone(),
121                target_type: SuggestedTarget::ClaudeMd,
122                target_path: claude_md_path.clone(),
123                content: p.suggested_content.clone(),
124                track: ApplyTrack::Shared,
125            });
126        }
127    }
128
129    // Skills — AI generation with two-phase pipeline
130    for pattern in &skill_patterns {
131        let project_root = project.unwrap_or(".");
132        match skill::generate_with_retry(backend, pattern, 2) {
133            Ok(draft) => {
134                let path = skill::skill_path(project_root, &draft.name);
135                actions.push(ApplyAction {
136                    pattern_id: pattern.id.clone(),
137                    pattern_description: pattern.description.clone(),
138                    target_type: SuggestedTarget::Skill,
139                    target_path: path,
140                    content: draft.content,
141                    track: ApplyTrack::Shared,
142                });
143            }
144            Err(e) => {
145                eprintln!(
146                    "warning: skill generation failed for pattern {}: {e}",
147                    pattern.id
148                );
149                let _ = db::set_generation_failed(conn, &pattern.id, true);
150            }
151        }
152    }
153
154    // Global agents — AI generation
155    let claude_dir = config.claude_dir().to_string_lossy().to_string();
156    for pattern in &agent_patterns {
157        match global_agent::generate_agent(backend, pattern) {
158            Ok(draft) => {
159                let path = global_agent::agent_path(&claude_dir, &draft.name);
160                actions.push(ApplyAction {
161                    pattern_id: pattern.id.clone(),
162                    pattern_description: pattern.description.clone(),
163                    target_type: SuggestedTarget::GlobalAgent,
164                    target_path: path,
165                    content: draft.content,
166                    track: ApplyTrack::Personal,
167                });
168            }
169            Err(e) => {
170                eprintln!(
171                    "warning: agent generation failed for pattern {}: {e}",
172                    pattern.id
173                );
174                let _ = db::set_generation_failed(conn, &pattern.id, true);
175            }
176        }
177    }
178
179    Ok(ApplyPlan { actions })
180}
181
182/// Execute actions from an apply plan, optionally filtered by track.
183/// When `track_filter` is Some, only actions matching that track are executed.
184/// When None, all actions are executed.
185pub fn execute_plan(
186    conn: &Connection,
187    _config: &Config,
188    plan: &ApplyPlan,
189    _project: Option<&str>,
190    track_filter: Option<&ApplyTrack>,
191) -> Result<ExecuteResult, CoreError> {
192    let mut files_written = 0;
193    let mut patterns_activated = 0;
194
195    let backup_dir = crate::config::retro_dir().join("backups");
196    std::fs::create_dir_all(&backup_dir)
197        .map_err(|e| CoreError::Io(format!("creating backup dir: {e}")))?;
198
199    let actions: Vec<&ApplyAction> = plan
200        .actions
201        .iter()
202        .filter(|a| match track_filter {
203            Some(track) => a.track == *track,
204            None => true,
205        })
206        .collect();
207
208    // Collect CLAUDE.md actions and separate edits from plain rules
209    let claude_md_actions: Vec<&&ApplyAction> = actions
210        .iter()
211        .filter(|a| a.target_type == SuggestedTarget::ClaudeMd)
212        .collect();
213
214    if !claude_md_actions.is_empty() {
215        let target_path = &claude_md_actions[0].target_path;
216
217        // Separate JSON edits from plain rule additions
218        let mut edits: Vec<ClaudeMdEdit> = Vec::new();
219        let mut plain_rules: Vec<String> = Vec::new();
220
221        for action in &claude_md_actions {
222            if is_edit_action(&action.content) {
223                if let Some(edit) = parse_edit(&action.content) {
224                    edits.push(edit);
225                } else {
226                    // Fallback: treat unparseable JSON edits as plain rules
227                    plain_rules.push(action.content.clone());
228                }
229            } else {
230                plain_rules.push(action.content.clone());
231            }
232        }
233
234        write_claude_md_with_edits(target_path, &edits, &plain_rules, &backup_dir)?;
235        files_written += 1;
236
237        // Record projections and update status for each pattern
238        for action in &claude_md_actions {
239            record_projection(conn, action, target_path)?;
240            db::update_pattern_status(conn, &action.pattern_id, &PatternStatus::Active)?;
241            db::update_pattern_last_projected(conn, &action.pattern_id)?;
242            patterns_activated += 1;
243        }
244    }
245
246    // Write skills and global agents individually
247    for action in &actions {
248        if action.target_type == SuggestedTarget::ClaudeMd {
249            continue; // Already handled above
250        }
251
252        write_file_with_backup(&action.target_path, &action.content, &backup_dir)?;
253        files_written += 1;
254
255        record_projection(conn, action, &action.target_path)?;
256        db::update_pattern_status(conn, &action.pattern_id, &PatternStatus::Active)?;
257        db::update_pattern_last_projected(conn, &action.pattern_id)?;
258        patterns_activated += 1;
259    }
260
261    Ok(ExecuteResult {
262        files_written,
263        patterns_activated,
264    })
265}
266
267/// Save an apply plan's actions as pending_review projections in the database.
268/// Does NOT write files or create PRs — just records the generated content for later review.
269pub fn save_plan_for_review(
270    conn: &Connection,
271    plan: &ApplyPlan,
272    project: Option<&str>,
273) -> Result<usize, CoreError> {
274    let mut saved = 0;
275
276    for action in &plan.actions {
277        let target_path = if action.target_type == SuggestedTarget::ClaudeMd {
278            match project {
279                Some(proj) => format!("{proj}/CLAUDE.md"),
280                None => "CLAUDE.md".to_string(),
281            }
282        } else {
283            action.target_path.clone()
284        };
285
286        let proj = Projection {
287            id: uuid::Uuid::new_v4().to_string(),
288            pattern_id: action.pattern_id.clone(),
289            target_type: action.target_type.to_string(),
290            target_path,
291            content: action.content.clone(),
292            applied_at: Utc::now(),
293            pr_url: None,
294            status: ProjectionStatus::PendingReview,
295        };
296        db::insert_projection(conn, &proj)?;
297        saved += 1;
298    }
299
300    Ok(saved)
301}
302
303/// Result of executing an apply plan.
304pub struct ExecuteResult {
305    pub files_written: usize,
306    pub patterns_activated: usize,
307}
308
309/// Get patterns qualifying for projection.
310fn get_qualifying_patterns(
311    conn: &Connection,
312    config: &Config,
313    project: Option<&str>,
314) -> Result<Vec<Pattern>, CoreError> {
315    let patterns = db::get_patterns(conn, &["discovered", "active"], project)?;
316    let projected_ids = db::get_projected_pattern_ids_by_status(
317        conn,
318        &[ProjectionStatus::Applied, ProjectionStatus::PendingReview],
319    )?;
320    Ok(patterns
321        .into_iter()
322        .filter(|p| p.confidence >= config.analysis.confidence_threshold)
323        // times_seen filter removed: the confidence threshold (default 0.7)
324        // is the primary gate. The AI assigns low confidence (0.4-0.5) to weak
325        // single-session observations and high confidence (0.6-0.75) to explicit
326        // directives ("always"/"never"), so the threshold naturally filters.
327        .filter(|p| p.suggested_target != SuggestedTarget::DbOnly)
328        .filter(|p| !p.generation_failed)
329        .filter(|p| !projected_ids.contains(&p.id))
330        .collect())
331}
332
333/// Write CLAUDE.md: apply edits first, then add plain rules to managed section.
334fn write_claude_md_with_edits(
335    target_path: &str,
336    edits: &[ClaudeMdEdit],
337    rules: &[String],
338    backup_dir: &Path,
339) -> Result<(), CoreError> {
340    let existing = if Path::new(target_path).exists() {
341        backup_file(target_path, backup_dir)?;
342        std::fs::read_to_string(target_path)
343            .map_err(|e| CoreError::Io(format!("reading {target_path}: {e}")))?
344    } else {
345        String::new()
346    };
347
348    // Phase 1: apply edits to full file content
349    let after_edits = if edits.is_empty() {
350        existing
351    } else {
352        claude_md::apply_edits(&existing, edits)
353    };
354
355    // Phase 2: add plain rules to managed section (preserving existing rules)
356    let updated = if rules.is_empty() {
357        after_edits
358    } else {
359        let mut combined = claude_md::read_managed_section(&after_edits).unwrap_or_default();
360        for rule in rules {
361            if !combined.iter().any(|r| r == rule) {
362                combined.push(rule.clone());
363            }
364        }
365        claude_md::update_claude_md_content(&after_edits, &combined)
366    };
367
368    if let Some(parent) = Path::new(target_path).parent() {
369        std::fs::create_dir_all(parent)
370            .map_err(|e| CoreError::Io(format!("creating dir for {target_path}: {e}")))?;
371    }
372
373    std::fs::write(target_path, &updated)
374        .map_err(|e| CoreError::Io(format!("writing {target_path}: {e}")))?;
375
376    Ok(())
377}
378
379/// Write a file, backing up the original if it exists.
380fn write_file_with_backup(
381    target_path: &str,
382    content: &str,
383    backup_dir: &Path,
384) -> Result<(), CoreError> {
385    if Path::new(target_path).exists() {
386        backup_file(target_path, backup_dir)?;
387    }
388
389    if let Some(parent) = Path::new(target_path).parent() {
390        std::fs::create_dir_all(parent)
391            .map_err(|e| CoreError::Io(format!("creating dir for {target_path}: {e}")))?;
392    }
393
394    std::fs::write(target_path, content)
395        .map_err(|e| CoreError::Io(format!("writing {target_path}: {e}")))?;
396
397    Ok(())
398}
399
400/// If CLAUDE.md has managed delimiters, dissolve them (backup first).
401/// Returns `Ok(true)` if dissolution happened, `Ok(false)` if no action needed.
402pub fn dissolve_if_needed(claude_md_path: &str, backup_dir: &Path) -> Result<bool, CoreError> {
403    if !Path::new(claude_md_path).exists() {
404        return Ok(false);
405    }
406
407    let content = std::fs::read_to_string(claude_md_path)
408        .map_err(|e| CoreError::Io(format!("reading {claude_md_path}: {e}")))?;
409
410    if !claude_md::has_managed_section(&content) {
411        return Ok(false);
412    }
413
414    backup_file(claude_md_path, backup_dir)?;
415    let cleaned = claude_md::dissolve_managed_section(&content);
416    std::fs::write(claude_md_path, &cleaned)
417        .map_err(|e| CoreError::Io(format!("writing {claude_md_path}: {e}")))?;
418
419    Ok(true)
420}
421
422/// Record a projection in the database.
423fn record_projection(
424    conn: &Connection,
425    action: &ApplyAction,
426    target_path: &str,
427) -> Result<(), CoreError> {
428    let proj = Projection {
429        id: uuid::Uuid::new_v4().to_string(),
430        pattern_id: action.pattern_id.clone(),
431        target_type: action.target_type.to_string(),
432        target_path: target_path.to_string(),
433        content: action.content.clone(),
434        applied_at: Utc::now(),
435        pr_url: None,
436        status: crate::models::ProjectionStatus::Applied,
437    };
438    db::insert_projection(conn, &proj)
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn test_is_edit_action_reword() {
447        let content = r#"{"edit_type":"reword","original":"old text","replacement":"new text","reasoning":"clarity"}"#;
448        assert!(is_edit_action(content));
449    }
450
451    #[test]
452    fn test_is_edit_action_remove() {
453        let content = r#"{"edit_type":"remove","original":"stale rule","reasoning":"no longer relevant"}"#;
454        assert!(is_edit_action(content));
455    }
456
457    #[test]
458    fn test_is_edit_action_plain_rule() {
459        let content = "Always use uv for Python packages";
460        assert!(!is_edit_action(content));
461    }
462
463    #[test]
464    fn test_is_edit_action_json_without_edit_type() {
465        let content = r#"{"name":"something","value":42}"#;
466        assert!(!is_edit_action(content));
467    }
468
469    #[test]
470    fn test_is_edit_action_with_whitespace() {
471        let content = r#"  {"edit_type":"add","replacement":"new rule","reasoning":"new pattern"}  "#;
472        assert!(is_edit_action(content));
473    }
474
475    #[test]
476    fn test_is_edit_action_empty() {
477        assert!(!is_edit_action(""));
478    }
479
480    #[test]
481    fn test_parse_edit_reword() {
482        let content = r#"{"edit_type":"reword","original":"No async","replacement":"Sync only — no tokio, no async","target_section":null,"reasoning":"too terse"}"#;
483        let edit = parse_edit(content).unwrap();
484        assert_eq!(edit.edit_type, ClaudeMdEditType::Reword);
485        assert_eq!(edit.original_text, "No async");
486        assert_eq!(edit.suggested_content.unwrap(), "Sync only — no tokio, no async");
487        assert!(edit.target_section.is_none());
488        assert_eq!(edit.reasoning, "too terse");
489    }
490
491    #[test]
492    fn test_parse_edit_remove() {
493        let content = r#"{"edit_type":"remove","original":"stale rule","reasoning":"no longer relevant"}"#;
494        let edit = parse_edit(content).unwrap();
495        assert_eq!(edit.edit_type, ClaudeMdEditType::Remove);
496        assert_eq!(edit.original_text, "stale rule");
497        assert!(edit.suggested_content.is_none());
498        assert_eq!(edit.reasoning, "no longer relevant");
499    }
500
501    #[test]
502    fn test_parse_edit_add() {
503        let content = r#"{"edit_type":"add","original":"","replacement":"- New rule","reasoning":"new pattern"}"#;
504        let edit = parse_edit(content).unwrap();
505        assert_eq!(edit.edit_type, ClaudeMdEditType::Add);
506        assert_eq!(edit.original_text, "");
507        assert_eq!(edit.suggested_content.unwrap(), "- New rule");
508    }
509
510    #[test]
511    fn test_parse_edit_move() {
512        let content = r#"{"edit_type":"move","original":"misplaced rule","replacement":"misplaced rule","target_section":"Build","reasoning":"wrong section"}"#;
513        let edit = parse_edit(content).unwrap();
514        assert_eq!(edit.edit_type, ClaudeMdEditType::Move);
515        assert_eq!(edit.original_text, "misplaced rule");
516        assert_eq!(edit.target_section.unwrap(), "Build");
517    }
518
519    #[test]
520    fn test_parse_edit_plain_text_returns_none() {
521        let content = "Always use uv for Python packages";
522        assert!(parse_edit(content).is_none());
523    }
524
525    #[test]
526    fn test_parse_edit_invalid_edit_type_returns_none() {
527        let content = r#"{"edit_type":"unknown","original":"text","reasoning":"why"}"#;
528        assert!(parse_edit(content).is_none());
529    }
530
531    #[test]
532    fn test_parse_edit_missing_edit_type_returns_none() {
533        let content = r#"{"original":"text","reasoning":"why"}"#;
534        assert!(parse_edit(content).is_none());
535    }
536
537    #[test]
538    fn test_dissolve_if_needed_with_managed() {
539        let dir = tempfile::tempdir().unwrap();
540        let claude_md = dir.path().join("CLAUDE.md");
541        let backup_dir = dir.path().join("backups");
542        std::fs::create_dir_all(&backup_dir).unwrap();
543        std::fs::write(&claude_md, "# Proj\n\n<!-- retro:managed:start -->\n## Retro-Discovered Patterns\n\n- Rule\n\n<!-- retro:managed:end -->\n").unwrap();
544
545        let dissolved = dissolve_if_needed(claude_md.to_str().unwrap(), &backup_dir).unwrap();
546        assert!(dissolved);
547        let content = std::fs::read_to_string(&claude_md).unwrap();
548        assert!(!content.contains("retro:managed"));
549        assert!(content.contains("- Rule"));
550    }
551
552    #[test]
553    fn test_dissolve_if_needed_without_managed() {
554        let dir = tempfile::tempdir().unwrap();
555        let claude_md = dir.path().join("CLAUDE.md");
556        let backup_dir = dir.path().join("backups");
557        std::fs::create_dir_all(&backup_dir).unwrap();
558        std::fs::write(&claude_md, "# Proj\n\nNo managed section.\n").unwrap();
559
560        let dissolved = dissolve_if_needed(claude_md.to_str().unwrap(), &backup_dir).unwrap();
561        assert!(!dissolved);
562    }
563
564    #[test]
565    fn test_dissolve_if_needed_no_file() {
566        let dir = tempfile::tempdir().unwrap();
567        let claude_md = dir.path().join("CLAUDE.md");
568        let backup_dir = dir.path().join("backups");
569
570        let dissolved = dissolve_if_needed(claude_md.to_str().unwrap(), &backup_dir).unwrap();
571        assert!(!dissolved);
572    }
573}