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