use std::path::Path;
use crate::errors::CoreError;
use crate::models::{ClaudeMdEdit, ClaudeMdEditType};
const MANAGED_START: &str = "<!-- retro:managed:start -->";
const MANAGED_END: &str = "<!-- retro:managed:end -->";
pub fn build_managed_section(rules: &[String]) -> String {
let mut section = String::new();
section.push_str(MANAGED_START);
section.push('\n');
section.push_str("## Retro-Discovered Patterns\n\n");
for rule in rules {
section.push_str(&format!("- {rule}\n"));
}
section.push('\n');
section.push_str(MANAGED_END);
section
}
pub fn update_claude_md_content(existing: &str, rules: &[String]) -> String {
let managed = build_managed_section(rules);
if let Some((before, after)) = find_managed_bounds(existing) {
format!("{before}{managed}{after}")
} else {
let mut result = existing.to_string();
if !result.is_empty() && !result.ends_with('\n') {
result.push('\n');
}
if !result.is_empty() {
result.push('\n');
}
result.push_str(&managed);
result.push('\n');
result
}
}
pub fn read_managed_section(content: &str) -> Option<Vec<String>> {
let (_, inner, _) = split_managed(content)?;
let rules: Vec<String> = inner
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("- ") {
Some(rest.to_string())
} else {
None
}
})
.collect();
if rules.is_empty() {
None
} else {
Some(rules)
}
}
fn split_managed(content: &str) -> Option<(String, String, String)> {
let start_idx = content.find(MANAGED_START)?;
let after_start = start_idx + MANAGED_START.len();
let end_idx = content[after_start..].find(MANAGED_END)?;
let end_abs = after_start + end_idx;
let after_end = end_abs + MANAGED_END.len();
Some((
content[..start_idx].to_string(),
content[after_start..end_abs].to_string(),
content[after_end..].to_string(),
))
}
fn find_managed_bounds(content: &str) -> Option<(String, String)> {
let (before, _, after) = split_managed(content)?;
Some((before, after))
}
pub fn has_managed_section(content: &str) -> bool {
content.contains(MANAGED_START) && content.contains(MANAGED_END)
}
pub fn dissolve_managed_section(content: &str) -> String {
let Some((before, inner, after)) = split_managed(content) else {
return content.to_string();
};
let cleaned_inner: String = inner
.lines()
.filter(|line| {
let trimmed = line.trim();
trimmed != "## Retro-Discovered Patterns"
})
.collect::<Vec<_>>()
.join("\n");
let mut result = before;
if !cleaned_inner.trim().is_empty() {
result.push_str(&cleaned_inner);
}
result.push_str(&after);
result
}
fn remove_substring(content: &str, needle: &str) -> String {
let trimmed = needle.trim();
if trimmed.is_empty() {
return content.to_string();
}
if let Some(pos) = content.find(trimmed) {
let before = &content[..pos];
let after = &content[pos + trimmed.len()..];
let after = after.strip_prefix('\n').unwrap_or(after);
format!("{before}{after}")
} else {
content
.lines()
.filter(|line| line.trim() != trimmed)
.collect::<Vec<_>>()
.join("\n")
+ if content.ends_with('\n') { "\n" } else { "" }
}
}
pub fn apply_edit(content: &str, edit: &ClaudeMdEdit) -> String {
match edit.edit_type {
ClaudeMdEditType::Remove => {
remove_substring(content, &edit.original_text)
}
ClaudeMdEditType::Reword => {
if let Some(replacement) = &edit.suggested_content {
content.replacen(edit.original_text.trim(), replacement.trim(), 1)
} else {
content.to_string()
}
}
ClaudeMdEditType::Add => {
let mut result = content.to_string();
if let Some(new_content) = &edit.suggested_content {
if !result.ends_with('\n') {
result.push('\n');
}
result.push_str(new_content);
result.push('\n');
}
result
}
ClaudeMdEditType::Move => {
let without = remove_substring(content, &edit.original_text);
if let (Some(section), Some(text)) = (&edit.target_section, &edit.suggested_content) {
let mut result = String::new();
let mut inserted = false;
for line in without.lines() {
result.push_str(line);
result.push('\n');
if !inserted && line.trim().starts_with('#') && line.contains(section) {
result.push_str(text);
result.push('\n');
inserted = true;
}
}
if !inserted {
result.push_str(text);
result.push('\n');
}
result
} else {
without + "\n"
}
}
}
}
pub fn apply_edits(content: &str, edits: &[ClaudeMdEdit]) -> String {
let mut result = content.to_string();
for edit in edits {
result = apply_edit(&result, edit);
}
result
}
pub fn project_rule_to_claude_md(path: &Path, rule: &str) -> Result<(), CoreError> {
let existing = if path.exists() {
std::fs::read_to_string(path)
.map_err(|e| CoreError::Io(format!("reading CLAUDE.md: {e}")))?
} else {
String::new()
};
let mut rules: Vec<String> = read_managed_section(&existing).unwrap_or_default();
if !rules.iter().any(|r| r == rule) {
rules.push(rule.to_string());
}
let updated = update_claude_md_content(&existing, &rules);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| CoreError::Io(format!("creating directory: {e}")))?;
}
std::fs::write(path, &updated)
.map_err(|e| CoreError::Io(format!("writing CLAUDE.md: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_managed_section() {
let rules = vec![
"Always use uv for Python packages".to_string(),
"Run cargo test after changes".to_string(),
];
let section = build_managed_section(&rules);
assert!(section.starts_with(MANAGED_START));
assert!(section.ends_with(MANAGED_END));
assert!(section.contains("- Always use uv for Python packages"));
assert!(section.contains("- Run cargo test after changes"));
}
#[test]
fn test_update_claude_md_no_existing_section() {
let existing = "# My Project\n\nSome existing content.\n";
let rules = vec!["Use uv".to_string()];
let result = update_claude_md_content(existing, &rules);
assert!(result.starts_with("# My Project\n\nSome existing content.\n"));
assert!(result.contains(MANAGED_START));
assert!(result.contains("- Use uv"));
assert!(result.contains(MANAGED_END));
}
#[test]
fn test_update_claude_md_replace_existing() {
let existing = format!(
"# My Project\n\n{}\n## Retro-Discovered Patterns\n\n- Old rule\n\n{}\n\n## Footer\n",
MANAGED_START, MANAGED_END
);
let rules = vec!["New rule".to_string()];
let result = update_claude_md_content(&existing, &rules);
assert!(result.contains("# My Project"));
assert!(result.contains("- New rule"));
assert!(!result.contains("- Old rule"));
assert!(result.contains("## Footer"));
}
#[test]
fn test_update_claude_md_empty_file() {
let rules = vec!["Rule one".to_string()];
let result = update_claude_md_content("", &rules);
assert!(result.contains(MANAGED_START));
assert!(result.contains("- Rule one"));
}
#[test]
fn test_read_managed_section() {
let content = format!(
"# Header\n\n{}\n## Retro-Discovered Patterns\n\n- Rule A\n- Rule B\n\n{}\n",
MANAGED_START, MANAGED_END
);
let rules = read_managed_section(&content).unwrap();
assert_eq!(rules, vec!["Rule A", "Rule B"]);
}
#[test]
fn test_read_managed_section_none() {
let content = "# No managed section here\n";
assert!(read_managed_section(content).is_none());
}
#[test]
fn test_dissolve_managed_section() {
let content = format!(
"# My Project\n\nSome content.\n\n{}\n## Retro-Discovered Patterns\n\n- Rule A\n- Rule B\n\n{}\n\n## Footer\n",
MANAGED_START, MANAGED_END
);
let result = dissolve_managed_section(&content);
assert!(!result.contains(MANAGED_START));
assert!(!result.contains(MANAGED_END));
assert!(!result.contains("## Retro-Discovered Patterns"));
assert!(result.contains("- Rule A"));
assert!(result.contains("- Rule B"));
assert!(result.contains("# My Project"));
assert!(result.contains("## Footer"));
}
#[test]
fn test_dissolve_no_managed_section() {
let content = "# My Project\n\nNo managed section.\n";
let result = dissolve_managed_section(content);
assert_eq!(result, content);
}
#[test]
fn test_has_managed_section() {
let with = format!("content\n{}\nrules\n{}\n", MANAGED_START, MANAGED_END);
let without = "just content\n";
assert!(has_managed_section(&with));
assert!(!has_managed_section(without));
}
use crate::models::{ClaudeMdEdit, ClaudeMdEditType};
#[test]
fn test_apply_edit_remove() {
let content = "# Project\n\n- Use thiserror in lib crates\n- Stale rule to remove\n\n## More\n";
let edit = ClaudeMdEdit {
edit_type: ClaudeMdEditType::Remove,
original_text: "- Stale rule to remove".to_string(),
suggested_content: None,
target_section: None,
reasoning: "stale".to_string(),
};
let result = apply_edit(content, &edit);
assert!(!result.contains("Stale rule to remove"));
assert!(result.contains("Use thiserror"));
assert!(result.contains("## More"));
}
#[test]
fn test_apply_edit_reword() {
let content = "# Project\n\nNo async\n\n## More\n";
let edit = ClaudeMdEdit {
edit_type: ClaudeMdEditType::Reword,
original_text: "No async".to_string(),
suggested_content: Some("Sync only — no tokio, no async".to_string()),
target_section: None,
reasoning: "too terse".to_string(),
};
let result = apply_edit(content, &edit);
assert!(!result.contains("\nNo async\n"));
assert!(result.contains("Sync only — no tokio, no async"));
}
#[test]
fn test_apply_edit_add() {
let content = "# Project\n\nExisting content.\n";
let edit = ClaudeMdEdit {
edit_type: ClaudeMdEditType::Add,
original_text: String::new(),
suggested_content: Some("- New rule to add".to_string()),
target_section: None,
reasoning: "new pattern".to_string(),
};
let result = apply_edit(content, &edit);
assert!(result.contains("Existing content."));
assert!(result.contains("- New rule to add"));
}
#[test]
fn test_apply_edit_remove_multiline() {
let content = "# Project\n\n## Old Section\n\n- item A\n- item B\n\n## Next Section\n";
let edit = ClaudeMdEdit {
edit_type: ClaudeMdEditType::Remove,
original_text: "## Old Section\n\n- item A\n- item B".to_string(),
suggested_content: None,
target_section: None,
reasoning: "entire section is stale".to_string(),
};
let result = apply_edit(content, &edit);
assert!(!result.contains("Old Section"));
assert!(!result.contains("item A"));
assert!(!result.contains("item B"));
assert!(result.contains("## Next Section"));
}
#[test]
fn test_apply_edit_reword_first_only() {
let content = "# Project\n\nNo async\n\n## Rules\n\nNo async\n";
let edit = ClaudeMdEdit {
edit_type: ClaudeMdEditType::Reword,
original_text: "No async".to_string(),
suggested_content: Some("Sync only".to_string()),
target_section: None,
reasoning: "too terse".to_string(),
};
let result = apply_edit(content, &edit);
assert!(result.starts_with("# Project\n\nSync only\n"));
assert!(result.contains("\nNo async\n"));
}
#[test]
fn test_project_rule_to_claude_md() {
let dir = tempfile::TempDir::new().unwrap();
let claude_md_path = dir.path().join("CLAUDE.md");
project_rule_to_claude_md(&claude_md_path, "Always use snake_case").unwrap();
let content = std::fs::read_to_string(&claude_md_path).unwrap();
assert!(content.contains("retro:managed:start"));
assert!(content.contains("Always use snake_case"));
project_rule_to_claude_md(&claude_md_path, "Run tests before committing").unwrap();
let content = std::fs::read_to_string(&claude_md_path).unwrap();
assert!(content.contains("Always use snake_case"));
assert!(content.contains("Run tests before committing"));
assert_eq!(content.matches("retro:managed:start").count(), 1);
}
#[test]
fn test_apply_edits_batch() {
let content = "# Project\n\nRule A\nRule B\nRule C\n";
let edits = vec![
ClaudeMdEdit {
edit_type: ClaudeMdEditType::Remove,
original_text: "Rule B".to_string(),
suggested_content: None,
target_section: None,
reasoning: "stale".to_string(),
},
ClaudeMdEdit {
edit_type: ClaudeMdEditType::Reword,
original_text: "Rule A".to_string(),
suggested_content: Some("Rule A (improved)".to_string()),
target_section: None,
reasoning: "clarity".to_string(),
},
];
let result = apply_edits(content, &edits);
assert!(!result.contains("\nRule B\n"));
assert!(result.contains("Rule A (improved)"));
assert!(result.contains("Rule C"));
}
}