use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::graph::filter::FileFilter;
const CONFIG_FILENAME: &str = ".magellan.toml";
fn default_debounce() -> u64 {
500
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectSection {
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IndexSection {
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatchSection {
#[serde(default = "default_debounce")]
pub debounce_ms: u64,
#[serde(default = "default_true")]
pub gitignore_aware: bool,
#[serde(default = "default_true")]
pub scan_initial: bool,
}
impl Default for WatchSection {
fn default() -> Self {
Self {
debounce_ms: default_debounce(),
gitignore_aware: true,
scan_initial: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectConfig {
#[serde(default)]
pub project: ProjectSection,
#[serde(default)]
pub index: IndexSection,
#[serde(default)]
pub watch: WatchSection,
}
impl ProjectConfig {
pub fn load(project_root: &Path) -> Result<Self> {
let path = project_root.join(CONFIG_FILENAME);
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
}
pub fn init(project_root: &Path, name: &str) -> Result<()> {
let path = project_root.join(CONFIG_FILENAME);
if path.exists() {
anyhow::bail!(
"{} already exists — remove it first if you want to regenerate",
path.display()
);
}
let config = Self {
project: ProjectSection {
name: Some(name.to_string()),
},
index: IndexSection {
include: vec!["src/".into()],
exclude: Vec::new(),
},
..Self::default()
};
let content =
toml::to_string_pretty(&config).context("Failed to serialize project config")?;
std::fs::write(&path, content)
.with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
pub fn to_file_filter(&self, root: &Path) -> Result<FileFilter> {
let include = normalize_dir_patterns(&self.index.include);
let exclude = normalize_dir_patterns(&self.index.exclude);
FileFilter::new(root, &include, &exclude)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CargoManifest {
pub package_name: Option<String>,
#[serde(default)]
pub features: std::collections::HashMap<String, Vec<String>>,
#[serde(default)]
pub dependencies: Vec<String>,
#[serde(default)]
pub targets: Vec<String>,
}
impl CargoManifest {
pub fn parse(project_root: &Path) -> Result<Self> {
let path = project_root.join("Cargo.toml");
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let doc: toml::Table = toml::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
let mut manifest = Self::default();
if let Some(toml::Value::Table(package)) = doc.get("package") {
if let Some(toml::Value::String(name)) = package.get("name") {
manifest.package_name = Some(name.clone());
}
}
if let Some(toml::Value::Table(features)) = doc.get("features") {
for (k, v) in features.iter().filter(|(k, _)| *k != "default") {
if let toml::Value::Array(arr) = v {
manifest.features.insert(
k.clone(),
arr.iter()
.filter_map(|item| item.as_str().map(String::from))
.collect(),
);
}
}
}
if let Some(toml::Value::Table(deps)) = doc.get("dependencies") {
manifest.dependencies = deps.keys().cloned().collect();
}
for key in ["bin", "test", "bench"] {
if let Some(toml::Value::Array(arr)) = doc.get(key) {
for item in arr {
if let toml::Value::Table(t) = item {
if let Some(toml::Value::String(path)) = t.get("path") {
manifest.targets.push(path.clone());
}
}
}
}
}
Ok(manifest)
}
pub fn store_in_db(&self, conn: &rusqlite::Connection) -> Result<()> {
let metadata_json = serde_json::to_string(self)
.context("Failed to serialize CargoManifest")?;
conn.execute(
"UPDATE magellan_meta SET project_name = ?1, project_metadata = ?2 WHERE id = 1",
rusqlite::params![self.package_name, metadata_json],
)
.context("Failed to update magellan_meta with project metadata")?;
Ok(())
}
}
impl ProjectConfig {
pub fn parse_cargo_manifest(project_root: &Path) -> Result<CargoManifest> {
CargoManifest::parse(project_root)
}
}
fn normalize_dir_patterns(patterns: &[String]) -> Vec<String> {
patterns
.iter()
.map(|p| {
if p.ends_with('/') {
format!("{}**", p)
} else {
p.clone()
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn parse_minimal_toml() {
let cfg: ProjectConfig = toml::from_str("[project]\nname = \"test\"").unwrap();
assert_eq!(cfg.project.name.as_deref(), Some("test"));
assert!(cfg.index.include.is_empty()); assert!(cfg.index.exclude.is_empty());
assert_eq!(cfg.watch.debounce_ms, 500);
}
#[test]
fn parse_full_toml() {
let input = r#"
[project]
name = "magellan"
[index]
include = ["src/", "tests/", "benches/"]
exclude = ["src/generated/**"]
[watch]
debounce_ms = 1000
gitignore_aware = false
scan_initial = false
"#;
let cfg: ProjectConfig = toml::from_str(input).unwrap();
assert_eq!(cfg.project.name.as_deref(), Some("magellan"));
assert_eq!(cfg.index.include, vec!["src/", "tests/", "benches/"]);
assert_eq!(cfg.index.exclude, vec!["src/generated/**"]);
assert_eq!(cfg.watch.debounce_ms, 1000);
assert!(!cfg.watch.gitignore_aware);
assert!(!cfg.watch.scan_initial);
}
#[test]
fn missing_file_returns_default() {
let dir = tempfile::tempdir().unwrap();
let cfg = ProjectConfig::load(dir.path()).unwrap();
assert!(cfg.project.name.is_none());
assert!(cfg.index.include.is_empty()); }
#[test]
fn invalid_toml_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILENAME);
fs::write(&path, "not valid toml {{{").unwrap();
let result = ProjectConfig::load(dir.path());
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Failed to parse"), "got: {err}");
}
#[test]
fn init_writes_config() {
let dir = tempfile::tempdir().unwrap();
ProjectConfig::init(dir.path(), "my-project").unwrap();
let path = dir.path().join(CONFIG_FILENAME);
assert!(path.exists());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("name = \"my-project\""));
assert!(content.contains("include = [\"src/\"]"));
let cfg = ProjectConfig::load(dir.path()).unwrap();
assert_eq!(cfg.project.name.as_deref(), Some("my-project"));
assert!(cfg.index.include.contains(&"src/".to_string()));
}
#[test]
fn init_refuses_overwrite() {
let dir = tempfile::tempdir().unwrap();
ProjectConfig::init(dir.path(), "first").unwrap();
let result = ProjectConfig::init(dir.path(), "second");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
}
#[test]
fn to_file_filter_creates_matcher() {
let cfg = ProjectConfig {
index: IndexSection {
include: vec!["src/".into(), "tests/".into()],
exclude: vec!["src/generated/**".into()],
},
..Default::default()
};
let dir = tempfile::tempdir().unwrap();
let filter = cfg.to_file_filter(dir.path()).unwrap();
assert!(filter
.should_skip(&dir.path().join("target/foo.rs"))
.is_some());
}
#[test]
fn parse_cargo_toml_features_and_dependencies() {
let dir = tempfile::tempdir().unwrap();
let cargo_toml = r#"
[package]
name = "test-crate"
version = "0.1.0"
edition = "2021"
[features]
default = ["sqlite-backend", "geometric-backend"]
sqlite-backend = []
geometric-backend = ["dep:geographdb-core"]
llvm-cfg = ["dep:llvm-sys"]
[dependencies]
anyhow = "1.0"
thiserror = "1.0"
serde = { version = "1.0", features = ["derive"] }
[dev-dependencies]
tempfile = "3.10"
[[test]]
name = "integration"
path = "tests/integration.rs"
"#;
fs::write(dir.path().join("Cargo.toml"), cargo_toml).unwrap();
let manifest = ProjectConfig::parse_cargo_manifest(dir.path()).unwrap();
assert_eq!(manifest.package_name, Some("test-crate".to_string()));
assert!(manifest.features.contains_key("sqlite-backend"));
assert!(manifest.features.contains_key("geometric-backend"));
assert!(manifest.features.contains_key("llvm-cfg"));
assert!(!manifest.features.contains_key("default"));
assert!(manifest.dependencies.contains(&"anyhow".to_string()));
assert!(manifest.dependencies.contains(&"thiserror".to_string()));
assert!(manifest.dependencies.contains(&"serde".to_string()));
assert!(!manifest.dependencies.contains(&"tempfile".to_string()));
assert!(manifest.targets.contains(&"tests/integration.rs".to_string()));
}
}