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
18pub fn is_edit_action(content: &str) -> bool {
20 let trimmed = content.trim();
21 trimmed.starts_with('{') && trimmed.contains("\"edit_type\"")
22}
23
24pub 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
77pub 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 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 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 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 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
182pub 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 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 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 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 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 for action in &actions {
248 if action.target_type == SuggestedTarget::ClaudeMd {
249 continue; }
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
267pub 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
303pub struct ExecuteResult {
305 pub files_written: usize,
306 pub patterns_activated: usize,
307}
308
309fn 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 .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
333fn 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 let after_edits = if edits.is_empty() {
350 existing
351 } else {
352 claude_md::apply_edits(&existing, edits)
353 };
354
355 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
379fn 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
400pub 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
422fn 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}