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