use clap::ValueEnum;
use serde::Deserialize;
use std::fmt;
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum AiProvider {
Claude,
Cursor,
Copilot,
Ollama,
Anthropic,
#[serde(alias = "openai")]
OpenAi,
Gemini,
#[serde(alias = "deepseek")]
DeepSeek,
Groq,
Mistral,
#[serde(alias = "xai", alias = "grok")]
XAi,
Together,
Custom,
}
impl AiProvider {
#[allow(dead_code)]
pub fn default_command(&self) -> Option<&'static str> {
match self {
AiProvider::Claude => Some("claude -p --output-format text"),
AiProvider::Ollama => Some("ollama run llama3"),
AiProvider::Copilot => Some("gh copilot suggest -t shell"),
AiProvider::Cursor
| AiProvider::Custom
| AiProvider::Anthropic
| AiProvider::OpenAi
| AiProvider::Gemini
| AiProvider::DeepSeek
| AiProvider::Groq
| AiProvider::Mistral
| AiProvider::XAi
| AiProvider::Together => None,
}
}
pub fn binary_name(&self) -> &'static str {
match self {
AiProvider::Claude => "claude",
AiProvider::Cursor => "cursor",
AiProvider::Copilot => "gh",
AiProvider::Ollama => "ollama",
AiProvider::Anthropic
| AiProvider::OpenAi
| AiProvider::Gemini
| AiProvider::DeepSeek
| AiProvider::Groq
| AiProvider::Mistral
| AiProvider::XAi
| AiProvider::Together
| AiProvider::Custom => "",
}
}
pub fn is_api_provider(&self) -> bool {
matches!(
self,
AiProvider::Anthropic
| AiProvider::OpenAi
| AiProvider::Gemini
| AiProvider::DeepSeek
| AiProvider::Groq
| AiProvider::Mistral
| AiProvider::XAi
| AiProvider::Together
)
}
pub fn api_key_env_var(&self) -> Option<&'static str> {
match self {
AiProvider::Anthropic => Some("ANTHROPIC_API_KEY"),
AiProvider::OpenAi => Some("OPENAI_API_KEY"),
AiProvider::Gemini => Some("GEMINI_API_KEY"),
AiProvider::DeepSeek => Some("DEEPSEEK_API_KEY"),
AiProvider::Groq => Some("GROQ_API_KEY"),
AiProvider::Mistral => Some("MISTRAL_API_KEY"),
AiProvider::XAi => Some("XAI_API_KEY"),
AiProvider::Together => Some("TOGETHER_API_KEY"),
_ => None,
}
}
pub fn default_model(&self) -> Option<&'static str> {
match self {
AiProvider::Anthropic => Some("claude-sonnet-4-20250514"),
AiProvider::OpenAi => Some("gpt-4o"),
AiProvider::Gemini => Some("gemini-2.5-flash"),
AiProvider::DeepSeek => Some("deepseek-chat"),
AiProvider::Groq => Some("llama-3.3-70b-versatile"),
AiProvider::Mistral => Some("mistral-large-latest"),
AiProvider::XAi => Some("grok-3-mini"),
AiProvider::Together => Some("meta-llama/Llama-3.3-70B-Instruct-Turbo"),
_ => None,
}
}
pub fn default_base_url(&self) -> Option<&'static str> {
match self {
AiProvider::DeepSeek => Some("https://api.deepseek.com"),
AiProvider::Groq => Some("https://api.groq.com/openai"),
AiProvider::Mistral => Some("https://api.mistral.ai"),
AiProvider::XAi => Some("https://api.x.ai"),
AiProvider::Together => Some("https://api.together.xyz"),
_ => None,
}
}
pub fn from_str_loose(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"claude" => Some(AiProvider::Claude),
"cursor" => Some(AiProvider::Cursor),
"copilot" | "gh-copilot" => Some(AiProvider::Copilot),
"ollama" => Some(AiProvider::Ollama),
"anthropic" | "anthropic-api" => Some(AiProvider::Anthropic),
"openai" | "openai-api" => Some(AiProvider::OpenAi),
"gemini" | "google" => Some(AiProvider::Gemini),
"deepseek" => Some(AiProvider::DeepSeek),
"groq" => Some(AiProvider::Groq),
"mistral" => Some(AiProvider::Mistral),
"xai" | "grok" | "x-ai" => Some(AiProvider::XAi),
"together" | "together-ai" => Some(AiProvider::Together),
_ => None,
}
}
pub fn detection_order() -> &'static [AiProvider] {
&[
AiProvider::Claude,
AiProvider::Copilot,
AiProvider::Ollama,
AiProvider::Anthropic,
AiProvider::DeepSeek,
AiProvider::Gemini,
AiProvider::Groq,
AiProvider::Mistral,
AiProvider::OpenAi,
AiProvider::Together,
AiProvider::XAi,
]
}
}
impl fmt::Display for AiProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AiProvider::Claude => write!(f, "claude"),
AiProvider::Cursor => write!(f, "cursor"),
AiProvider::Copilot => write!(f, "copilot"),
AiProvider::Ollama => write!(f, "ollama"),
AiProvider::Anthropic => write!(f, "anthropic"),
AiProvider::OpenAi => write!(f, "openai"),
AiProvider::Gemini => write!(f, "gemini"),
AiProvider::DeepSeek => write!(f, "deepseek"),
AiProvider::Groq => write!(f, "groq"),
AiProvider::Mistral => write!(f, "mistral"),
AiProvider::XAi => write!(f, "xai"),
AiProvider::Together => write!(f, "together"),
AiProvider::Custom => write!(f, "custom"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
pub enum OutputFormat {
#[default]
Text,
Json,
Markdown,
Github,
Table,
Csv,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SpecStatus {
Draft,
Review,
Active,
Stable,
Deprecated,
Archived,
}
impl SpecStatus {
pub fn from_str_loose(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"draft" => Some(Self::Draft),
"review" => Some(Self::Review),
"active" => Some(Self::Active),
"stable" => Some(Self::Stable),
"deprecated" => Some(Self::Deprecated),
"archived" => Some(Self::Archived),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Review => "review",
Self::Active => "active",
Self::Stable => "stable",
Self::Deprecated => "deprecated",
Self::Archived => "archived",
}
}
pub fn all() -> &'static [SpecStatus] {
&[
Self::Draft,
Self::Review,
Self::Active,
Self::Stable,
Self::Deprecated,
Self::Archived,
]
}
pub fn ordinal(&self) -> usize {
match self {
Self::Draft => 0,
Self::Review => 1,
Self::Active => 2,
Self::Stable => 3,
Self::Deprecated => 4,
Self::Archived => 5,
}
}
pub fn next(&self) -> Option<Self> {
let all = Self::all();
let idx = self.ordinal();
all.get(idx + 1).copied()
}
pub fn prev(&self) -> Option<Self> {
let idx = self.ordinal();
if idx == 0 {
return None;
}
Some(Self::all()[idx - 1])
}
pub fn valid_transitions(&self) -> Vec<Self> {
let mut transitions = Vec::new();
if let Some(next) = self.next() {
transitions.push(next);
}
if let Some(prev) = self.prev() {
transitions.push(prev);
}
if *self != Self::Deprecated
&& *self != Self::Archived
&& !transitions.contains(&Self::Deprecated)
{
transitions.push(Self::Deprecated);
}
transitions
}
pub fn can_transition_to(&self, target: &Self) -> bool {
self.valid_transitions().contains(target)
}
}
#[derive(Debug, Default, Clone)]
pub struct Frontmatter {
pub module: Option<String>,
pub version: Option<String>,
pub status: Option<String>,
pub files: Vec<String>,
pub db_tables: Vec<String>,
pub depends_on: Vec<String>,
pub agent_policy: Option<String>,
pub implements: Vec<u64>,
pub tracks: Vec<u64>,
pub lifecycle_log: Vec<String>,
}
impl Frontmatter {
pub fn parsed_status(&self) -> Option<SpecStatus> {
self.status.as_deref().and_then(SpecStatus::from_str_loose)
}
}
#[derive(Debug)]
pub struct ValidationResult {
pub spec_path: String,
pub errors: Vec<String>,
pub warnings: Vec<String>,
pub export_summary: Option<String>,
pub fixes: Vec<String>,
}
impl ValidationResult {
pub fn new(spec_path: String) -> Self {
Self {
spec_path,
errors: Vec::new(),
warnings: Vec::new(),
export_summary: None,
fixes: Vec::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct CoverageReport {
pub total_source_files: usize,
pub specced_file_count: usize,
pub unspecced_files: Vec<String>,
pub unspecced_modules: Vec<String>,
pub coverage_percent: usize,
pub total_loc: usize,
pub specced_loc: usize,
pub loc_coverage_percent: usize,
pub unspecced_file_loc: Vec<(String, usize)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ExportLevel {
Type,
#[default]
Member,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ParseMode {
#[default]
Regex,
Ast,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum EnforcementMode {
#[default]
Warn,
EnforceNew,
Strict,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpecSyncConfig {
#[serde(default = "default_specs_dir")]
pub specs_dir: String,
#[serde(default = "default_source_dirs")]
pub source_dirs: Vec<String>,
pub schema_dir: Option<String>,
pub schema_pattern: Option<String>,
#[serde(default = "default_required_sections")]
pub required_sections: Vec<String>,
#[serde(default = "default_exclude_dirs")]
pub exclude_dirs: Vec<String>,
#[serde(default = "default_exclude_patterns")]
pub exclude_patterns: Vec<String>,
#[serde(default)]
pub source_extensions: Vec<String>,
#[serde(default)]
pub export_level: ExportLevel,
#[serde(default)]
pub parse_mode: ParseMode,
#[serde(default)]
pub modules: std::collections::HashMap<String, ModuleDefinition>,
#[serde(default)]
pub ai_provider: Option<AiProvider>,
#[serde(default)]
pub ai_model: Option<String>,
#[serde(default)]
pub ai_command: Option<String>,
#[serde(default)]
pub ai_api_key: Option<String>,
#[serde(default)]
pub ai_base_url: Option<String>,
#[serde(default)]
pub ai_timeout: Option<u64>,
#[serde(default)]
pub rules: ValidationRules,
#[serde(default)]
pub custom_rules: Vec<CustomRule>,
#[serde(default)]
pub task_archive_days: Option<u32>,
#[serde(default)]
pub github: Option<GitHubConfig>,
#[serde(default)]
pub enforcement: EnforcementMode,
#[serde(default)]
pub lifecycle: LifecycleConfig,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LifecycleConfig {
#[serde(default)]
pub guards: std::collections::HashMap<String, TransitionGuard>,
#[serde(default = "default_true")]
pub track_history: bool,
#[serde(default)]
pub max_age: std::collections::HashMap<String, u64>,
#[serde(default)]
pub allowed_statuses: Vec<String>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransitionGuard {
#[serde(default)]
pub min_score: Option<u32>,
#[serde(default)]
pub require_sections: Vec<String>,
#[serde(default)]
pub no_stale: Option<bool>,
#[serde(default)]
pub stale_threshold: Option<usize>,
#[serde(default)]
pub message: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitHubConfig {
#[serde(default)]
pub repo: Option<String>,
#[serde(default = "default_drift_labels")]
pub drift_labels: Vec<String>,
#[serde(default = "default_true")]
pub verify_issues: bool,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ValidationRules {
#[serde(default)]
pub max_changelog_entries: Option<usize>,
#[serde(default)]
pub require_behavioral_examples: Option<bool>,
#[serde(default)]
pub min_invariants: Option<usize>,
#[serde(default)]
pub max_spec_size_kb: Option<usize>,
#[serde(default)]
pub require_depends_on: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomRule {
pub name: String,
#[serde(rename = "type")]
pub rule_type: CustomRuleType,
#[serde(default)]
pub section: Option<String>,
#[serde(default)]
pub pattern: Option<String>,
#[serde(default)]
pub min_words: Option<usize>,
#[serde(default)]
pub severity: RuleSeverity,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub applies_to: Option<RuleFilter>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum CustomRuleType {
RequireSection,
MinWordCount,
RequirePattern,
ForbidPattern,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum RuleSeverity {
Error,
#[default]
Warning,
Info,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuleFilter {
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub module: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct ModuleDefinition {
#[serde(default)]
pub files: Vec<String>,
#[serde(default)]
pub depends_on: Vec<String>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct RegistryEntry {
pub name: String,
pub specs: Vec<(String, String)>, }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Language {
TypeScript,
Rust,
Go,
Python,
Swift,
Kotlin,
Java,
CSharp,
Dart,
Php,
Ruby,
}
impl Language {
pub fn from_extension(ext: &str) -> Option<Self> {
match ext {
"ts" | "tsx" | "js" | "jsx" | "mts" | "cts" => Some(Language::TypeScript),
"rs" => Some(Language::Rust),
"go" => Some(Language::Go),
"py" => Some(Language::Python),
"swift" => Some(Language::Swift),
"kt" | "kts" => Some(Language::Kotlin),
"java" => Some(Language::Java),
"cs" => Some(Language::CSharp),
"dart" => Some(Language::Dart),
"php" => Some(Language::Php),
"rb" => Some(Language::Ruby),
_ => None,
}
}
#[allow(dead_code)]
pub fn extensions(&self) -> &[&str] {
match self {
Language::TypeScript => &["ts", "tsx", "js", "jsx", "mts", "cts"],
Language::Rust => &["rs"],
Language::Go => &["go"],
Language::Python => &["py"],
Language::Swift => &["swift"],
Language::Kotlin => &["kt", "kts"],
Language::Java => &["java"],
Language::CSharp => &["cs"],
Language::Dart => &["dart"],
Language::Php => &["php"],
Language::Ruby => &["rb"],
}
}
pub fn test_patterns(&self) -> &[&str] {
match self {
Language::TypeScript => &[".test.ts", ".spec.ts", ".test.tsx", ".spec.tsx", ".d.ts"],
Language::Rust => &[], Language::Go => &["_test.go"],
Language::Python => &["test_", "_test.py"],
Language::Swift => &[
"Tests.swift",
"Test.swift",
"Spec.swift",
"Specs.swift",
"Mock.swift",
"Mocks.swift",
"Stub.swift",
"Fake.swift",
],
Language::Kotlin => &[
"Test.kt", "Tests.kt", "Spec.kt", "Specs.kt", "Mock.kt", "Fake.kt",
],
Language::Java => &[
"Test.java",
"Tests.java",
"Spec.java",
"Mock.java",
"IT.java",
],
Language::CSharp => &["Tests.cs", "Test.cs", "Spec.cs", "Mock.cs"],
Language::Dart => &["_test.dart"],
Language::Php => &["Test.php", "test_"],
Language::Ruby => &["_spec.rb", "_test.rb", "test_"],
}
}
}
fn default_specs_dir() -> String {
"specs".to_string()
}
fn default_source_dirs() -> Vec<String> {
vec!["src".to_string()]
}
fn default_required_sections() -> Vec<String> {
vec![
"Purpose".to_string(),
"Public API".to_string(),
"Invariants".to_string(),
"Behavioral Examples".to_string(),
"Error Cases".to_string(),
"Dependencies".to_string(),
"Change Log".to_string(),
]
}
fn default_exclude_dirs() -> Vec<String> {
vec!["__tests__".to_string()]
}
fn default_exclude_patterns() -> Vec<String> {
vec![
"**/__tests__/**".to_string(),
"**/*.test.ts".to_string(),
"**/*.spec.ts".to_string(),
]
}
fn default_drift_labels() -> Vec<String> {
vec!["spec-drift".to_string()]
}
fn default_true() -> bool {
true
}
impl Default for SpecSyncConfig {
fn default() -> Self {
Self {
specs_dir: default_specs_dir(),
source_dirs: default_source_dirs(),
schema_dir: None,
schema_pattern: None,
required_sections: default_required_sections(),
exclude_dirs: default_exclude_dirs(),
exclude_patterns: default_exclude_patterns(),
source_extensions: Vec::new(),
export_level: ExportLevel::default(),
parse_mode: ParseMode::default(),
modules: std::collections::HashMap::new(),
ai_provider: None,
ai_model: None,
ai_command: None,
ai_api_key: None,
ai_base_url: None,
ai_timeout: None,
rules: ValidationRules::default(),
custom_rules: Vec::new(),
task_archive_days: None,
github: None,
enforcement: EnforcementMode::default(),
lifecycle: LifecycleConfig::default(),
}
}
}