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 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#[derive(Debug)]
178pub struct ApplyResult {
179 pub new_profile_name: String,
180 pub new_profile_path: PathBuf,
181 pub files_modified: usize,
182}
183
184pub 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 pub fn generate_prompt(&self, profile: &Profile) -> Result<String> {
200 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 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 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 let new_profile =
282 self.profile_manager
283 .import_profile(&profile.path, &new_profile_name, false)?;
284
285 let prompt = self.generate_prompt(profile)?;
287 let output = execute_claude(&new_profile.path, &prompt)?;
288
289 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
300pub 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 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 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
362pub 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
396fn 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}