acp/sync/
mod.rs

1//! @acp:module "Tool Sync"
2//! @acp:summary "Sync ACP context to AI tool configuration files"
3//! @acp:domain cli
4//! @acp:layer service
5//!
6//! This module implements tool synchronization for transparent AI tool integration.
7//!
8//! ## Overview
9//!
10//! The sync system generates tool-specific configuration files that inject ACP context
11//! into AI development tools automatically. Users run `acp init` once, and context flows
12//! to all their tools without manual intervention.
13//!
14//! ## Supported Tools
15//!
16//! - Cursor (.cursorrules)
17//! - Claude Code (CLAUDE.md)
18//! - GitHub Copilot (.github/copilot-instructions.md)
19//! - Continue.dev (.continue/config.json)
20//! - Windsurf (.windsurfrules)
21//! - Cline (.clinerules)
22//! - Aider (.aider.conf.yml)
23//! - Generic fallback (AGENTS.md)
24
25pub mod adapter;
26pub mod adapters;
27pub mod content;
28pub mod merge;
29pub mod tool;
30
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33
34pub use adapter::{BootstrapContext, DetectionResult, ToolAdapter};
35pub use tool::{MergeStrategy, OutputFormat, Tool};
36
37use crate::error::Result;
38use adapters::*;
39
40/// Main sync executor - coordinates tool detection and bootstrap generation
41pub struct SyncExecutor {
42    adapters: HashMap<Tool, Box<dyn ToolAdapter>>,
43}
44
45impl SyncExecutor {
46    /// Create a new sync executor with all built-in adapters
47    pub fn new() -> Self {
48        let mut adapters: HashMap<Tool, Box<dyn ToolAdapter>> = HashMap::new();
49
50        adapters.insert(Tool::Cursor, Box::new(CursorAdapter));
51        adapters.insert(Tool::ClaudeCode, Box::new(ClaudeCodeAdapter));
52        adapters.insert(Tool::Copilot, Box::new(CopilotAdapter));
53        adapters.insert(Tool::Continue, Box::new(ContinueAdapter));
54        adapters.insert(Tool::Windsurf, Box::new(WindsurfAdapter));
55        adapters.insert(Tool::Cline, Box::new(ClineAdapter));
56        adapters.insert(Tool::Aider, Box::new(AiderAdapter));
57        adapters.insert(Tool::Generic, Box::new(GenericAdapter));
58
59        Self { adapters }
60    }
61
62    /// Detect which tools are in use in the project
63    pub fn detect_tools(&self, project_root: &Path) -> Vec<Tool> {
64        self.adapters
65            .iter()
66            .filter_map(|(tool, adapter)| {
67                let result = adapter.detect(project_root);
68                // Don't auto-include Generic - it's added separately as fallback
69                if result.detected && *tool != Tool::Generic {
70                    Some(*tool)
71                } else {
72                    None
73                }
74            })
75            .collect()
76    }
77
78    /// Get detection results for all tools
79    pub fn detect_all(&self, project_root: &Path) -> Vec<DetectionResult> {
80        self.adapters
81            .values()
82            .map(|adapter| adapter.detect(project_root))
83            .collect()
84    }
85
86    /// Bootstrap a single tool with ACP context
87    pub fn bootstrap_tool(&self, tool: Tool, project_root: &Path) -> Result<BootstrapResult> {
88        let adapter = self.adapters.get(&tool).ok_or_else(|| {
89            crate::error::AcpError::Other(format!("No adapter for tool: {:?}", tool))
90        })?;
91
92        let context = BootstrapContext { project_root, tool };
93
94        // Generate content
95        let content = adapter.generate(&context)?;
96
97        // Validate content
98        adapter.validate(&content)?;
99
100        // Determine output path
101        let output_path = project_root.join(tool.output_path());
102
103        // Create parent directories if needed
104        if let Some(parent) = output_path.parent() {
105            if !parent.exists() {
106                std::fs::create_dir_all(parent)?;
107            }
108        }
109
110        // Handle existing file
111        let action = if output_path.exists() {
112            let existing = std::fs::read_to_string(&output_path)?;
113            let (start_marker, end_marker) = adapter.section_markers();
114
115            let merged = if start_marker.is_empty() {
116                // Special handling for JSON (Continue.dev)
117                if tool == Tool::Continue {
118                    merge::merge_json(&existing, &content)
119                        .map_err(|e| crate::error::AcpError::Other(e.to_string()))?
120                } else {
121                    content.clone()
122                }
123            } else {
124                merge::merge_content(
125                    adapter.merge_strategy(),
126                    &existing,
127                    &content,
128                    start_marker,
129                    end_marker,
130                )
131            };
132
133            std::fs::write(&output_path, merged)?;
134            BootstrapAction::Merged
135        } else {
136            // New file - wrap with markers if applicable
137            let (start_marker, end_marker) = adapter.section_markers();
138            let final_content = if !start_marker.is_empty() {
139                format!("{}\n{}\n{}", start_marker, content, end_marker)
140            } else {
141                content
142            };
143
144            std::fs::write(&output_path, final_content)?;
145            BootstrapAction::Created
146        };
147
148        Ok(BootstrapResult {
149            tool,
150            output_path,
151            action,
152        })
153    }
154
155    /// Bootstrap all detected tools plus the generic fallback
156    pub fn bootstrap_all(&self, project_root: &Path) -> Vec<Result<BootstrapResult>> {
157        let mut tools = self.detect_tools(project_root);
158
159        // Always include generic as fallback if AGENTS.md doesn't exist
160        if !project_root.join("AGENTS.md").exists() {
161            tools.push(Tool::Generic);
162        }
163
164        tools
165            .into_iter()
166            .map(|tool| self.bootstrap_tool(tool, project_root))
167            .collect()
168    }
169}
170
171impl Default for SyncExecutor {
172    fn default() -> Self {
173        Self::new()
174    }
175}
176
177/// Result of bootstrapping a tool
178#[derive(Debug)]
179pub struct BootstrapResult {
180    pub tool: Tool,
181    pub output_path: PathBuf,
182    pub action: BootstrapAction,
183}
184
185/// Action taken during bootstrap
186#[derive(Debug, PartialEq, Eq)]
187pub enum BootstrapAction {
188    /// File was created
189    Created,
190    /// Existing file was merged
191    Merged,
192    /// File was skipped
193    Skipped,
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use tempfile::TempDir;
200
201    #[test]
202    fn test_sync_executor_creation() {
203        let executor = SyncExecutor::new();
204        assert_eq!(executor.adapters.len(), 8);
205    }
206
207    #[test]
208    fn test_detect_tools_empty_project() {
209        let temp = TempDir::new().unwrap();
210        let executor = SyncExecutor::new();
211        let detected = executor.detect_tools(temp.path());
212
213        // Fresh project should only detect Claude if 'claude' CLI is in PATH
214        // Generic is never auto-included in detect_tools
215        assert!(!detected.contains(&Tool::Cursor));
216        assert!(!detected.contains(&Tool::Copilot));
217        assert!(!detected.contains(&Tool::Generic));
218    }
219
220    #[test]
221    fn test_detect_tools_with_cursorrules() {
222        let temp = TempDir::new().unwrap();
223        std::fs::write(temp.path().join(".cursorrules"), "").unwrap();
224
225        let executor = SyncExecutor::new();
226        let detected = executor.detect_tools(temp.path());
227
228        assert!(detected.contains(&Tool::Cursor));
229    }
230
231    #[test]
232    fn test_bootstrap_creates_file() {
233        let temp = TempDir::new().unwrap();
234        let executor = SyncExecutor::new();
235
236        let result = executor.bootstrap_tool(Tool::Generic, temp.path()).unwrap();
237
238        assert_eq!(result.action, BootstrapAction::Created);
239        assert!(result.output_path.exists());
240
241        let content = std::fs::read_to_string(&result.output_path).unwrap();
242        assert!(content.contains("ACP Context"));
243    }
244
245    #[test]
246    fn test_bootstrap_merges_existing() {
247        let temp = TempDir::new().unwrap();
248        let existing_content = "# My Project\n\nSome existing content.";
249        std::fs::write(temp.path().join(".cursorrules"), existing_content).unwrap();
250
251        let executor = SyncExecutor::new();
252        let result = executor.bootstrap_tool(Tool::Cursor, temp.path()).unwrap();
253
254        assert_eq!(result.action, BootstrapAction::Merged);
255
256        let content = std::fs::read_to_string(&result.output_path).unwrap();
257        assert!(content.contains("My Project"));
258        assert!(content.contains("ACP Context"));
259        assert!(content.contains("BEGIN ACP GENERATED"));
260    }
261}