agpm_cli/installer/
config_check.rs1use std::collections::HashSet;
8use std::path::Path;
9
10use tokio::fs;
11
12use crate::core::ResourceType;
13use crate::lockfile::LockFile;
14
15#[derive(Debug, Default)]
17pub struct ConfigValidation {
18 pub missing_gitignore_entries: Vec<String>,
20 pub claude_settings_ok: bool,
22 pub claude_settings_warning: Option<String>,
24}
25
26impl ConfigValidation {
27 pub fn is_valid(&self) -> bool {
29 self.missing_gitignore_entries.is_empty() && self.claude_settings_ok
30 }
31}
32
33pub async fn validate_config(
48 project_dir: &Path,
49 lockfile: &LockFile,
50 gitignore_enabled: bool,
51) -> ConfigValidation {
52 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_ok: true,
63 claude_settings_warning: None,
64 }
65}
66
67async 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 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 let installed_types = get_installed_resource_types(lockfile);
93
94 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 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 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 if existing.contains(&normalized) {
127 return;
128 }
129
130 for pattern in existing {
131 if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
133 if let Ok(glob_pattern) = glob::Pattern::new(pattern) {
135 if glob_pattern.matches(&normalized) {
136 return;
137 }
138 }
139 }
140
141 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(); let result = check_gitignore_entries(temp.path(), &lockfile).await;
184
185 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 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 assert!(result.is_empty());
264 Ok(())
265 }
266}