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