use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::graph::filter::FileFilter;
use crate::manifest::detect_include_paths_from_root;
pub use crate::manifest::{CargoManifest, PyprojectManifest};
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 auto_include = detect_include_paths_from_root(project_root);
let config = Self {
project: ProjectSection {
name: Some(name.to_string()),
},
index: IndexSection {
include: auto_include,
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)
}
pub fn parse_cargo_manifest(project_root: &Path) -> Result<CargoManifest> {
crate::manifest::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\""));
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()),
"should have at least src/ as default"
);
}
#[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"]
sqlite-backend = []
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("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()));
}
#[test]
fn init_uses_auto_detected_cargo_paths() {
let dir = tempfile::tempdir().unwrap();
let cargo_toml = r#"
[package]
name = "auto-project"
[[test]]
name = "integration"
path = "tests/integration.rs"
[[bench]]
name = "perf"
path = "benches/perf.rs"
"#;
fs::write(dir.path().join("Cargo.toml"), cargo_toml).unwrap();
ProjectConfig::init(dir.path(), "auto-project").unwrap();
let cfg = ProjectConfig::load(dir.path()).unwrap();
assert!(
cfg.index.include.contains(&"src/".to_string()),
"should auto-detect src/"
);
assert!(
cfg.index.include.contains(&"tests/".to_string()),
"should auto-detect tests/ from [[test]]"
);
assert!(
cfg.index.include.contains(&"benches/".to_string()),
"should auto-detect benches/ from [[bench]]"
);
}
}