Skip to main content

dot_agent_core/
rule.rs

1//! Rule module for profile customization.
2//!
3//! Rules are simple markdown files that describe how to customize a profile.
4//! They are applied to base profiles to create new customized profiles.
5
6use 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// ============================================================================
17// Rule Entity
18// ============================================================================
19
20/// A customization rule (single markdown file).
21#[derive(Debug)]
22pub struct Rule {
23    pub name: String,
24    pub path: PathBuf,
25    pub content: String,
26}
27
28impl Rule {
29    /// Load a rule from its file path.
30    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    /// Get a short summary (first non-empty, non-heading line).
54    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
65// ============================================================================
66// RuleManager
67// ============================================================================
68
69/// Manages rule CRUD operations.
70pub 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    /// List all registered rules.
88    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    /// Get a specific rule by name.
110    pub fn get(&self, name: &str) -> Result<Rule> {
111        let path = self.rule_path(name);
112        Rule::load(&path)
113    }
114
115    /// Create a new rule with template content.
116    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    /// Import a rule from an existing markdown file.
135    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    /// Remove a rule.
152    pub fn remove(&self, name: &str) -> Result<()> {
153        let rule = self.get(name)?;
154        fs::remove_file(&rule.path)?;
155        Ok(())
156    }
157
158    /// Rename a rule.
159    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    /// Update rule content.
181    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// ============================================================================
195// RuleExecutor - Applies rule to profile
196// ============================================================================
197
198/// Result of rule application.
199#[derive(Debug)]
200pub struct ApplyResult {
201    pub new_profile_name: String,
202    pub new_profile_path: PathBuf,
203    pub files_modified: usize,
204}
205
206/// Executes a rule against a profile to create a new customized profile.
207pub 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    /// Generate the full prompt for AI.
221    pub fn generate_prompt(&self, profile: &Profile) -> Result<String> {
222        // Collect profile files for context
223        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    /// Apply the rule to create a new profile.
277    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    /// Apply the rule with validation and automatic retries.
287    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        // Determine new profile name
299        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        // Copy base profile to new location
314        let new_profile =
315            self.profile_manager
316                .import_profile(&profile.path, &new_profile_name, false)?;
317
318        // Generate prompt and execute AI with retry on validation failure
319        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            // Validate the generated profile
335            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                    // Continue to retry
346                }
347            }
348        }
349
350        // All retries exhausted
351        Err(
352            last_error.unwrap_or_else(|| DotAgentError::ClaudeExecutionFailed {
353                message: "Validation failed after all retries".to_string(),
354            }),
355        )
356    }
357}
358
359// ============================================================================
360// AI Operations
361// ============================================================================
362
363/// Extract a rule from an existing profile using AI.
364pub 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    // Collect profile files
370    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    // Create the rule file
415    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
421/// Generate a rule from natural language instruction.
422/// If `rule_name` is None, the AI will generate a suitable name.
423pub 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
491/// Parse NAME: line from AI output and return (name, remaining_content)
492fn parse_name_from_output(output: &str) -> Result<(String, String)> {
493    let mut lines = output.lines();
494
495    // Find NAME: line
496    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            // Validate name
501            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            // Collect remaining content
507            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
518// ============================================================================
519// Helpers
520// ============================================================================
521
522/// Validate a profile by checking all TOML files can be parsed.
523fn 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                // Skip markdown code block markers
617                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}