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.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 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
64pub 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 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 pub fn get(&self, name: &str) -> Result<Rule> {
110 let path = self.rule_path(name);
111 Rule::load(&path)
112 }
113
114 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 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 pub fn remove(&self, name: &str) -> Result<()> {
152 let rule = self.get(name)?;
153 fs::remove_file(&rule.path)?;
154 Ok(())
155 }
156
157 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#[derive(Debug)]
177pub struct ApplyResult {
178 pub new_profile_name: String,
179 pub new_profile_path: PathBuf,
180 pub files_modified: usize,
181}
182
183pub 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 pub fn generate_prompt(&self, profile: &Profile) -> Result<String> {
199 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 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 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 let new_profile = self
281 .profile_manager
282 .import_profile(&profile.path, &new_profile_name, false)?;
283
284 let prompt = self.generate_prompt(profile)?;
286 let output = execute_claude(&new_profile.path, &prompt)?;
287
288 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
299pub 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 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 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
365pub 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
403fn 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}