1use std::fs;
7use std::path::{Path, PathBuf};
8
9use crate::error::{DotAgentError, Result};
10use crate::llm::{check_claude_cli, execute_claude};
11use crate::profile::{Profile, ProfileManager};
12
13const RULES_DIR: &str = "rules";
14
15#[derive(Debug)]
21pub struct Rule {
22 pub name: String,
23 pub path: PathBuf,
24 pub content: String,
25}
26
27impl Rule {
28 pub fn load(path: &Path) -> Result<Self> {
30 if !path.exists() {
31 return Err(DotAgentError::RuleNotFound {
32 name: path
33 .file_stem()
34 .map(|s| s.to_string_lossy().to_string())
35 .unwrap_or_default(),
36 });
37 }
38
39 let content = fs::read_to_string(path)?;
40 let name = path
41 .file_stem()
42 .map(|s| s.to_string_lossy().to_string())
43 .unwrap_or_default();
44
45 Ok(Self {
46 name,
47 path: path.to_path_buf(),
48 content,
49 })
50 }
51
52 pub fn summary(&self) -> String {
54 self.content
55 .lines()
56 .find(|line| !line.is_empty() && !line.starts_with('#'))
57 .unwrap_or("(no description)")
58 .chars()
59 .take(60)
60 .collect()
61 }
62}
63
64pub struct RuleManager {
70 base_dir: PathBuf,
71}
72
73impl RuleManager {
74 pub fn new(base_dir: PathBuf) -> Self {
75 Self { base_dir }
76 }
77
78 pub fn rules_dir(&self) -> PathBuf {
79 self.base_dir.join(RULES_DIR)
80 }
81
82 fn rule_path(&self, name: &str) -> PathBuf {
83 self.rules_dir().join(format!("{}.md", name))
84 }
85
86 pub fn list(&self) -> Result<Vec<Rule>> {
88 let dir = self.rules_dir();
89 if !dir.exists() {
90 return Ok(Vec::new());
91 }
92
93 let mut rules = Vec::new();
94 for entry in fs::read_dir(&dir)? {
95 let entry = entry?;
96 let path = entry.path();
97 if path.extension().is_some_and(|ext| ext == "md") {
98 if let Ok(rule) = Rule::load(&path) {
99 rules.push(rule);
100 }
101 }
102 }
103
104 rules.sort_by(|a, b| a.name.cmp(&b.name));
105 Ok(rules)
106 }
107
108 pub fn get(&self, name: &str) -> Result<Rule> {
110 let path = self.rule_path(name);
111 Rule::load(&path)
112 }
113
114 pub fn create(&self, name: &str) -> Result<Rule> {
116 validate_name(name)?;
117
118 let path = self.rule_path(name);
119 if path.exists() {
120 return Err(DotAgentError::RuleAlreadyExists {
121 name: name.to_string(),
122 });
123 }
124
125 fs::create_dir_all(self.rules_dir())?;
126
127 let template = generate_rule_template(name);
128 fs::write(&path, &template)?;
129
130 Rule::load(&path)
131 }
132
133 pub fn import(&self, name: &str, source_file: &Path) -> Result<Rule> {
135 validate_name(name)?;
136
137 let path = self.rule_path(name);
138 if path.exists() {
139 return Err(DotAgentError::RuleAlreadyExists {
140 name: name.to_string(),
141 });
142 }
143
144 fs::create_dir_all(self.rules_dir())?;
145 fs::copy(source_file, &path)?;
146
147 Rule::load(&path)
148 }
149
150 pub fn remove(&self, name: &str) -> Result<()> {
152 let rule = self.get(name)?;
153 fs::remove_file(&rule.path)?;
154 Ok(())
155 }
156
157 pub fn rename(&self, name: &str, new_name: &str) -> Result<Rule> {
159 validate_name(new_name)?;
160
161 let old_path = self.rule_path(name);
162 if !old_path.exists() {
163 return Err(DotAgentError::RuleNotFound {
164 name: name.to_string(),
165 });
166 }
167
168 let new_path = self.rule_path(new_name);
169 if new_path.exists() {
170 return Err(DotAgentError::RuleAlreadyExists {
171 name: new_name.to_string(),
172 });
173 }
174
175 fs::rename(&old_path, &new_path)?;
176 Rule::load(&new_path)
177 }
178
179 pub fn update(&self, name: &str, content: &str) -> Result<Rule> {
181 let path = self.rule_path(name);
182 if !path.exists() {
183 return Err(DotAgentError::RuleNotFound {
184 name: name.to_string(),
185 });
186 }
187
188 fs::write(&path, content)?;
189 Rule::load(&path)
190 }
191}
192
193#[derive(Debug)]
199pub struct ApplyResult {
200 pub new_profile_name: String,
201 pub new_profile_path: PathBuf,
202 pub files_modified: usize,
203}
204
205pub struct RuleExecutor<'a> {
207 rule: &'a Rule,
208 profile_manager: &'a ProfileManager,
209}
210
211impl<'a> RuleExecutor<'a> {
212 pub fn new(rule: &'a Rule, profile_manager: &'a ProfileManager) -> Self {
213 Self {
214 rule,
215 profile_manager,
216 }
217 }
218
219 pub fn generate_prompt(&self, profile: &Profile) -> Result<String> {
221 let mut files_content = String::new();
223 for entry in walkdir::WalkDir::new(&profile.path)
224 .into_iter()
225 .filter_map(|e| e.ok())
226 .filter(|e| e.path().is_file())
227 .filter(|e| {
228 e.path()
229 .extension()
230 .is_some_and(|ext| ext == "md" || ext == "toml")
231 })
232 {
233 if let Ok(content) = fs::read_to_string(entry.path()) {
234 let relative = entry
235 .path()
236 .strip_prefix(&profile.path)
237 .unwrap_or(entry.path());
238 files_content.push_str(&format!(
239 "### {}\n```\n{}\n```\n\n",
240 relative.display(),
241 content
242 ));
243 }
244 }
245
246 Ok(format!(
247 r#"You are customizing a Claude Code configuration profile.
248
249## Source Profile: {}
250
251### Current Files
252{}
253
254## Customization Rule
255
256{}
257
258## Your Task
259
260Apply the customization rule to the profile. Output the changes in this format:
261
262```
263ACTION: CREATE|MODIFY|DELETE
264FILE: <relative path>
265CONTENT:
266<file content>
267```
268
269Only output file changes. No explanations needed.
270"#,
271 profile.name, files_content, self.rule.content
272 ))
273 }
274
275 pub fn apply(
277 &self,
278 profile: &Profile,
279 new_name: Option<&str>,
280 dry_run: bool,
281 ) -> Result<ApplyResult> {
282 self.apply_with_retries(profile, new_name, dry_run, 3)
283 }
284
285 pub fn apply_with_retries(
287 &self,
288 profile: &Profile,
289 new_name: Option<&str>,
290 dry_run: bool,
291 max_retries: usize,
292 ) -> Result<ApplyResult> {
293 if !check_claude_cli() {
294 return Err(DotAgentError::ClaudeNotFound);
295 }
296
297 let new_profile_name = new_name
299 .map(|s| s.to_string())
300 .unwrap_or_else(|| format!("{}-{}", profile.name, self.rule.name));
301
302 let new_profile_path = self.profile_manager.profiles_dir().join(&new_profile_name);
303
304 if dry_run {
305 return Ok(ApplyResult {
306 new_profile_name,
307 new_profile_path,
308 files_modified: 0,
309 });
310 }
311
312 let new_profile =
314 self.profile_manager
315 .import_profile(&profile.path, &new_profile_name, false)?;
316
317 let prompt = self.generate_prompt(profile)?;
319 let mut last_error = None;
320
321 for attempt in 0..=max_retries {
322 if attempt > 0 {
323 eprintln!(
324 "Validation failed, retrying... (attempt {}/{})",
325 attempt + 1,
326 max_retries + 1
327 );
328 }
329
330 let output = execute_claude(&new_profile.path, &prompt)?;
331 let files_modified = apply_ai_output(&new_profile.path, &output)?;
332
333 match validate_profile(&new_profile.path) {
335 Ok(()) => {
336 return Ok(ApplyResult {
337 new_profile_name,
338 new_profile_path: new_profile.path,
339 files_modified,
340 });
341 }
342 Err(e) => {
343 last_error = Some(e);
344 }
346 }
347 }
348
349 Err(
351 last_error.unwrap_or_else(|| DotAgentError::ClaudeExecutionFailed {
352 message: "Validation failed after all retries".to_string(),
353 }),
354 )
355 }
356}
357
358pub fn extract_rule(profile: &Profile, rule_name: &str, manager: &RuleManager) -> Result<Rule> {
364 if !check_claude_cli() {
365 return Err(DotAgentError::ClaudeNotFound);
366 }
367
368 let mut files_content = String::new();
370 for entry in walkdir::WalkDir::new(&profile.path)
371 .into_iter()
372 .filter_map(|e| e.ok())
373 .filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
374 {
375 if let Ok(content) = fs::read_to_string(entry.path()) {
376 let relative = entry
377 .path()
378 .strip_prefix(&profile.path)
379 .unwrap_or(entry.path());
380 files_content.push_str(&format!(
381 "### {}\n```\n{}\n```\n\n",
382 relative.display(),
383 content
384 ));
385 }
386 }
387
388 let extract_prompt = format!(
389 r#"Analyze this profile and extract the key customization patterns as a reusable rule.
390
391## Profile: {}
392
393{}
394
395## Task
396
397Create a markdown rule that captures:
3981. Language/framework specific patterns
3992. Coding style preferences
4003. Tool configurations
4014. Recommended libraries/crates
402
403Output ONLY the rule content in markdown format. Start with a heading.
404"#,
405 profile.name, files_content
406 );
407
408 let rules_dir = manager.rules_dir();
409 fs::create_dir_all(&rules_dir)?;
410
411 let generated_content = execute_claude(&rules_dir, &extract_prompt)?;
412
413 let rule_path = manager.rules_dir().join(format!("{}.md", rule_name));
415 fs::write(&rule_path, &generated_content)?;
416
417 Rule::load(&rule_path)
418}
419
420pub fn generate_rule(
423 instruction: &str,
424 rule_name: Option<&str>,
425 manager: &RuleManager,
426) -> Result<Rule> {
427 if !check_claude_cli() {
428 return Err(DotAgentError::ClaudeNotFound);
429 }
430
431 let rules_dir = manager.rules_dir();
432 fs::create_dir_all(&rules_dir)?;
433
434 let (final_name, generated_content) = match rule_name {
435 Some(name) => {
436 let prompt = format!(
437 r##"Create a customization rule based on this instruction:
438
439"{}"
440
441The rule will be used to customize Claude Code configuration profiles.
442
443Output a markdown document that includes:
4441. Clear section headings
4452. Specific patterns or conventions to follow
4463. Any recommended libraries, tools, or configurations
447
448Start with a heading like "# {} Customization Rule"
449"##,
450 instruction, name
451 );
452 let content = execute_claude(&rules_dir, &prompt)?;
453 (name.to_string(), content)
454 }
455 None => {
456 let prompt = format!(
457 r##"Create a customization rule based on this instruction:
458
459"{}"
460
461The rule will be used to customize Claude Code configuration profiles.
462
463IMPORTANT: On the FIRST line, output a suggested rule name in this exact format:
464NAME: <kebab-case-name>
465
466The name should be:
467- Lowercase kebab-case (e.g., "rust-optimization", "python-style")
468- Short and descriptive (2-4 words)
469- Based on the instruction content
470
471Then output a markdown document that includes:
4721. Clear section headings
4732. Specific patterns or conventions to follow
4743. Any recommended libraries, tools, or configurations
475"##,
476 instruction
477 );
478 let content = execute_claude(&rules_dir, &prompt)?;
479 let (name, content) = parse_name_from_output(&content)?;
480 (name, content)
481 }
482 };
483
484 let rule_path = rules_dir.join(format!("{}.md", final_name));
485 fs::write(&rule_path, &generated_content)?;
486
487 Rule::load(&rule_path)
488}
489
490fn parse_name_from_output(output: &str) -> Result<(String, String)> {
492 let mut lines = output.lines();
493
494 for line in lines.by_ref() {
496 let trimmed = line.trim();
497 if let Some(name) = trimmed.strip_prefix("NAME:") {
498 let name = name.trim().to_lowercase().replace(' ', "-");
499 if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
501 return Err(DotAgentError::RuleNotFound {
502 name: "AI generated invalid rule name".to_string(),
503 });
504 }
505 let remaining: String = lines.collect::<Vec<_>>().join("\n");
507 let content = remaining.trim_start().to_string();
508 return Ok((name, content));
509 }
510 }
511
512 Err(DotAgentError::RuleNotFound {
513 name: "AI did not generate NAME: line".to_string(),
514 })
515}
516
517fn validate_profile(profile_path: &Path) -> Result<()> {
523 for entry in walkdir::WalkDir::new(profile_path)
524 .into_iter()
525 .filter_map(|e| e.ok())
526 .filter(|e| e.path().is_file())
527 .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
528 {
529 let path = entry.path();
530 let content = fs::read_to_string(path)?;
531 if let Err(e) = content.parse::<toml::Table>() {
532 return Err(DotAgentError::TomlError {
533 path: path.to_path_buf(),
534 message: e.to_string(),
535 });
536 }
537 }
538 Ok(())
539}
540
541fn apply_ai_output(profile_path: &Path, output: &str) -> Result<usize> {
542 let mut files_modified = 0;
543
544 let mut lines = output.lines().peekable();
545 while let Some(line) = lines.next() {
546 if line.starts_with("ACTION:") {
547 let action = line.trim_start_matches("ACTION:").trim();
548
549 let file_line = lines.next().unwrap_or("");
550 if !file_line.starts_with("FILE:") {
551 continue;
552 }
553 let file_path = file_line.trim_start_matches("FILE:").trim();
554
555 let content_line = lines.next().unwrap_or("");
556 if !content_line.starts_with("CONTENT:") {
557 continue;
558 }
559
560 let mut content = String::new();
561 while let Some(line) = lines.peek() {
562 if line.starts_with("ACTION:") {
563 break;
564 }
565 let line = lines.next().unwrap_or("");
566 if line.trim().starts_with("```") {
568 continue;
569 }
570 content.push_str(line);
571 content.push('\n');
572 }
573
574 let target_path = profile_path.join(file_path);
575
576 match action {
577 "CREATE" | "MODIFY" => {
578 if let Some(parent) = target_path.parent() {
579 fs::create_dir_all(parent)?;
580 }
581 fs::write(&target_path, content.trim())?;
582 files_modified += 1;
583 }
584 "DELETE" => {
585 if target_path.exists() {
586 fs::remove_file(&target_path)?;
587 files_modified += 1;
588 }
589 }
590 _ => {}
591 }
592 }
593 }
594
595 Ok(files_modified)
596}
597
598fn validate_name(name: &str) -> Result<()> {
599 if name.is_empty() || name.len() > 64 {
600 return Err(DotAgentError::InvalidRuleName {
601 name: name.to_string(),
602 });
603 }
604
605 let first = name.chars().next().unwrap();
606 if !first.is_ascii_alphabetic() {
607 return Err(DotAgentError::InvalidRuleName {
608 name: name.to_string(),
609 });
610 }
611
612 for c in name.chars() {
613 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
614 return Err(DotAgentError::InvalidRuleName {
615 name: name.to_string(),
616 });
617 }
618 }
619
620 Ok(())
621}
622
623fn generate_rule_template(name: &str) -> String {
624 format!(
625 r#"# {} Customization Rule
626
627## Language
628(e.g., Rust, Kotlin, TypeScript)
629
630## Recommended Libraries
631- library1
632- library2
633
634## Coding Style
635- Style guideline 1
636- Style guideline 2
637
638## Replace Sections
639(Describe what sections to replace and with what content)
640
641## Additional Rules
642(Any other customization instructions)
643"#,
644 name
645 )
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use tempfile::TempDir;
652
653 #[test]
654 fn test_validate_name_valid() {
655 assert!(validate_name("rust").is_ok());
656 assert!(validate_name("my-rule").is_ok());
657 assert!(validate_name("python_3").is_ok());
658 }
659
660 #[test]
661 fn test_validate_name_invalid() {
662 assert!(validate_name("").is_err());
663 assert!(validate_name("123start").is_err());
664 assert!(validate_name("has space").is_err());
665 assert!(validate_name("has.dot").is_err());
666 }
667
668 #[test]
669 fn test_create_rule() {
670 let temp = TempDir::new().unwrap();
671 let manager = RuleManager::new(temp.path().to_path_buf());
672
673 let rule = manager.create("test").unwrap();
674 assert_eq!(rule.name, "test");
675 assert!(rule.path.exists());
676 assert!(rule.content.contains("# test Customization Rule"));
677 }
678
679 #[test]
680 fn test_list_rules() {
681 let temp = TempDir::new().unwrap();
682 let manager = RuleManager::new(temp.path().to_path_buf());
683
684 manager.create("alpha").unwrap();
685 manager.create("beta").unwrap();
686
687 let list = manager.list().unwrap();
688 assert_eq!(list.len(), 2);
689 assert_eq!(list[0].name, "alpha");
690 assert_eq!(list[1].name, "beta");
691 }
692
693 #[test]
694 fn test_remove_rule() {
695 let temp = TempDir::new().unwrap();
696 let manager = RuleManager::new(temp.path().to_path_buf());
697
698 manager.create("test").unwrap();
699 assert!(manager.get("test").is_ok());
700
701 manager.remove("test").unwrap();
702 assert!(manager.get("test").is_err());
703 }
704
705 #[test]
706 fn test_rule_already_exists() {
707 let temp = TempDir::new().unwrap();
708 let manager = RuleManager::new(temp.path().to_path_buf());
709
710 manager.create("test").unwrap();
711 let result = manager.create("test");
712 assert!(matches!(
713 result,
714 Err(DotAgentError::RuleAlreadyExists { .. })
715 ));
716 }
717}