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::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// ============================================================================
16// Rule Entity
17// ============================================================================
18
19/// A customization rule (single markdown file).
20#[derive(Debug)]
21pub struct Rule {
22    pub name: String,
23    pub path: PathBuf,
24    pub content: String,
25}
26
27impl Rule {
28    /// Load a rule from its file path.
29    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    /// Get a short summary (first non-empty, non-heading line).
53    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
64// ============================================================================
65// RuleManager
66// ============================================================================
67
68/// Manages rule CRUD operations.
69pub 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    /// List all registered rules.
87    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    /// Get a specific rule by name.
109    pub fn get(&self, name: &str) -> Result<Rule> {
110        let path = self.rule_path(name);
111        Rule::load(&path)
112    }
113
114    /// Create a new rule with template content.
115    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    /// Import a rule from an existing markdown file.
134    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    /// Remove a rule.
151    pub fn remove(&self, name: &str) -> Result<()> {
152        let rule = self.get(name)?;
153        fs::remove_file(&rule.path)?;
154        Ok(())
155    }
156
157    /// Rename a rule.
158    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    /// Update rule content.
180    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// ============================================================================
194// RuleExecutor - Applies rule to profile
195// ============================================================================
196
197/// Result of rule application.
198#[derive(Debug)]
199pub struct ApplyResult {
200    pub new_profile_name: String,
201    pub new_profile_path: PathBuf,
202    pub files_modified: usize,
203}
204
205/// Executes a rule against a profile to create a new customized profile.
206pub 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    /// Generate the full prompt for AI.
220    pub fn generate_prompt(&self, profile: &Profile) -> Result<String> {
221        // Collect profile files for context
222        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    /// Apply the rule to create a new profile.
276    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    /// Apply the rule with validation and automatic retries.
286    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        // Determine new profile name
298        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        // Copy base profile to new location
313        let new_profile =
314            self.profile_manager
315                .import_profile(&profile.path, &new_profile_name, false)?;
316
317        // Generate prompt and execute AI with retry on validation failure
318        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            // Validate the generated profile
334            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                    // Continue to retry
345                }
346            }
347        }
348
349        // All retries exhausted
350        Err(
351            last_error.unwrap_or_else(|| DotAgentError::ClaudeExecutionFailed {
352                message: "Validation failed after all retries".to_string(),
353            }),
354        )
355    }
356}
357
358// ============================================================================
359// AI Operations
360// ============================================================================
361
362/// Extract a rule from an existing profile using AI.
363pub 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    // Collect profile files
369    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    // Create the rule file
414    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
420/// Generate a rule from natural language instruction.
421/// If `rule_name` is None, the AI will generate a suitable name.
422pub 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
490/// Parse NAME: line from AI output and return (name, remaining_content)
491fn parse_name_from_output(output: &str) -> Result<(String, String)> {
492    let mut lines = output.lines();
493
494    // Find NAME: line
495    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            // Validate name
500            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            // Collect remaining content
506            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
517// ============================================================================
518// Helpers
519// ============================================================================
520
521/// Validate a profile by checking all TOML files can be parsed.
522fn 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                // Skip markdown code block markers
567                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}