agpm_cli/installer/
config_check.rs

1//! Configuration validation for AGPM installations.
2//!
3//! Validates that the project is correctly configured for AGPM:
4//! - Required entries exist in .gitignore
5//! - Claude Code settings allow access to gitignored files
6
7use std::collections::HashSet;
8use std::path::Path;
9
10use tokio::fs;
11
12use crate::core::ResourceType;
13use crate::lockfile::LockFile;
14
15/// Result of configuration validation.
16#[derive(Debug, Default)]
17pub struct ConfigValidation {
18    /// Missing .gitignore entries.
19    pub missing_gitignore_entries: Vec<String>,
20    /// Whether Claude Code settings are correctly configured.
21    pub claude_settings_ok: bool,
22    /// Warning message for Claude Code settings (if not ok).
23    pub claude_settings_warning: Option<String>,
24}
25
26impl ConfigValidation {
27    /// Returns true if all configuration is valid.
28    pub fn is_valid(&self) -> bool {
29        self.missing_gitignore_entries.is_empty() && self.claude_settings_ok
30    }
31}
32
33/// Validate project configuration for AGPM.
34///
35/// Checks:
36/// 1. Required .gitignore entries based on installed resource types (if gitignore_enabled)
37///
38/// Note: Claude Code settings check is intentionally not performed here.
39/// The `/config` guidance is only shown during `init` and `migrate` commands
40/// to avoid repetitive warnings on every install/update.
41///
42/// # Arguments
43///
44/// * `project_dir` - Path to the project directory
45/// * `lockfile` - The lockfile containing installed resources
46/// * `gitignore_enabled` - Whether gitignore validation is enabled (from manifest)
47pub async fn validate_config(
48    project_dir: &Path,
49    lockfile: &LockFile,
50    gitignore_enabled: bool,
51) -> ConfigValidation {
52    // Check gitignore entries only if enabled
53    let missing_gitignore_entries = if gitignore_enabled {
54        check_gitignore_entries(project_dir, lockfile).await
55    } else {
56        Vec::new()
57    };
58
59    ConfigValidation {
60        missing_gitignore_entries,
61        // Claude settings check removed - guidance shown only during init/migrate
62        claude_settings_ok: true,
63        claude_settings_warning: None,
64    }
65}
66
67/// Check if required .gitignore entries exist.
68///
69/// Returns list of missing entries.
70async fn check_gitignore_entries(project_dir: &Path, lockfile: &LockFile) -> Vec<String> {
71    let gitignore_path = project_dir.join(".gitignore");
72    let gitignore_content = match fs::read_to_string(&gitignore_path).await {
73        Ok(content) => content,
74        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
75        Err(e) => {
76            tracing::warn!("Failed to read .gitignore: {}", e);
77            return Vec::new();
78        }
79    };
80
81    // Parse gitignore into a set of entries (normalized)
82    let existing_entries: HashSet<String> = gitignore_content
83        .lines()
84        .map(|line| line.trim())
85        .filter(|line| !line.is_empty() && !line.starts_with('#'))
86        .map(normalize_gitignore_entry)
87        .collect();
88
89    let mut missing = Vec::new();
90
91    // Determine which resource types are installed
92    let installed_types = get_installed_resource_types(lockfile);
93
94    // Check Claude Code entries
95    if installed_types.contains(&ResourceType::Agent) {
96        check_entry(&existing_entries, ".claude/agents/agpm/", &mut missing);
97    }
98    if installed_types.contains(&ResourceType::Command) {
99        check_entry(&existing_entries, ".claude/commands/agpm/", &mut missing);
100    }
101    if installed_types.contains(&ResourceType::Snippet) {
102        check_entry(&existing_entries, ".claude/snippets/agpm/", &mut missing);
103        check_entry(&existing_entries, ".agpm/snippets/", &mut missing);
104    }
105    if installed_types.contains(&ResourceType::Script) {
106        check_entry(&existing_entries, ".claude/scripts/agpm/", &mut missing);
107    }
108
109    // Always check for private config files
110    check_entry(&existing_entries, "agpm.private.toml", &mut missing);
111    check_entry(&existing_entries, "agpm.private.lock", &mut missing);
112
113    missing
114}
115
116fn normalize_gitignore_entry(entry: &str) -> String {
117    // Remove leading slashes for comparison (relative to repo root)
118    // Preserve trailing slashes (directories only semantics in gitignore)
119    entry.trim_start_matches('/').to_string()
120}
121
122fn check_entry(existing: &HashSet<String>, expected: &str, missing: &mut Vec<String>) {
123    let normalized = normalize_gitignore_entry(expected);
124
125    // First check for exact match
126    if existing.contains(&normalized) {
127        return;
128    }
129
130    for pattern in existing {
131        // Check if pattern contains glob characters
132        if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
133            // Use glob matching: pattern matches expected
134            if let Ok(glob_pattern) = glob::Pattern::new(pattern) {
135                if glob_pattern.matches(&normalized) {
136                    return;
137                }
138            }
139        }
140
141        // Check if a parent directory pattern covers this path
142        // e.g., ".agpm/" covers ".agpm/snippets/"
143        if pattern.ends_with('/') && normalized.starts_with(pattern) {
144            return;
145        }
146    }
147
148    missing.push(expected.to_string());
149}
150
151fn get_installed_resource_types(lockfile: &LockFile) -> HashSet<ResourceType> {
152    let mut types = HashSet::new();
153
154    if !lockfile.agents.is_empty() {
155        types.insert(ResourceType::Agent);
156    }
157    if !lockfile.snippets.is_empty() {
158        types.insert(ResourceType::Snippet);
159    }
160    if !lockfile.commands.is_empty() {
161        types.insert(ResourceType::Command);
162    }
163    if !lockfile.scripts.is_empty() {
164        types.insert(ResourceType::Script);
165    }
166
167    types
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use anyhow::Result;
174    use tempfile::TempDir;
175
176    #[tokio::test]
177    async fn test_missing_gitignore_entries() -> Result<()> {
178        let temp = TempDir::new()?;
179        let gitignore = temp.path().join(".gitignore");
180        std::fs::write(&gitignore, "# empty\n")?;
181
182        let lockfile = LockFile::default(); // Empty lockfile
183        let result = check_gitignore_entries(temp.path(), &lockfile).await;
184
185        // Should always check for private config
186        assert!(result.contains(&"agpm.private.toml".to_string()));
187        assert!(result.contains(&"agpm.private.lock".to_string()));
188        Ok(())
189    }
190
191    #[tokio::test]
192    async fn test_gitignore_entries_with_agents() -> Result<()> {
193        use crate::resolver::lockfile_builder::VariantInputs;
194        use std::collections::BTreeMap;
195
196        let temp = TempDir::new()?;
197        let gitignore = temp.path().join(".gitignore");
198        std::fs::write(&gitignore, "# empty\n")?;
199
200        let mut lockfile = LockFile::default();
201        lockfile.agents.push(crate::lockfile::LockedResource {
202            name: "test".to_string(),
203            source: None,
204            url: None,
205            version: None,
206            path: "agents/test.md".to_string(),
207            resolved_commit: None,
208            checksum: "sha256:test".to_string(),
209            context_checksum: None,
210            installed_at: ".claude/agents/agpm/test.md".to_string(),
211            dependencies: vec![],
212            resource_type: ResourceType::Agent,
213            tool: Some("claude-code".to_string()),
214            manifest_alias: None,
215            variant_inputs: VariantInputs::default(),
216            applied_patches: BTreeMap::new(),
217            install: None,
218            is_private: false,
219            approximate_token_count: None,
220        });
221
222        let result = check_gitignore_entries(temp.path(), &lockfile).await;
223
224        // Should require agent gitignore entry
225        assert!(result.contains(&".claude/agents/agpm/".to_string()));
226        Ok(())
227    }
228
229    #[tokio::test]
230    async fn test_gitignore_entries_satisfied() -> Result<()> {
231        use crate::resolver::lockfile_builder::VariantInputs;
232        use std::collections::BTreeMap;
233
234        let temp = TempDir::new()?;
235        let gitignore = temp.path().join(".gitignore");
236        std::fs::write(&gitignore, ".claude/agents/agpm/\nagpm.private.toml\nagpm.private.lock\n")?;
237
238        let mut lockfile = LockFile::default();
239        lockfile.agents.push(crate::lockfile::LockedResource {
240            name: "test".to_string(),
241            source: None,
242            url: None,
243            version: None,
244            path: "agents/test.md".to_string(),
245            resolved_commit: None,
246            checksum: "sha256:test".to_string(),
247            context_checksum: None,
248            installed_at: ".claude/agents/agpm/test.md".to_string(),
249            dependencies: vec![],
250            resource_type: ResourceType::Agent,
251            tool: Some("claude-code".to_string()),
252            manifest_alias: None,
253            variant_inputs: VariantInputs::default(),
254            applied_patches: BTreeMap::new(),
255            install: None,
256            is_private: false,
257            approximate_token_count: None,
258        });
259
260        let result = check_gitignore_entries(temp.path(), &lockfile).await;
261
262        // All required entries present
263        assert!(result.is_empty());
264        Ok(())
265    }
266}