use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectConfig {
pub project: ProjectSection,
#[serde(default)]
pub index: IndexSection,
#[serde(default)]
pub embeddings: EmbeddingsSection,
#[serde(default)]
pub output: OutputSection,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectSection {
pub name: String,
pub languages: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexSection {
#[serde(default = "default_ignore_patterns")]
pub ignore: Vec<String>,
#[serde(default = "default_true")]
pub include_tests: bool,
#[serde(default = "default_vendor_patterns")]
pub vendor_patterns: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingsSection {
#[serde(default = "default_provider")]
pub provider: String,
#[serde(default = "default_model")]
pub model: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputSection {
#[serde(default = "default_max_refs")]
pub max_refs: usize,
#[serde(default = "default_max_impact_depth")]
pub max_impact_depth: usize,
}
impl ProjectConfig {
pub fn load(scope_dir: &Path) -> Result<Self> {
let config_path = scope_dir.join("config.toml");
let content = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read {}", config_path.display()))?;
let config: ProjectConfig =
toml::from_str(&content).with_context(|| "Failed to parse config.toml")?;
Ok(config)
}
pub fn save(&self, scope_dir: &Path) -> Result<()> {
let config_path = scope_dir.join("config.toml");
let content = toml::to_string_pretty(self).with_context(|| "Failed to serialize config")?;
std::fs::write(&config_path, content)
.with_context(|| format!("Failed to write {}", config_path.display()))?;
Ok(())
}
pub fn default_for(name: &str, languages: Vec<String>) -> Self {
Self {
project: ProjectSection {
name: name.to_string(),
languages,
},
index: IndexSection::default(),
embeddings: EmbeddingsSection::default(),
output: OutputSection::default(),
}
}
}
impl Default for IndexSection {
fn default() -> Self {
Self {
ignore: default_ignore_patterns(),
include_tests: true,
vendor_patterns: default_vendor_patterns(),
}
}
}
impl Default for EmbeddingsSection {
fn default() -> Self {
Self {
provider: default_provider(),
model: default_model(),
}
}
}
impl Default for OutputSection {
fn default() -> Self {
Self {
max_refs: default_max_refs(),
max_impact_depth: default_max_impact_depth(),
}
}
}
fn default_ignore_patterns() -> Vec<String> {
vec![
"node_modules".to_string(),
"dist".to_string(),
"build".to_string(),
".git".to_string(),
]
}
fn default_true() -> bool {
true
}
fn default_provider() -> String {
"local".to_string()
}
fn default_model() -> String {
"nomic-embed-code".to_string()
}
fn default_max_refs() -> usize {
20
}
fn default_max_impact_depth() -> usize {
3
}
fn default_vendor_patterns() -> Vec<String> {
vec![
"node_modules".to_string(),
"vendor".to_string(),
"target".to_string(),
".cargo".to_string(),
"venv".to_string(),
"site-packages".to_string(),
".m2".to_string(),
"third_party".to_string(),
]
}
pub fn vendor_patterns_for_languages(languages: &[String]) -> Vec<String> {
let mut patterns = Vec::new();
for lang in languages {
match lang.as_str() {
"typescript" => {
patterns.extend_from_slice(&[
"node_modules".to_string(),
"dist".to_string(),
"build".to_string(),
]);
}
"rust" => {
patterns.extend_from_slice(&["target".to_string(), ".cargo".to_string()]);
}
"python" => {
patterns.extend_from_slice(&[
"venv".to_string(),
".venv".to_string(),
"site-packages".to_string(),
"__pycache__".to_string(),
]);
}
"go" => {
patterns.push("vendor".to_string());
}
"java" => {
patterns.extend_from_slice(&[
".m2".to_string(),
"build".to_string(),
"out".to_string(),
]);
}
"csharp" => {
patterns.extend_from_slice(&[
"bin".to_string(),
"obj".to_string(),
"packages".to_string(),
]);
}
_ => {}
}
}
let mut seen = std::collections::HashSet::new();
patterns.retain(|p| seen.insert(p.clone()));
patterns
}
pub fn is_vendor_path(file_path: &str, vendor_patterns: &[String]) -> bool {
let path = std::path::Path::new(file_path);
path.components().any(|c| {
if let std::path::Component::Normal(s) = c {
vendor_patterns
.iter()
.any(|p| s.to_str() == Some(p.as_str()))
} else {
false
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_vendor_path_node_modules() {
let patterns = default_vendor_patterns();
assert!(is_vendor_path("node_modules/stripe/payment.js", &patterns));
}
#[test]
fn test_is_vendor_path_first_party() {
let patterns = default_vendor_patterns();
assert!(!is_vendor_path("src/payments/service.ts", &patterns));
}
#[test]
fn test_is_vendor_path_go_vendor() {
let patterns = default_vendor_patterns();
assert!(is_vendor_path(
"vendor/github.com/pkg/errors/errors.go",
&patterns
));
}
#[test]
fn test_is_vendor_path_no_substring_match() {
let patterns = default_vendor_patterns();
assert!(!is_vendor_path("src/my-vendor-lib/util.rs", &patterns));
}
#[test]
fn test_is_vendor_path_nested_vendor() {
let patterns = default_vendor_patterns();
assert!(is_vendor_path(
"packages/api/node_modules/lodash/index.js",
&patterns
));
}
#[test]
fn test_vendor_patterns_for_languages_dedup() {
let langs = vec!["typescript".to_string(), "java".to_string()];
let patterns = vendor_patterns_for_languages(&langs);
let build_count = patterns.iter().filter(|p| p.as_str() == "build").count();
assert_eq!(build_count, 1);
}
#[test]
fn test_vendor_patterns_for_languages_unknown() {
let langs = vec!["brainfuck".to_string()];
let patterns = vendor_patterns_for_languages(&langs);
assert!(patterns.is_empty());
}
}