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 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
48pub async fn validate_config(
63 project_dir: &Path,
64 lockfile: &LockFile,
65 gitignore_enabled: bool,
66) -> ConfigValidation {
67 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_ok: true,
78 claude_settings_warning: None,
79 }
80}
81
82async 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 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 let installed_types = get_installed_resource_types(lockfile);
108
109 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 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 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 if existing.contains(&normalized) {
142 return;
143 }
144
145 for pattern in existing {
146 if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
148 if let Ok(glob_pattern) = glob::Pattern::new(pattern) {
150 if glob_pattern.matches(&normalized) {
151 return;
152 }
153 }
154 }
155
156 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(); let result = check_gitignore_entries(temp.path(), &lockfile).await;
199
200 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 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 assert!(result.is_empty());
277 Ok(())
278 }
279}