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.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    /// Update rule content.
158    pub fn update(&self, name: &str, content: &str) -> Result<Rule> {
159        let path = self.rule_path(name);
160        if !path.exists() {
161            return Err(DotAgentError::RuleNotFound {
162                name: name.to_string(),
163            });
164        }
165
166        fs::write(&path, content)?;
167        Rule::load(&path)
168    }
169}
170
171// ============================================================================
172// RuleExecutor - Applies rule to profile
173// ============================================================================
174
175/// Result of rule application.
176#[derive(Debug)]
177pub struct ApplyResult {
178    pub new_profile_name: String,
179    pub new_profile_path: PathBuf,
180    pub files_modified: usize,
181}
182
183/// Executes a rule against a profile to create a new customized profile.
184pub struct RuleExecutor<'a> {
185    rule: &'a Rule,
186    profile_manager: &'a ProfileManager,
187}
188
189impl<'a> RuleExecutor<'a> {
190    pub fn new(rule: &'a Rule, profile_manager: &'a ProfileManager) -> Self {
191        Self {
192            rule,
193            profile_manager,
194        }
195    }
196
197    /// Generate the full prompt for AI.
198    pub fn generate_prompt(&self, profile: &Profile) -> Result<String> {
199        // Collect profile files for context
200        let mut files_content = String::new();
201        for entry in walkdir::WalkDir::new(&profile.path)
202            .into_iter()
203            .filter_map(|e| e.ok())
204            .filter(|e| e.path().is_file())
205            .filter(|e| {
206                e.path()
207                    .extension()
208                    .is_some_and(|ext| ext == "md" || ext == "toml")
209            })
210        {
211            if let Ok(content) = fs::read_to_string(entry.path()) {
212                let relative = entry
213                    .path()
214                    .strip_prefix(&profile.path)
215                    .unwrap_or(entry.path());
216                files_content.push_str(&format!(
217                    "### {}\n```\n{}\n```\n\n",
218                    relative.display(),
219                    content
220                ));
221            }
222        }
223
224        Ok(format!(
225            r#"You are customizing a Claude Code configuration profile.
226
227## Source Profile: {}
228
229### Current Files
230{}
231
232## Customization Rule
233
234{}
235
236## Your Task
237
238Apply the customization rule to the profile. Output the changes in this format:
239
240```
241ACTION: CREATE|MODIFY|DELETE
242FILE: <relative path>
243CONTENT:
244<file content>
245```
246
247Only output file changes. No explanations needed.
248"#,
249            profile.name, files_content, self.rule.content
250        ))
251    }
252
253    /// Apply the rule to create a new profile.
254    pub fn apply(
255        &self,
256        profile: &Profile,
257        new_name: Option<&str>,
258        dry_run: bool,
259    ) -> Result<ApplyResult> {
260        if !check_claude_cli() {
261            return Err(DotAgentError::ClaudeNotFound);
262        }
263
264        // Determine new profile name
265        let new_profile_name = new_name
266            .map(|s| s.to_string())
267            .unwrap_or_else(|| format!("{}-{}", profile.name, self.rule.name));
268
269        let new_profile_path = self.profile_manager.profiles_dir().join(&new_profile_name);
270
271        if dry_run {
272            return Ok(ApplyResult {
273                new_profile_name,
274                new_profile_path,
275                files_modified: 0,
276            });
277        }
278
279        // Copy base profile to new location
280        let new_profile = self
281            .profile_manager
282            .import_profile(&profile.path, &new_profile_name, false)?;
283
284        // Generate prompt and execute AI
285        let prompt = self.generate_prompt(profile)?;
286        let output = execute_claude(&new_profile.path, &prompt)?;
287
288        // Apply changes
289        let files_modified = apply_ai_output(&new_profile.path, &output)?;
290
291        Ok(ApplyResult {
292            new_profile_name,
293            new_profile_path: new_profile.path,
294            files_modified,
295        })
296    }
297}
298
299// ============================================================================
300// AI Operations
301// ============================================================================
302
303/// Extract a rule from an existing profile using AI.
304pub fn extract_rule(
305    profile: &Profile,
306    rule_name: &str,
307    manager: &RuleManager,
308) -> Result<Rule> {
309    if !check_claude_cli() {
310        return Err(DotAgentError::ClaudeNotFound);
311    }
312
313    // Collect profile files
314    let mut files_content = String::new();
315    for entry in walkdir::WalkDir::new(&profile.path)
316        .into_iter()
317        .filter_map(|e| e.ok())
318        .filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
319    {
320        if let Ok(content) = fs::read_to_string(entry.path()) {
321            let relative = entry
322                .path()
323                .strip_prefix(&profile.path)
324                .unwrap_or(entry.path());
325            files_content.push_str(&format!(
326                "### {}\n```\n{}\n```\n\n",
327                relative.display(),
328                content
329            ));
330        }
331    }
332
333    let extract_prompt = format!(
334        r#"Analyze this profile and extract the key customization patterns as a reusable rule.
335
336## Profile: {}
337
338{}
339
340## Task
341
342Create a markdown rule that captures:
3431. Language/framework specific patterns
3442. Coding style preferences
3453. Tool configurations
3464. Recommended libraries/crates
347
348Output ONLY the rule content in markdown format. Start with a heading.
349"#,
350        profile.name, files_content
351    );
352
353    let rules_dir = manager.rules_dir();
354    fs::create_dir_all(&rules_dir)?;
355
356    let generated_content = execute_claude(&rules_dir, &extract_prompt)?;
357
358    // Create the rule file
359    let rule_path = manager.rules_dir().join(format!("{}.md", rule_name));
360    fs::write(&rule_path, &generated_content)?;
361
362    Rule::load(&rule_path)
363}
364
365/// Generate a rule from natural language instruction.
366pub fn generate_rule(
367    instruction: &str,
368    rule_name: &str,
369    manager: &RuleManager,
370) -> Result<Rule> {
371    if !check_claude_cli() {
372        return Err(DotAgentError::ClaudeNotFound);
373    }
374
375    let generate_prompt = format!(
376        r##"Create a customization rule based on this instruction:
377
378"{}"
379
380The rule will be used to customize Claude Code configuration profiles.
381
382Output a markdown document that includes:
3831. Clear section headings
3842. Specific patterns or conventions to follow
3853. Any recommended libraries, tools, or configurations
386
387Start with a heading like "# {} Customization Rule"
388"##,
389        instruction, rule_name
390    );
391
392    let rules_dir = manager.rules_dir();
393    fs::create_dir_all(&rules_dir)?;
394
395    let generated_content = execute_claude(&rules_dir, &generate_prompt)?;
396
397    let rule_path = manager.rules_dir().join(format!("{}.md", rule_name));
398    fs::write(&rule_path, &generated_content)?;
399
400    Rule::load(&rule_path)
401}
402
403// ============================================================================
404// Helpers
405// ============================================================================
406
407fn check_claude_cli() -> bool {
408    Command::new("claude")
409        .arg("--version")
410        .stdout(Stdio::null())
411        .stderr(Stdio::null())
412        .status()
413        .map(|s| s.success())
414        .unwrap_or(false)
415}
416
417fn execute_claude(working_dir: &Path, prompt: &str) -> Result<String> {
418    let mut cmd = Command::new("claude");
419    cmd.arg("--print");
420    cmd.arg("--dangerously-skip-permissions");
421    cmd.current_dir(working_dir);
422    cmd.stdin(Stdio::piped());
423    cmd.stdout(Stdio::piped());
424    cmd.stderr(Stdio::piped());
425
426    let mut child = cmd.spawn().map_err(|e| DotAgentError::ClaudeExecutionFailed {
427        message: format!("Failed to spawn claude: {}", e),
428    })?;
429
430    if let Some(mut stdin) = child.stdin.take() {
431        stdin.write_all(prompt.as_bytes()).map_err(|e| {
432            DotAgentError::ClaudeExecutionFailed {
433                message: format!("Failed to write prompt: {}", e),
434            }
435        })?;
436    }
437
438    let output = child.wait_with_output().map_err(|e| {
439        DotAgentError::ClaudeExecutionFailed {
440            message: format!("Execution failed: {}", e),
441        }
442    })?;
443
444    if !output.status.success() {
445        let stderr = String::from_utf8_lossy(&output.stderr);
446        return Err(DotAgentError::ClaudeExecutionFailed {
447            message: format!("Claude exited with error: {}", stderr),
448        });
449    }
450
451    Ok(String::from_utf8_lossy(&output.stdout).to_string())
452}
453
454fn apply_ai_output(profile_path: &Path, output: &str) -> Result<usize> {
455    let mut files_modified = 0;
456
457    let mut lines = output.lines().peekable();
458    while let Some(line) = lines.next() {
459        if line.starts_with("ACTION:") {
460            let action = line.trim_start_matches("ACTION:").trim();
461
462            let file_line = lines.next().unwrap_or("");
463            if !file_line.starts_with("FILE:") {
464                continue;
465            }
466            let file_path = file_line.trim_start_matches("FILE:").trim();
467
468            let content_line = lines.next().unwrap_or("");
469            if !content_line.starts_with("CONTENT:") {
470                continue;
471            }
472
473            let mut content = String::new();
474            while let Some(line) = lines.peek() {
475                if line.starts_with("ACTION:") {
476                    break;
477                }
478                content.push_str(lines.next().unwrap_or(""));
479                content.push('\n');
480            }
481
482            let target_path = profile_path.join(file_path);
483
484            match action {
485                "CREATE" | "MODIFY" => {
486                    if let Some(parent) = target_path.parent() {
487                        fs::create_dir_all(parent)?;
488                    }
489                    fs::write(&target_path, content.trim())?;
490                    files_modified += 1;
491                }
492                "DELETE" => {
493                    if target_path.exists() {
494                        fs::remove_file(&target_path)?;
495                        files_modified += 1;
496                    }
497                }
498                _ => {}
499            }
500        }
501    }
502
503    Ok(files_modified)
504}
505
506fn validate_name(name: &str) -> Result<()> {
507    if name.is_empty() || name.len() > 64 {
508        return Err(DotAgentError::InvalidRuleName {
509            name: name.to_string(),
510        });
511    }
512
513    let first = name.chars().next().unwrap();
514    if !first.is_ascii_alphabetic() {
515        return Err(DotAgentError::InvalidRuleName {
516            name: name.to_string(),
517        });
518    }
519
520    for c in name.chars() {
521        if !c.is_ascii_alphanumeric() && c != '-' && c != '_' {
522            return Err(DotAgentError::InvalidRuleName {
523                name: name.to_string(),
524            });
525        }
526    }
527
528    Ok(())
529}
530
531fn generate_rule_template(name: &str) -> String {
532    format!(
533        r#"# {} Customization Rule
534
535## Language
536(e.g., Rust, Kotlin, TypeScript)
537
538## Recommended Libraries
539- library1
540- library2
541
542## Coding Style
543- Style guideline 1
544- Style guideline 2
545
546## Replace Sections
547(Describe what sections to replace and with what content)
548
549## Additional Rules
550(Any other customization instructions)
551"#,
552        name
553    )
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use tempfile::TempDir;
560
561    #[test]
562    fn test_validate_name_valid() {
563        assert!(validate_name("rust").is_ok());
564        assert!(validate_name("my-rule").is_ok());
565        assert!(validate_name("python_3").is_ok());
566    }
567
568    #[test]
569    fn test_validate_name_invalid() {
570        assert!(validate_name("").is_err());
571        assert!(validate_name("123start").is_err());
572        assert!(validate_name("has space").is_err());
573        assert!(validate_name("has.dot").is_err());
574    }
575
576    #[test]
577    fn test_create_rule() {
578        let temp = TempDir::new().unwrap();
579        let manager = RuleManager::new(temp.path().to_path_buf());
580
581        let rule = manager.create("test").unwrap();
582        assert_eq!(rule.name, "test");
583        assert!(rule.path.exists());
584        assert!(rule.content.contains("# test Customization Rule"));
585    }
586
587    #[test]
588    fn test_list_rules() {
589        let temp = TempDir::new().unwrap();
590        let manager = RuleManager::new(temp.path().to_path_buf());
591
592        manager.create("alpha").unwrap();
593        manager.create("beta").unwrap();
594
595        let list = manager.list().unwrap();
596        assert_eq!(list.len(), 2);
597        assert_eq!(list[0].name, "alpha");
598        assert_eq!(list[1].name, "beta");
599    }
600
601    #[test]
602    fn test_remove_rule() {
603        let temp = TempDir::new().unwrap();
604        let manager = RuleManager::new(temp.path().to_path_buf());
605
606        manager.create("test").unwrap();
607        assert!(manager.get("test").is_ok());
608
609        manager.remove("test").unwrap();
610        assert!(manager.get("test").is_err());
611    }
612
613    #[test]
614    fn test_rule_already_exists() {
615        let temp = TempDir::new().unwrap();
616        let manager = RuleManager::new(temp.path().to_path_buf());
617
618        manager.create("test").unwrap();
619        let result = manager.create("test");
620        assert!(matches!(result, Err(DotAgentError::RuleAlreadyExists { .. })));
621    }
622}