use std::collections::HashSet;
use std::path::Path;
use tokio::fs;
use crate::core::ResourceType;
use crate::lockfile::LockFile;
#[derive(Debug, Default)]
pub struct ConfigValidation {
pub missing_gitignore_entries: Vec<String>,
pub claude_settings_ok: bool,
pub claude_settings_warning: Option<String>,
}
impl ConfigValidation {
pub fn is_valid(&self) -> bool {
self.missing_gitignore_entries.is_empty() && self.claude_settings_ok
}
}
pub async fn validate_config(
project_dir: &Path,
lockfile: &LockFile,
gitignore_enabled: bool,
) -> ConfigValidation {
let missing_gitignore_entries = if gitignore_enabled {
check_gitignore_entries(project_dir, lockfile).await
} else {
Vec::new()
};
ConfigValidation {
missing_gitignore_entries,
claude_settings_ok: true,
claude_settings_warning: None,
}
}
async fn check_gitignore_entries(project_dir: &Path, lockfile: &LockFile) -> Vec<String> {
let gitignore_path = project_dir.join(".gitignore");
let gitignore_content = match fs::read_to_string(&gitignore_path).await {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
tracing::warn!("Failed to read .gitignore: {}", e);
return Vec::new();
}
};
let existing_entries: HashSet<String> = gitignore_content
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(normalize_gitignore_entry)
.collect();
let mut missing = Vec::new();
let installed_types = get_installed_resource_types(lockfile);
if installed_types.contains(&ResourceType::Agent) {
check_entry(&existing_entries, ".claude/agents/agpm/", &mut missing);
}
if installed_types.contains(&ResourceType::Command) {
check_entry(&existing_entries, ".claude/commands/agpm/", &mut missing);
}
if installed_types.contains(&ResourceType::Snippet) {
check_entry(&existing_entries, ".claude/snippets/agpm/", &mut missing);
check_entry(&existing_entries, ".agpm/snippets/", &mut missing);
}
if installed_types.contains(&ResourceType::Script) {
check_entry(&existing_entries, ".claude/scripts/agpm/", &mut missing);
}
check_entry(&existing_entries, "agpm.private.toml", &mut missing);
check_entry(&existing_entries, "agpm.private.lock", &mut missing);
missing
}
fn normalize_gitignore_entry(entry: &str) -> String {
entry.trim_start_matches('/').to_string()
}
fn check_entry(existing: &HashSet<String>, expected: &str, missing: &mut Vec<String>) {
let normalized = normalize_gitignore_entry(expected);
if existing.contains(&normalized) {
return;
}
for pattern in existing {
if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
if let Ok(glob_pattern) = glob::Pattern::new(pattern) {
if glob_pattern.matches(&normalized) {
return;
}
}
}
if pattern.ends_with('/') && normalized.starts_with(pattern) {
return;
}
}
missing.push(expected.to_string());
}
fn get_installed_resource_types(lockfile: &LockFile) -> HashSet<ResourceType> {
let mut types = HashSet::new();
if !lockfile.agents.is_empty() {
types.insert(ResourceType::Agent);
}
if !lockfile.snippets.is_empty() {
types.insert(ResourceType::Snippet);
}
if !lockfile.commands.is_empty() {
types.insert(ResourceType::Command);
}
if !lockfile.scripts.is_empty() {
types.insert(ResourceType::Script);
}
types
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use tempfile::TempDir;
#[tokio::test]
async fn test_missing_gitignore_entries() -> Result<()> {
let temp = TempDir::new()?;
let gitignore = temp.path().join(".gitignore");
std::fs::write(&gitignore, "# empty\n")?;
let lockfile = LockFile::default(); let result = check_gitignore_entries(temp.path(), &lockfile).await;
assert!(result.contains(&"agpm.private.toml".to_string()));
assert!(result.contains(&"agpm.private.lock".to_string()));
Ok(())
}
#[tokio::test]
async fn test_gitignore_entries_with_agents() -> Result<()> {
use crate::resolver::lockfile_builder::VariantInputs;
use std::collections::BTreeMap;
let temp = TempDir::new()?;
let gitignore = temp.path().join(".gitignore");
std::fs::write(&gitignore, "# empty\n")?;
let mut lockfile = LockFile::default();
lockfile.agents.push(crate::lockfile::LockedResource {
name: "test".to_string(),
source: None,
url: None,
version: None,
path: "agents/test.md".to_string(),
resolved_commit: None,
checksum: "sha256:test".to_string(),
context_checksum: None,
installed_at: ".claude/agents/agpm/test.md".to_string(),
dependencies: vec![],
resource_type: ResourceType::Agent,
tool: Some("claude-code".to_string()),
manifest_alias: None,
variant_inputs: VariantInputs::default(),
applied_patches: BTreeMap::new(),
install: None,
is_private: false,
approximate_token_count: None,
});
let result = check_gitignore_entries(temp.path(), &lockfile).await;
assert!(result.contains(&".claude/agents/agpm/".to_string()));
Ok(())
}
#[tokio::test]
async fn test_gitignore_entries_satisfied() -> Result<()> {
use crate::resolver::lockfile_builder::VariantInputs;
use std::collections::BTreeMap;
let temp = TempDir::new()?;
let gitignore = temp.path().join(".gitignore");
std::fs::write(&gitignore, ".claude/agents/agpm/\nagpm.private.toml\nagpm.private.lock\n")?;
let mut lockfile = LockFile::default();
lockfile.agents.push(crate::lockfile::LockedResource {
name: "test".to_string(),
source: None,
url: None,
version: None,
path: "agents/test.md".to_string(),
resolved_commit: None,
checksum: "sha256:test".to_string(),
context_checksum: None,
installed_at: ".claude/agents/agpm/test.md".to_string(),
dependencies: vec![],
resource_type: ResourceType::Agent,
tool: Some("claude-code".to_string()),
manifest_alias: None,
variant_inputs: VariantInputs::default(),
applied_patches: BTreeMap::new(),
install: None,
is_private: false,
approximate_token_count: None,
});
let result = check_gitignore_entries(temp.path(), &lockfile).await;
assert!(result.is_empty());
Ok(())
}
}