use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use super::SetupResult;
use crate::paths;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GlobalConfig {
pub workspace: WorkspaceConfig,
pub adapter: AdapterConfig,
pub serve: ServeConfig,
#[serde(default)]
pub adapters: AdaptersConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceConfig {
pub path: String,
}
impl Default for WorkspaceConfig {
fn default() -> Self {
let home = dirs::home_dir().unwrap_or_default();
Self {
path: home
.join("Projects")
.join("Patina")
.to_string_lossy()
.to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdapterConfig {
pub default: String,
}
impl Default for AdapterConfig {
fn default() -> Self {
Self {
default: "claude".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServeConfig {
pub port: u16,
pub auto_start: bool,
}
impl Default for ServeConfig {
fn default() -> Self {
Self {
port: 50051,
auto_start: true,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AdaptersConfig {
#[serde(flatten)]
pub entries: HashMap<String, AdapterEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdapterEntry {
pub command: String,
pub detected: bool,
#[serde(default)]
pub mcp_config: Option<String>,
}
#[derive(Debug)]
pub struct WorkspaceInfo {
pub mother_path: PathBuf,
pub workspace_path: PathBuf,
pub config_exists: bool,
pub adapters_installed: Vec<String>,
}
pub fn is_first_run() -> bool {
!paths::patina_home().exists()
}
pub fn setup() -> Result<SetupResult> {
let mother = paths::patina_home();
let adapters = paths::adapters_dir();
println!("First-time setup...");
fs::create_dir_all(&mother)
.with_context(|| format!("Failed to create {}", mother.display()))?;
println!(" ✓ Created {}", mother.display());
fs::create_dir_all(&adapters)?;
for adapter in &["claude", "gemini", "codex"] {
let adapter_dir = adapters.join(adapter);
fs::create_dir_all(&adapter_dir)?;
}
println!(" ✓ Installing adapter templates...");
crate::adapters::templates::install_all(&adapters)?;
println!(" ✓ Installed adapters: claude, gemini, codex");
let mut detected = Vec::new();
let mut adapters_config = AdaptersConfig::default();
for (name, mcp_config) in [
("claude", Some("~/.claude/settings.json")),
("gemini", None),
("codex", None),
("opencode", None),
] {
if detect_cli(name) {
detected.push(name.to_string());
adapters_config.entries.insert(
name.to_string(),
AdapterEntry {
command: name.to_string(),
detected: true,
mcp_config: mcp_config.map(String::from),
},
);
}
}
println!("\nDetecting LLM adapters...");
for name in &["claude", "gemini", "codex", "opencode"] {
if detected.contains(&name.to_string()) {
println!(" ✓ {} (found)", name);
} else {
println!(" ✗ {} (not found)", name);
}
}
let default_adapter = detected.first().cloned();
let workspace_path = dirs::home_dir()
.unwrap_or_default()
.join("Projects")
.join("Patina");
if !workspace_path.exists() {
fs::create_dir_all(&workspace_path)?;
println!(" ✓ Created {} workspace", workspace_path.display());
}
let config = GlobalConfig {
workspace: WorkspaceConfig {
path: workspace_path.to_string_lossy().to_string(),
},
adapter: AdapterConfig {
default: default_adapter
.clone()
.unwrap_or_else(|| "claude".to_string()),
},
serve: ServeConfig::default(),
adapters: adapters_config,
};
save_config(&config)?;
if let Some(ref adapter) = default_adapter {
println!("\nSetting default: {}", adapter);
}
Ok(SetupResult {
mother_path: mother,
workspace_path,
adapters_installed: vec![
"claude".to_string(),
"gemini".to_string(),
"codex".to_string(),
"opencode".to_string(),
],
adapters_detected: detected,
default_adapter,
})
}
pub fn ensure_workspace() -> Result<()> {
let mother = paths::patina_home();
if !mother.exists() {
setup()?;
return Ok(());
}
let adapters = paths::adapters_dir();
if !adapters.exists() {
fs::create_dir_all(&adapters)?;
for adapter in &["claude", "gemini", "codex"] {
fs::create_dir_all(adapters.join(adapter))?;
}
crate::adapters::templates::install_all(&adapters)?;
} else {
let claude_templates = adapters.join("claude").join("templates");
if !claude_templates.exists() {
crate::adapters::templates::install_all(&adapters)?;
}
}
if !paths::config_path().exists() {
save_config(&GlobalConfig::default())?;
}
Ok(())
}
pub fn load_config() -> Result<GlobalConfig> {
let path = paths::config_path();
if !path.exists() {
return Ok(GlobalConfig::default());
}
let contents = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config: {}", path.display()))?;
toml::from_str(&contents).with_context(|| format!("Failed to parse config: {}", path.display()))
}
pub fn save_config(config: &GlobalConfig) -> Result<()> {
let path = paths::config_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 workspace_info() -> Result<WorkspaceInfo> {
let mother = paths::patina_home();
let config = load_config()?;
let workspace_path = PathBuf::from(shellexpand::tilde(&config.workspace.path).as_ref());
let adapters = paths::adapters_dir();
let mut installed = Vec::new();
for name in &["claude", "gemini", "codex"] {
if adapters.join(name).exists() {
installed.push(name.to_string());
}
}
Ok(WorkspaceInfo {
mother_path: mother.clone(),
workspace_path,
config_exists: paths::config_path().exists(),
adapters_installed: installed,
})
}
fn detect_cli(name: &str) -> bool {
Command::new("which")
.arg(name)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = GlobalConfig::default();
assert_eq!(config.adapter.default, "claude");
assert_eq!(config.serve.port, 50051);
assert!(config.serve.auto_start);
}
#[test]
fn test_config_serialization() {
let config = GlobalConfig::default();
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(toml_str.contains("[workspace]"));
assert!(toml_str.contains("[adapter]"));
assert!(toml_str.contains("[serve]"));
}
}