use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub const DEFAULT_ENGINE_PORT: u16 = 3099;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ConfirmationsConfig {
pub batch_fix: bool,
pub undo_multiple: bool,
pub overwrite_docs: bool,
}
impl Default for ConfirmationsConfig {
fn default() -> Self {
Self {
batch_fix: true,
undo_multiple: true,
overwrite_docs: false,
}
}
}
const DEFAULT_TICK_RATE_MS: u64 = 250;
const DEFAULT_PROJECT_API_URL: &str = "";
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
struct GlobalConfig {
engine_port: u16,
engine_host: String,
tick_rate_ms: u64,
theme: String,
navigation: String,
sidebar_visible: bool,
animations_enabled: bool,
scroll_acceleration: f32,
llm_provider: Option<String>,
llm_model: Option<String>,
project_api_url: String,
offline_mode: bool,
confirmations: ConfirmationsConfig,
}
impl Default for GlobalConfig {
fn default() -> Self {
Self {
engine_port: DEFAULT_ENGINE_PORT,
engine_host: "127.0.0.1".to_string(),
tick_rate_ms: DEFAULT_TICK_RATE_MS,
theme: "dark".to_string(),
navigation: "standard".to_string(),
sidebar_visible: true,
animations_enabled: true,
scroll_acceleration: 1.5,
llm_provider: None,
llm_model: None,
project_api_url: DEFAULT_PROJECT_API_URL.to_string(),
offline_mode: false,
confirmations: ConfirmationsConfig::default(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
struct ProjectConfig {
onboarding_completed: bool,
onboarding_last_step: Option<usize>,
project_type: String,
jurisdiction: String,
requirements: Vec<String>,
role: String,
industry: String,
scan_scope: Vec<String>,
watch_on_start: bool,
llm_provider: Option<String>,
llm_model: Option<String>,
project_api_url: Option<String>,
offline_mode: Option<bool>,
}
impl Default for ProjectConfig {
fn default() -> Self {
Self {
onboarding_completed: false,
onboarding_last_step: None,
project_type: "existing".to_string(),
jurisdiction: "eu".to_string(),
requirements: vec!["eu-ai-act".to_string()],
role: "deployer".to_string(),
industry: "general".to_string(),
scan_scope: vec![
"deps".to_string(),
"env".to_string(),
"source".to_string(),
],
watch_on_start: false,
llm_provider: None,
llm_model: None,
project_api_url: None,
offline_mode: None,
}
}
}
pub fn default_project_toml() -> impl serde::Serialize {
ProjectConfig::default()
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct TuiConfig {
pub engine_port: u16,
pub engine_host: String,
pub tick_rate_ms: u64,
pub project_path: Option<String>,
pub theme: String,
pub sidebar_visible: bool,
pub watch_on_start: bool,
pub onboarding_completed: bool,
pub animations_enabled: bool,
pub scroll_acceleration: f32,
pub navigation: String,
pub project_type: String,
pub jurisdiction: String,
pub requirements: Vec<String>,
pub role: String,
pub industry: String,
pub scan_scope: Vec<String>,
pub onboarding_last_step: Option<usize>,
#[serde(skip)]
pub engine_url_override: Option<String>,
pub project_api_url: String,
#[serde(skip)]
pub api_key: Option<String>,
#[serde(default)]
pub offline_mode: bool,
#[serde(default)]
pub llm_provider: Option<String>,
#[serde(default)]
pub llm_model: Option<String>,
#[serde(default)]
pub confirmations: ConfirmationsConfig,
}
impl Default for TuiConfig {
fn default() -> Self {
Self {
engine_port: DEFAULT_ENGINE_PORT,
engine_host: "127.0.0.1".to_string(),
tick_rate_ms: DEFAULT_TICK_RATE_MS,
project_path: None,
theme: "dark".to_string(),
sidebar_visible: true,
watch_on_start: false,
onboarding_completed: false,
animations_enabled: true,
scroll_acceleration: 1.5,
navigation: "standard".to_string(),
project_type: "existing".to_string(),
jurisdiction: "eu".to_string(),
requirements: vec!["eu-ai-act".to_string()],
role: "deployer".to_string(),
industry: "general".to_string(),
scan_scope: vec![
"deps".to_string(),
"env".to_string(),
"source".to_string(),
],
onboarding_last_step: None,
engine_url_override: None,
llm_provider: None,
llm_model: None,
project_api_url: DEFAULT_PROJECT_API_URL.to_string(),
api_key: None,
offline_mode: false,
confirmations: ConfirmationsConfig::default(),
}
}
}
impl TuiConfig {
pub fn engine_url(&self) -> String {
if let Some(ref url) = self.engine_url_override {
url.clone()
} else {
format!("http://{}:{}", self.engine_host, self.engine_port)
}
}
}
fn global_config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("complior")
.join("settings.toml")
}
fn project_config_path() -> PathBuf {
find_project_root().join(".complior").join("project.toml")
}
const PROJECT_MARKERS: &[&str] = &[
".complior", ".git", "Cargo.toml", "package.json", "go.mod", "pyproject.toml", "pom.xml", "build.gradle", ".project", ];
pub fn find_project_root() -> PathBuf {
let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let home = dirs::home_dir();
let mut dir = start.clone();
for _ in 0..10 {
if dir.join(".complior").is_dir() {
return dir;
}
for marker in &PROJECT_MARKERS[1..] {
if dir.join(marker).exists() {
return dir;
}
}
if home.as_ref().is_some_and(|h| &dir == h) {
break;
}
if !dir.pop() {
break;
}
}
start
}
fn legacy_config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("complior")
.join("tui.toml")
}
fn load_global_config() -> GlobalConfig {
let path = global_config_path();
match std::fs::read_to_string(&path) {
Ok(content) => toml::from_str(&content).unwrap_or_default(),
Err(_) => GlobalConfig::default(),
}
}
fn load_project_config() -> ProjectConfig {
let path = project_config_path();
match std::fs::read_to_string(&path) {
Ok(content) => toml::from_str(&content).unwrap_or_default(),
Err(_) => ProjectConfig::default(),
}
}
fn merge_config(global: GlobalConfig, project: ProjectConfig) -> TuiConfig {
TuiConfig {
engine_port: global.engine_port,
engine_host: global.engine_host,
tick_rate_ms: global.tick_rate_ms,
project_path: None,
theme: global.theme,
sidebar_visible: global.sidebar_visible,
animations_enabled: global.animations_enabled,
scroll_acceleration: global.scroll_acceleration,
navigation: global.navigation,
project_api_url: project.project_api_url.unwrap_or(global.project_api_url),
offline_mode: project.offline_mode.unwrap_or(global.offline_mode),
confirmations: global.confirmations,
onboarding_completed: project.onboarding_completed,
onboarding_last_step: project.onboarding_last_step,
project_type: project.project_type,
jurisdiction: project.jurisdiction,
requirements: project.requirements,
role: project.role,
industry: project.industry,
scan_scope: project.scan_scope,
watch_on_start: project.watch_on_start,
llm_provider: project.llm_provider.or(global.llm_provider),
llm_model: project.llm_model.or(global.llm_model),
engine_url_override: None,
api_key: None,
}
}
pub fn load_config() -> TuiConfig {
migrate_legacy_config();
let global = load_global_config();
let project = load_project_config();
let mut config = merge_config(global, project);
if let Ok(url) = std::env::var("PROJECT_API_URL")
&& !url.is_empty() {
config.project_api_url = url;
}
if std::env::var("OFFLINE_MODE").as_deref() == Ok("1") {
config.offline_mode = true;
}
config.api_key = load_api_key();
config
}
async fn save_global_config(config: &GlobalConfig) {
let path = global_config_path();
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
if let Ok(content) = toml::to_string_pretty(config) {
let _ = tokio::fs::write(&path, content).await;
}
}
async fn save_project_config(config: &ProjectConfig) {
let path = project_config_path();
if let Some(parent) = path.parent()
&& let Err(e) = tokio::fs::create_dir_all(parent).await {
tracing::warn!("cannot create {}: {e}", parent.display());
return;
}
match toml::to_string_pretty(config) {
Ok(content) => {
if let Err(e) = tokio::fs::write(&path, content).await {
tracing::warn!("cannot write {}: {e}", path.display());
}
}
Err(e) => tracing::warn!("cannot serialize project config: {e}"),
}
}
pub async fn save_project_api_url(url: &str) {
let mut global = load_global_config();
global.project_api_url = url.to_string();
save_global_config(&global).await;
}
pub async fn save_theme(name: &str) {
let mut global = load_global_config();
global.theme = name.to_string();
save_global_config(&global).await;
}
pub async fn mark_onboarding_complete() {
let mut project = load_project_config();
project.onboarding_completed = true;
project.onboarding_last_step = None;
save_project_config(&project).await;
}
pub async fn save_onboarding_partial(last_step: usize) {
let mut project = load_project_config();
project.onboarding_last_step = Some(last_step);
save_project_config(&project).await;
}
#[cfg(feature = "tui")]
pub async fn save_onboarding_results(
wizard: &crate::views::onboarding::OnboardingWizard,
) {
let mut global = load_global_config();
global.theme = wizard.selected_config_value("welcome_theme");
let provider = wizard.selected_config_value("ai_provider");
match provider.as_str() {
"offline" => {
global.offline_mode = true;
}
"guard_api" => {
global.llm_provider = Some("guard_api".to_string());
}
_ => {
let api_key = wizard.step_text_value("ai_provider");
global.llm_provider = Some(provider.clone());
if !api_key.is_empty() {
save_llm_api_key(&provider, &api_key);
}
}
}
save_global_config(&global).await;
let mut project = load_project_config();
project.project_type = wizard.selected_config_value("project_type");
project.role = wizard.selected_config_value("role");
project.industry = wizard.selected_config_value("industry");
let req_str = wizard.selected_config_value("requirements");
project.requirements = req_str
.split(',')
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
project.onboarding_completed = true;
project.onboarding_last_step = None;
save_project_config(&project).await;
}
pub async fn save_llm_config(
provider: Option<&str>,
model: Option<&str>,
api_key: Option<&str>,
) {
let mut global = load_global_config();
global.llm_provider = provider.map(String::from);
global.llm_model = model.map(String::from);
save_global_config(&global).await;
if let Some(key) = api_key
&& !key.is_empty() {
let provider_name = provider.unwrap_or("LLM");
save_llm_api_key(provider_name, key);
}
}
fn migrate_legacy_config() {
let old_path = legacy_config_path();
let new_path = global_config_path();
if !old_path.exists() || new_path.exists() {
return;
}
let Ok(content) = std::fs::read_to_string(&old_path) else {
return;
};
let Ok(legacy): Result<TuiConfig, _> = toml::from_str(&content) else {
return;
};
let global = GlobalConfig {
engine_port: legacy.engine_port,
engine_host: legacy.engine_host,
tick_rate_ms: legacy.tick_rate_ms,
theme: legacy.theme,
navigation: legacy.navigation,
sidebar_visible: legacy.sidebar_visible,
animations_enabled: legacy.animations_enabled,
scroll_acceleration: legacy.scroll_acceleration,
llm_provider: legacy.llm_provider.clone(),
llm_model: legacy.llm_model.clone(),
project_api_url: legacy.project_api_url,
offline_mode: legacy.offline_mode,
confirmations: legacy.confirmations,
};
let project = ProjectConfig {
onboarding_completed: legacy.onboarding_completed,
onboarding_last_step: legacy.onboarding_last_step,
project_type: legacy.project_type,
jurisdiction: legacy.jurisdiction,
requirements: legacy.requirements,
role: legacy.role,
industry: legacy.industry,
scan_scope: legacy.scan_scope,
watch_on_start: legacy.watch_on_start,
llm_provider: None, llm_model: None,
project_api_url: None,
offline_mode: None,
};
if let Some(parent) = new_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(toml_str) = toml::to_string_pretty(&global) {
let _ = std::fs::write(&new_path, toml_str);
}
let proj_path = project_config_path();
if let Some(parent) = proj_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(toml_str) = toml::to_string_pretty(&project) {
let _ = std::fs::write(&proj_path, toml_str);
}
let bak_path = old_path.with_extension("toml.bak");
let _ = std::fs::rename(&old_path, &bak_path);
}
pub fn load_api_key() -> Option<String> {
let path = dirs::config_dir()?.join("complior").join("credentials");
let content = std::fs::read_to_string(path).ok()?;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.is_empty() {
continue;
}
if let Some((key, value)) = trimmed.split_once('=')
&& key.trim() == "COMPLIOR_API_KEY" {
let v = value.trim().to_string();
if !v.is_empty() {
return Some(v);
}
}
}
None
}
pub fn validate_api_key(provider: &str, key: &str) -> Result<(), String> {
if key.is_empty() {
return Err("Key cannot be empty.".to_string());
}
match provider {
"openai" => {
if !key.starts_with("sk-") {
return Err("OpenAI keys start with \"sk-\".".to_string());
}
if key.len() < 20 {
return Err("Key too short for OpenAI.".to_string());
}
}
"anthropic" => {
if !key.starts_with("sk-ant-") {
return Err("Anthropic keys start with \"sk-ant-\".".to_string());
}
if key.len() < 20 {
return Err("Key too short for Anthropic.".to_string());
}
}
"openrouter" => {
if !key.starts_with("sk-or-") {
return Err("OpenRouter keys start with \"sk-or-\".".to_string());
}
if key.len() < 20 {
return Err("Key too short for OpenRouter.".to_string());
}
}
_ => {
if key.len() < 10 {
return Err("Key too short.".to_string());
}
}
}
Ok(())
}
fn provider_env_key(provider: &str) -> Option<&'static str> {
match provider {
"anthropic" => Some("ANTHROPIC_API_KEY"),
"openai" => Some("OPENAI_API_KEY"),
"openrouter" => Some("OPENROUTER_API_KEY"),
_ => None,
}
}
pub fn save_llm_api_key(provider: &str, key: &str) {
let Some(env_key) = provider_env_key(provider) else {
return;
};
let Some(path) = credentials_path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let mut lines: Vec<String> = existing
.lines()
.filter(|line| {
let trimmed = line.trim();
if let Some((k, _)) = trimmed.split_once('=') {
k.trim() != env_key
} else {
true
}
})
.map(String::from)
.collect();
lines.push(format!("{env_key}={key}"));
let _ = std::fs::write(&path, lines.join("\n") + "\n");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
let _ = std::fs::set_permissions(&path, perms);
}
}
pub fn load_llm_api_key(provider: &str) -> Option<String> {
let env_key = provider_env_key(provider)?;
if let Ok(val) = std::env::var(env_key)
&& !val.is_empty() {
return Some(val);
}
let path = credentials_path()?;
let content = std::fs::read_to_string(path).ok()?;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.is_empty() {
continue;
}
if let Some((key, value)) = trimmed.split_once('=')
&& key.trim() == env_key {
let v = value.trim().to_string();
if !v.is_empty() {
return Some(v);
}
}
}
None
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct StoredTokens {
pub access_token: String,
pub refresh_token: String,
pub expires_at: u64,
pub user_email: Option<String>,
pub org_name: Option<String>,
}
fn credentials_path() -> Option<std::path::PathBuf> {
dirs::config_dir().map(|d| d.join("complior").join("credentials"))
}
pub fn save_tokens(
access_token: &str,
refresh_token: &str,
expires_at: u64,
user_email: Option<&str>,
org_name: Option<&str>,
) -> Result<(), String> {
let path = credentials_path().ok_or("Cannot determine config directory")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("Cannot create config dir: {e}"))?;
}
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let mut lines: Vec<String> = Vec::new();
let token_keys = [
"COMPLIOR_ACCESS_TOKEN", "COMPLIOR_REFRESH_TOKEN",
"COMPLIOR_TOKEN_EXPIRES_AT", "COMPLIOR_USER_EMAIL", "COMPLIOR_ORG_NAME",
];
for line in existing.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
lines.push(line.to_string());
continue;
}
if let Some((key, _)) = trimmed.split_once('=') {
if !token_keys.contains(&key.trim()) {
lines.push(line.to_string());
}
} else {
lines.push(line.to_string());
}
}
lines.push(format!("COMPLIOR_ACCESS_TOKEN={access_token}"));
lines.push(format!("COMPLIOR_REFRESH_TOKEN={refresh_token}"));
lines.push(format!("COMPLIOR_TOKEN_EXPIRES_AT={expires_at}"));
if let Some(email) = user_email {
lines.push(format!("COMPLIOR_USER_EMAIL={email}"));
}
if let Some(org) = org_name {
lines.push(format!("COMPLIOR_ORG_NAME={org}"));
}
std::fs::write(&path, lines.join("\n") + "\n")
.map_err(|e| format!("Cannot write credentials: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&path, perms)
.map_err(|e| format!("Cannot set credentials permissions: {e}"))?;
}
Ok(())
}
pub fn load_tokens() -> Option<StoredTokens> {
let path = credentials_path()?;
let content = std::fs::read_to_string(path).ok()?;
let mut access_token = None;
let mut refresh_token = None;
let mut expires_at = None;
let mut user_email = None;
let mut org_name = None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.is_empty() {
continue;
}
if let Some((key, value)) = trimmed.split_once('=') {
let v = value.trim().to_string();
match key.trim() {
"COMPLIOR_ACCESS_TOKEN" if !v.is_empty() => access_token = Some(v),
"COMPLIOR_REFRESH_TOKEN" if !v.is_empty() => refresh_token = Some(v),
"COMPLIOR_TOKEN_EXPIRES_AT" => expires_at = v.parse().ok(),
"COMPLIOR_USER_EMAIL" if !v.is_empty() => user_email = Some(v),
"COMPLIOR_ORG_NAME" if !v.is_empty() => org_name = Some(v),
_ => {}
}
}
}
Some(StoredTokens {
access_token: access_token?,
refresh_token: refresh_token?,
expires_at: expires_at?,
user_email,
org_name,
})
}
pub fn clear_tokens() -> Result<(), String> {
let path = credentials_path().ok_or("Cannot determine config directory")?;
if !path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&path).unwrap_or_default();
let token_keys = [
"COMPLIOR_ACCESS_TOKEN", "COMPLIOR_REFRESH_TOKEN",
"COMPLIOR_TOKEN_EXPIRES_AT", "COMPLIOR_USER_EMAIL", "COMPLIOR_ORG_NAME",
];
let lines: Vec<&str> = content.lines().filter(|line| {
let trimmed = line.trim();
if let Some((key, _)) = trimmed.split_once('=') {
!token_keys.contains(&key.trim())
} else {
true
}
}).collect();
std::fs::write(&path, lines.join("\n") + "\n")
.map_err(|e| format!("Cannot write credentials: {e}"))
}
pub fn is_authenticated() -> bool {
if let Some(tokens) = load_tokens() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
tokens.expires_at > now
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = TuiConfig::default();
assert_eq!(config.engine_port, 3099);
assert_eq!(config.engine_host, "127.0.0.1");
assert_eq!(config.engine_url(), "http://127.0.0.1:3099");
assert_eq!(config.theme, "dark");
assert!(config.sidebar_visible);
assert!(!config.onboarding_completed);
assert_eq!(config.navigation, "standard");
assert_eq!(config.project_type, "existing");
assert_eq!(config.jurisdiction, "eu");
assert_eq!(config.role, "deployer");
assert_eq!(config.industry, "general");
assert_eq!(config.scan_scope, vec!["deps", "env", "source"]);
assert!(config.onboarding_last_step.is_none());
}
#[test]
fn test_config_deserialization() {
let toml_str = r#"
engine_port = 4000
engine_host = "localhost"
theme = "light"
sidebar_visible = false
onboarding_completed = true
"#;
let config: TuiConfig = toml::from_str(toml_str).expect("valid toml");
assert_eq!(config.engine_port, 4000);
assert_eq!(config.theme, "light");
assert!(!config.sidebar_visible);
assert!(config.onboarding_completed);
}
#[test]
fn test_config_toml_roundtrip() {
let config = TuiConfig {
theme: "Dracula".to_string(),
onboarding_completed: true,
..TuiConfig::default()
};
let serialized = toml::to_string_pretty(&config).expect("serialize");
let deserialized: TuiConfig = toml::from_str(&serialized).expect("deserialize");
assert_eq!(deserialized.theme, "Dracula");
assert!(deserialized.onboarding_completed);
}
#[test]
fn test_toml_confirmations() {
let toml_str = r#"
[confirmations]
batch_fix = false
undo_multiple = true
overwrite_docs = true
"#;
let config: TuiConfig = toml::from_str(toml_str).expect("parse config with confirmations");
assert!(!config.confirmations.batch_fix);
assert!(config.confirmations.undo_multiple);
assert!(config.confirmations.overwrite_docs);
}
#[test]
fn test_confirm_default_no() {
let conf = ConfirmationsConfig::default();
assert!(!conf.overwrite_docs, "overwrite_docs should default to false (safe)");
assert!(conf.batch_fix, "batch_fix should require confirmation by default");
assert!(conf.undo_multiple, "undo_multiple should require confirmation by default");
}
#[test]
fn test_confirm_yes_proceeds() {
let conf = ConfirmationsConfig {
batch_fix: false,
..ConfirmationsConfig::default()
};
assert!(!conf.batch_fix, "batch_fix=false means proceed without confirmation");
}
#[test]
fn test_global_config_defaults() {
let global = GlobalConfig::default();
assert_eq!(global.engine_port, 3099);
assert_eq!(global.theme, "dark");
assert_eq!(global.navigation, "standard");
assert!(global.sidebar_visible);
assert!(global.animations_enabled);
}
#[test]
fn test_project_config_defaults() {
let project = ProjectConfig::default();
assert!(!project.onboarding_completed);
assert_eq!(project.jurisdiction, "eu");
assert_eq!(project.role, "deployer");
assert_eq!(project.industry, "general");
assert_eq!(project.scan_scope, vec!["deps", "env", "source"]);
assert!(!project.watch_on_start);
}
#[test]
fn test_global_config_deserialization() {
let toml_str = r#"
engine_port = 4000
theme = "light"
navigation = "vim"
sidebar_visible = false
"#;
let config: GlobalConfig = toml::from_str(toml_str).expect("valid toml");
assert_eq!(config.engine_port, 4000);
assert_eq!(config.theme, "light");
assert_eq!(config.navigation, "vim");
assert!(!config.sidebar_visible);
}
#[test]
fn test_project_config_deserialization() {
let toml_str = r#"
onboarding_completed = true
jurisdiction = "us"
role = "provider"
industry = "healthcare"
scan_scope = ["deps", "source"]
watch_on_start = true
"#;
let config: ProjectConfig = toml::from_str(toml_str).expect("valid toml");
assert!(config.onboarding_completed);
assert_eq!(config.jurisdiction, "us");
assert_eq!(config.role, "provider");
assert_eq!(config.industry, "healthcare");
assert_eq!(config.scan_scope, vec!["deps", "source"]);
assert!(config.watch_on_start);
}
#[test]
fn test_merge_project_overrides_llm() {
let global = GlobalConfig {
llm_provider: Some("openai".into()),
llm_model: Some("gpt-4".into()),
..GlobalConfig::default()
};
let project = ProjectConfig {
llm_provider: Some("anthropic".into()),
llm_model: None, ..ProjectConfig::default()
};
let merged = merge_config(global, project);
assert_eq!(merged.llm_provider.as_deref(), Some("anthropic"));
assert_eq!(merged.llm_model.as_deref(), Some("gpt-4")); }
#[test]
fn test_merge_keeps_global_when_project_none() {
let global = GlobalConfig {
llm_provider: Some("openai".into()),
..GlobalConfig::default()
};
let project = ProjectConfig::default(); let merged = merge_config(global, project);
assert_eq!(merged.llm_provider.as_deref(), Some("openai"));
}
#[test]
fn test_merge_project_overrides_saas() {
let global = GlobalConfig {
project_api_url: "https://global.example.com".into(),
offline_mode: false,
..GlobalConfig::default()
};
let project = ProjectConfig {
project_api_url: Some("https://project.example.com".into()),
offline_mode: Some(true),
..ProjectConfig::default()
};
let merged = merge_config(global, project);
assert_eq!(merged.project_api_url, "https://project.example.com");
assert!(merged.offline_mode);
}
#[test]
fn test_merge_saas_falls_back_to_global() {
let global = GlobalConfig {
project_api_url: "https://global.example.com".into(),
offline_mode: true,
..GlobalConfig::default()
};
let project = ProjectConfig::default(); let merged = merge_config(global, project);
assert_eq!(merged.project_api_url, "https://global.example.com");
assert!(merged.offline_mode);
}
#[test]
fn test_merge_all_fields() {
let global = GlobalConfig {
theme: "dracula".into(),
navigation: "vim".into(),
engine_port: 4000,
..GlobalConfig::default()
};
let project = ProjectConfig {
jurisdiction: "us".into(),
role: "provider".into(),
onboarding_completed: true,
..ProjectConfig::default()
};
let merged = merge_config(global, project);
assert_eq!(merged.theme, "dracula");
assert_eq!(merged.navigation, "vim");
assert_eq!(merged.engine_port, 4000);
assert_eq!(merged.jurisdiction, "us");
assert_eq!(merged.role, "provider");
assert!(merged.onboarding_completed);
assert_eq!(merged.industry, "general");
}
}