use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProjectConfig {
#[serde(default)]
pub project: ProjectSection,
#[serde(default, skip_serializing)]
pub dev: DevSection,
#[serde(default)]
pub adapters: AdaptersSection,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream: Option<UpstreamSection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ci: Option<CiSection>,
#[serde(default)]
pub embeddings: EmbeddingsSection,
#[serde(default)]
pub search: SearchSection,
#[serde(default)]
pub retrieval: RetrievalSection,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<EnvironmentSection>,
}
impl ProjectConfig {
pub fn with_name(name: impl Into<String>) -> Self {
Self {
project: ProjectSection {
name: name.into(),
..Default::default()
},
..Default::default()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectSection {
#[serde(default = "default_name")]
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<String>,
}
fn default_name() -> String {
"unnamed".to_string()
}
impl Default for ProjectSection {
fn default() -> Self {
Self {
name: default_name(),
created: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DevSection {
#[serde(default = "default_dev_type", rename = "type")]
pub dev_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
fn default_dev_type() -> String {
"docker".to_string()
}
impl Default for DevSection {
fn default() -> Self {
Self {
dev_type: default_dev_type(),
version: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdaptersSection {
#[serde(default = "default_allowed")]
pub allowed: Vec<String>,
#[serde(default = "default_adapter")]
pub default: String,
}
fn default_allowed() -> Vec<String> {
vec!["claude".to_string()]
}
fn default_adapter() -> String {
"claude".to_string()
}
impl Default for AdaptersSection {
fn default() -> Self {
Self {
allowed: default_allowed(),
default: default_adapter(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpstreamSection {
pub repo: String,
#[serde(default = "default_upstream_branch")]
pub branch: String,
#[serde(default = "default_upstream_remote")]
pub remote: String,
#[serde(default)]
pub include_patina: bool,
#[serde(default)]
pub include_adapters: bool,
}
fn default_upstream_branch() -> String {
"main".to_string()
}
fn default_upstream_remote() -> String {
"upstream".to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CiSection {
#[serde(default)]
pub checks: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch_prefix: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingsSection {
#[serde(default = "default_model")]
pub model: String,
}
fn default_model() -> String {
"e5-base-v2".to_string()
}
impl Default for EmbeddingsSection {
fn default() -> Self {
Self {
model: default_model(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvironmentSection {
pub os: String,
pub arch: String,
#[serde(default)]
pub detected_tools: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetrievalSection {
#[serde(default = "default_rrf_k")]
pub rrf_k: usize,
#[serde(default = "default_fetch_multiplier")]
pub fetch_multiplier: usize,
}
fn default_rrf_k() -> usize {
60
}
fn default_fetch_multiplier() -> usize {
2
}
impl Default for RetrievalSection {
fn default() -> Self {
Self {
rrf_k: default_rrf_k(),
fetch_multiplier: default_fetch_multiplier(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchSection {
#[serde(default = "default_scry_threshold")]
pub scry_threshold: f32,
#[serde(default = "default_semantic_threshold")]
pub semantic_threshold: f32,
#[serde(default = "default_belief_threshold")]
pub belief_threshold: f32,
}
fn default_scry_threshold() -> f32 {
0.0
}
fn default_semantic_threshold() -> f32 {
0.35
}
fn default_belief_threshold() -> f32 {
0.50
}
impl Default for SearchSection {
fn default() -> Self {
Self {
scry_threshold: default_scry_threshold(),
semantic_threshold: default_semantic_threshold(),
belief_threshold: default_belief_threshold(),
}
}
}
pub fn patina_dir(project_path: &Path) -> PathBuf {
project_path.join(".patina")
}
pub fn config_path(project_path: &Path) -> PathBuf {
patina_dir(project_path).join("config.toml")
}
pub fn legacy_config_path(project_path: &Path) -> PathBuf {
patina_dir(project_path).join("config.json")
}
pub fn local_dir(project_path: &Path) -> PathBuf {
patina_dir(project_path).join("local")
}
pub fn backups_dir(project_path: &Path) -> PathBuf {
local_dir(project_path).join("backups")
}
pub fn uid_path(project_path: &Path) -> PathBuf {
patina_dir(project_path).join("uid")
}
pub fn create_uid_if_missing(project_path: &Path) -> Result<String> {
let uid_file = uid_path(project_path);
if uid_file.exists() {
return Ok(fs::read_to_string(&uid_file)?.trim().to_string());
}
let uid = format!("{:08x}", fastrand::u32(..));
if let Some(parent) = uid_file.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&uid_file, &uid)
.with_context(|| format!("Failed to create UID file: {}", uid_file.display()))?;
Ok(uid)
}
pub fn get_uid(project_path: &Path) -> Option<String> {
let uid_file = uid_path(project_path);
if uid_file.exists() {
fs::read_to_string(&uid_file)
.ok()
.map(|s| s.trim().to_string())
} else {
None
}
}
pub fn is_patina_project(path: &Path) -> bool {
patina_dir(path).exists()
}
pub fn has_legacy_config(project_path: &Path) -> bool {
legacy_config_path(project_path).exists()
}
pub fn migrate_legacy_config(project_path: &Path) -> Result<bool> {
let json_path = legacy_config_path(project_path);
if !json_path.exists() {
return Ok(false);
}
let mut config = load(project_path)?;
let json_content = fs::read_to_string(&json_path)
.with_context(|| format!("Failed to read legacy config: {}", json_path.display()))?;
let json: serde_json::Value = serde_json::from_str(&json_content)
.with_context(|| "Failed to parse legacy config.json")?;
if let Some(name) = json.get("name").and_then(|v| v.as_str()) {
config.project.name = name.to_string();
}
if let Some(created) = json.get("created").and_then(|v| v.as_str()) {
config.project.created = Some(created.to_string());
}
if let Some(llm) = json.get("llm").and_then(|v| v.as_str()) {
config.adapters.default = llm.to_string();
if !config.adapters.allowed.contains(&llm.to_string()) {
config.adapters.allowed.push(llm.to_string());
}
}
if let Some(env) = json.get("environment_snapshot") {
let os = env.get("os").and_then(|v| v.as_str()).unwrap_or("unknown");
let arch = env
.get("arch")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let tools = env
.get("detected_tools")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default();
config.environment = Some(EnvironmentSection {
os: os.to_string(),
arch: arch.to_string(),
detected_tools: tools,
});
}
save(project_path, &config)?;
backup_file(project_path, &json_path)?;
fs::remove_file(&json_path)?;
Ok(true)
}
pub fn load(project_path: &Path) -> Result<ProjectConfig> {
let path = config_path(project_path);
if !path.exists() {
return Ok(ProjectConfig::default());
}
let contents = fs::read_to_string(&path)
.with_context(|| format!("Failed to read project config: {}", path.display()))?;
toml::from_str(&contents)
.with_context(|| format!("Failed to parse project config: {}", path.display()))
}
pub fn load_with_migration(project_path: &Path) -> Result<ProjectConfig> {
if has_legacy_config(project_path) && migrate_legacy_config(project_path)? {
eprintln!(" ✓ Migrated config.json → config.toml");
}
load(project_path)
}
pub fn save(project_path: &Path, config: &ProjectConfig) -> Result<()> {
let path = config_path(project_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(config)?;
fs::write(&path, contents)?;
Ok(())
}
pub fn backup_file(project_path: &Path, file_path: &Path) -> Result<Option<PathBuf>> {
if !file_path.exists() {
return Ok(None);
}
let backups = backups_dir(project_path);
fs::create_dir_all(&backups)?;
let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
let filename = file_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
let backup_path = backups.join(format!("{}-{}", filename, timestamp));
fs::copy(file_path, &backup_path).with_context(|| {
format!(
"Failed to backup {} to {}",
file_path.display(),
backup_path.display()
)
})?;
Ok(Some(backup_path))
}
pub fn is_versioning_enabled(project_path: &Path) -> bool {
let config = match load(project_path) {
Ok(c) => c,
Err(_) => return true, };
match &config.upstream {
None => true, Some(up) => up.remote == "origin", }
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_default_config() {
let config = ProjectConfig::default();
assert_eq!(config.project.name, "unnamed");
assert_eq!(config.adapters.default, "claude");
assert!(config.adapters.allowed.contains(&"claude".to_string()));
assert_eq!(config.embeddings.model, "e5-base-v2");
assert!(config.upstream.is_none()); assert!(config.ci.is_none()); assert_eq!(config.retrieval.rrf_k, 60);
assert_eq!(config.retrieval.fetch_multiplier, 2);
}
#[test]
fn test_retrieval_config() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join(".patina/config.toml");
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(
&config_path,
"[retrieval]\nrrf_k = 30\nfetch_multiplier = 3\n",
)
.unwrap();
let config = load(tmp.path()).unwrap();
assert_eq!(config.retrieval.rrf_k, 30);
assert_eq!(config.retrieval.fetch_multiplier, 3);
}
#[test]
fn test_config_with_name() {
let config = ProjectConfig::with_name("my-project");
assert_eq!(config.project.name, "my-project");
}
#[test]
fn test_config_serialization() {
let config = ProjectConfig::default();
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(toml_str.contains("[project]"));
assert!(!toml_str.contains("[dev]"));
assert!(toml_str.contains("[adapters]"));
assert!(toml_str.contains("[embeddings]"));
}
#[test]
fn test_save_and_load() {
let tmp = TempDir::new().unwrap();
let project_path = tmp.path();
let mut config = ProjectConfig::with_name("test-project");
config.adapters.allowed = vec!["claude".to_string(), "gemini".to_string()];
save(project_path, &config).unwrap();
let loaded = load(project_path).unwrap();
assert_eq!(loaded.project.name, "test-project");
assert_eq!(loaded.adapters.allowed.len(), 2);
}
#[test]
fn test_load_missing_returns_default() {
let tmp = TempDir::new().unwrap();
let config = load(tmp.path()).unwrap();
assert_eq!(config.project.name, "unnamed");
}
#[test]
fn test_load_partial_config() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join(".patina/config.toml");
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, "[embeddings]\nmodel = \"all-minilm-l6-v2\"\n").unwrap();
let config = load(tmp.path()).unwrap();
assert_eq!(config.embeddings.model, "all-minilm-l6-v2");
assert_eq!(config.project.name, "unnamed");
assert_eq!(config.adapters.default, "claude");
}
#[test]
fn test_is_patina_project() {
let tmp = TempDir::new().unwrap();
assert!(!is_patina_project(tmp.path()));
fs::create_dir_all(patina_dir(tmp.path())).unwrap();
assert!(is_patina_project(tmp.path()));
}
#[test]
fn test_migrate_legacy_config() {
let tmp = TempDir::new().unwrap();
let patina = patina_dir(tmp.path());
fs::create_dir_all(&patina).unwrap();
let json = r#"{
"name": "test-project",
"llm": "gemini",
"dev": "native",
"created": "2025-01-01T00:00:00Z",
"environment_snapshot": {
"os": "linux",
"arch": "x86_64",
"detected_tools": ["cargo", "git"]
}
}"#;
fs::write(patina.join("config.json"), json).unwrap();
fs::write(
patina.join("config.toml"),
"[embeddings]\nmodel = \"bge-base\"\n",
)
.unwrap();
let migrated = migrate_legacy_config(tmp.path()).unwrap();
assert!(migrated);
let config = load(tmp.path()).unwrap();
assert_eq!(config.project.name, "test-project");
assert_eq!(config.adapters.default, "gemini");
assert!(config.adapters.allowed.contains(&"gemini".to_string()));
assert_eq!(config.embeddings.model, "bge-base");
assert!(!legacy_config_path(tmp.path()).exists());
assert!(backups_dir(tmp.path()).exists());
}
#[test]
fn test_upstream_config() {
let tmp = TempDir::new().unwrap();
let project_path = tmp.path();
let mut config = ProjectConfig::with_name("death-mountain");
config.upstream = Some(UpstreamSection {
repo: "Provable-Games/death-mountain".to_string(),
branch: "main".to_string(),
remote: "upstream".to_string(),
include_patina: false,
include_adapters: false,
});
config.ci = Some(CiSection {
checks: vec!["sozo build".to_string(), "scarb test".to_string()],
branch_prefix: Some("feat/".to_string()),
});
save(project_path, &config).unwrap();
let loaded = load(project_path).unwrap();
let upstream = loaded.upstream.unwrap();
assert_eq!(upstream.repo, "Provable-Games/death-mountain");
assert_eq!(upstream.branch, "main");
assert_eq!(upstream.remote, "upstream");
assert!(!upstream.include_patina);
assert!(!upstream.include_adapters);
let ci = loaded.ci.unwrap();
assert_eq!(ci.checks.len(), 2);
assert_eq!(ci.branch_prefix, Some("feat/".to_string()));
}
#[test]
fn test_upstream_config_owned_repo() {
let tmp = TempDir::new().unwrap();
let project_path = tmp.path();
let mut config = ProjectConfig::with_name("patina");
config.upstream = Some(UpstreamSection {
repo: "nicabar/patina".to_string(),
branch: "main".to_string(),
remote: "origin".to_string(), include_patina: true, include_adapters: true, });
save(project_path, &config).unwrap();
let loaded = load(project_path).unwrap();
let upstream = loaded.upstream.unwrap();
assert_eq!(upstream.remote, "origin");
assert!(upstream.include_patina);
assert!(upstream.include_adapters);
}
}