Skip to main content

chant/config/
defaults.rs

1//! Default values and configuration structs with default implementations.
2
3use serde::{Deserialize, Serialize};
4
5use crate::provider::ProviderType;
6
7/// Agent rotation strategy for single spec execution
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
9#[serde(rename_all = "snake_case")]
10pub enum RotationStrategy {
11    #[default]
12    None,
13    Random,
14    #[serde(alias = "round-robin")]
15    RoundRobin,
16}
17
18pub(crate) fn default_rotation_strategy_enum() -> RotationStrategy {
19    RotationStrategy::None
20}
21
22/// Macro to generate default functions for serde attributes
23macro_rules! default_fn {
24    ($name:ident, $type:ty, $value:expr) => {
25        pub(crate) fn $name() -> $type {
26            $value
27        }
28    };
29}
30
31// =========================================================================
32// DEFAULT VALUE FUNCTIONS
33// =========================================================================
34
35default_fn!(default_complexity_criteria, usize, 10);
36default_fn!(default_complexity_files, usize, 5);
37default_fn!(default_complexity_words, usize, 150);
38default_fn!(default_simple_criteria, usize, 1);
39default_fn!(default_simple_files, usize, 1);
40default_fn!(default_simple_words, usize, 3);
41default_fn!(default_max_retries, usize, 3);
42default_fn!(default_retry_delay_ms, u64, 60_000); // 60 seconds
43default_fn!(default_backoff_multiplier, f64, 2.0);
44default_fn!(default_poll_interval_ms, u64, 5000); // 5 seconds
45default_fn!(default_site_output_dir, String, "./public/".to_string());
46default_fn!(default_site_base_url, String, "/".to_string());
47default_fn!(default_site_title, String, "Project Specs".to_string());
48default_fn!(
49    default_include_statuses,
50    Vec<String>,
51    vec![
52        "completed".to_string(),
53        "in_progress".to_string(),
54        "pending".to_string(),
55    ]
56);
57default_fn!(default_true, bool, true);
58default_fn!(default_agent_weight, usize, 1);
59default_fn!(default_agent_name, String, "main".to_string());
60default_fn!(default_agent_command, String, "claude".to_string());
61default_fn!(default_max_concurrent, usize, 2);
62default_fn!(default_stagger_delay_ms, u64, 1000); // Default 1 second between agent spawns
63default_fn!(default_stagger_jitter_ms, u64, 200); // Default 20% of stagger_delay_ms (200ms is 20% of 1000ms)
64default_fn!(default_prompt, String, "bootstrap".to_string());
65default_fn!(default_branch_prefix, String, "chant/".to_string());
66default_fn!(default_main_branch, String, "main".to_string());
67
68// =========================================================================
69// CONFIG STRUCTS WITH DEFAULTS
70// =========================================================================
71
72/// Thresholds for linter complexity heuristics
73#[derive(Debug, Clone, Deserialize, Serialize)]
74pub struct LintThresholds {
75    /// Max acceptance criteria for complex specs (default: 10)
76    #[serde(default = "default_complexity_criteria")]
77    pub complexity_criteria: usize,
78    /// Max target files for complex specs (default: 5)
79    #[serde(default = "default_complexity_files")]
80    pub complexity_files: usize,
81    /// Max words in title for complex specs (default: 150)
82    #[serde(default = "default_complexity_words")]
83    pub complexity_words: usize,
84    /// Min acceptance criteria for simple specs (default: 1)
85    #[serde(default = "default_simple_criteria")]
86    pub simple_criteria: usize,
87    /// Min target files for simple specs (default: 1)
88    #[serde(default = "default_simple_files")]
89    pub simple_files: usize,
90    /// Min words in title for simple specs (default: 3)
91    #[serde(default = "default_simple_words")]
92    pub simple_words: usize,
93}
94
95impl Default for LintThresholds {
96    fn default() -> Self {
97        Self {
98            complexity_criteria: default_complexity_criteria(),
99            complexity_files: default_complexity_files(),
100            complexity_words: default_complexity_words(),
101            simple_criteria: default_simple_criteria(),
102            simple_files: default_simple_files(),
103            simple_words: default_simple_words(),
104        }
105    }
106}
107
108/// Default disabled lint rules
109fn default_lint_disable() -> Vec<String> {
110    vec!["complexity_words".to_string()]
111}
112
113/// Linter configuration for spec validation
114#[derive(Debug, Clone, Deserialize, Serialize)]
115pub struct LintConfig {
116    /// Thresholds for complexity heuristics
117    #[serde(default)]
118    pub thresholds: LintThresholds,
119    /// List of rule names to disable (skip during linting)
120    #[serde(default = "default_lint_disable")]
121    pub disable: Vec<String>,
122}
123
124impl Default for LintConfig {
125    fn default() -> Self {
126        Self {
127            thresholds: LintThresholds::default(),
128            disable: default_lint_disable(),
129        }
130    }
131}
132
133/// Failure handling strategy for permanent failures
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
135#[serde(rename_all = "lowercase")]
136pub enum OnPermanentFailure {
137    /// Skip the failed spec and continue watching others
138    #[default]
139    Skip,
140    /// Stop the watch command entirely
141    Stop,
142}
143
144/// Configuration for failure handling in watch command
145#[derive(Debug, Clone, Deserialize, Serialize)]
146pub struct FailureConfig {
147    /// Maximum number of retry attempts for transient errors
148    #[serde(default = "default_max_retries")]
149    pub max_retries: usize,
150    /// Delay in milliseconds before first retry
151    #[serde(default = "default_retry_delay_ms")]
152    pub retry_delay_ms: u64,
153    /// Multiplier for exponential backoff (must be >= 1.0)
154    #[serde(default = "default_backoff_multiplier")]
155    pub backoff_multiplier: f64,
156    /// Regex patterns for errors that should be retried
157    #[serde(default)]
158    pub retryable_patterns: Vec<String>,
159    /// Action to take on permanent failure
160    #[serde(default)]
161    pub on_permanent_failure: OnPermanentFailure,
162}
163
164impl Default for FailureConfig {
165    fn default() -> Self {
166        Self {
167            max_retries: default_max_retries(),
168            retry_delay_ms: default_retry_delay_ms(),
169            backoff_multiplier: default_backoff_multiplier(),
170            retryable_patterns: vec![],
171            on_permanent_failure: OnPermanentFailure::default(),
172        }
173    }
174}
175
176/// Configuration for watch command behavior
177#[derive(Debug, Clone, Deserialize, Serialize)]
178pub struct WatchConfig {
179    /// Poll interval in milliseconds
180    #[serde(default = "default_poll_interval_ms")]
181    pub poll_interval_ms: u64,
182    /// Failure handling configuration
183    #[serde(default)]
184    pub failure: FailureConfig,
185    /// Idle timeout in minutes (default: 5)
186    #[serde(default = "default_idle_timeout_minutes")]
187    pub idle_timeout_minutes: u64,
188}
189
190fn default_idle_timeout_minutes() -> u64 {
191    5
192}
193
194impl Default for WatchConfig {
195    fn default() -> Self {
196        Self {
197            poll_interval_ms: default_poll_interval_ms(),
198            failure: FailureConfig::default(),
199            idle_timeout_minutes: default_idle_timeout_minutes(),
200        }
201    }
202}
203
204/// Configuration for what specs to include in the site
205#[derive(Debug, Clone, Deserialize, Serialize)]
206pub struct SiteIncludeConfig {
207    /// Statuses to include (default: completed, in_progress, pending)
208    #[serde(default = "default_include_statuses")]
209    pub statuses: Vec<String>,
210    /// Labels to include (empty = all)
211    #[serde(default)]
212    pub labels: Vec<String>,
213}
214
215impl Default for SiteIncludeConfig {
216    fn default() -> Self {
217        Self {
218            statuses: default_include_statuses(),
219            labels: vec![],
220        }
221    }
222}
223
224/// Configuration for what to exclude from the site
225#[derive(Debug, Clone, Deserialize, Serialize, Default)]
226pub struct SiteExcludeConfig {
227    /// Labels to exclude from output
228    #[serde(default)]
229    pub labels: Vec<String>,
230    /// Fields to redact from output (e.g., cost_usd, tokens)
231    #[serde(default)]
232    pub fields: Vec<String>,
233}
234
235/// Feature toggles for site pages
236#[derive(Debug, Clone, Deserialize, Serialize)]
237pub struct SiteFeaturesConfig {
238    /// Generate changelog page
239    #[serde(default = "default_true")]
240    pub changelog: bool,
241    /// Generate dependency graph page
242    #[serde(default = "default_true")]
243    pub dependency_graph: bool,
244    /// Generate timeline page
245    #[serde(default = "default_true")]
246    pub timeline: bool,
247    /// Generate status index pages
248    #[serde(default = "default_true")]
249    pub status_indexes: bool,
250    /// Generate label index pages
251    #[serde(default = "default_true")]
252    pub label_indexes: bool,
253}
254
255impl Default for SiteFeaturesConfig {
256    fn default() -> Self {
257        Self {
258            changelog: true,
259            dependency_graph: true,
260            timeline: true,
261            status_indexes: true,
262            label_indexes: true,
263        }
264    }
265}
266
267/// Graph detail level for dependency visualization
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
269#[serde(rename_all = "lowercase")]
270pub enum GraphDetailLevel {
271    /// Show only spec IDs
272    Minimal,
273    /// Show IDs and titles
274    Titles,
275    /// Show IDs, titles, status, and labels
276    #[default]
277    Full,
278}
279
280/// Configuration for dependency graph visualization
281#[derive(Debug, Clone, Deserialize, Serialize)]
282pub struct SiteGraphConfig {
283    /// Level of detail in the graph
284    #[serde(default)]
285    pub detail: GraphDetailLevel,
286}
287
288impl Default for SiteGraphConfig {
289    fn default() -> Self {
290        Self {
291            detail: GraphDetailLevel::Full,
292        }
293    }
294}
295
296/// Timeline grouping option
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
298#[serde(rename_all = "lowercase")]
299pub enum TimelineGroupBy {
300    /// Group by day
301    #[default]
302    Day,
303    /// Group by week
304    Week,
305    /// Group by month
306    Month,
307}
308
309/// Configuration for timeline visualization
310#[derive(Debug, Clone, Deserialize, Serialize)]
311pub struct SiteTimelineConfig {
312    /// How to group timeline entries
313    #[serde(default)]
314    pub group_by: TimelineGroupBy,
315    /// Whether to include pending specs in timeline
316    #[serde(default)]
317    pub include_pending: bool,
318}
319
320impl Default for SiteTimelineConfig {
321    fn default() -> Self {
322        Self {
323            group_by: TimelineGroupBy::Day,
324            include_pending: false,
325        }
326    }
327}
328
329/// Configuration for static site generation
330#[derive(Debug, Clone, Deserialize, Serialize, Default)]
331pub struct SiteConfig {
332    /// Output directory for generated site (default: ./public/)
333    #[serde(default = "default_site_output_dir")]
334    pub output_dir: String,
335    /// Base URL for the site (default: /)
336    #[serde(default = "default_site_base_url")]
337    pub base_url: String,
338    /// Site title (default: "Project Specs")
339    #[serde(default = "default_site_title")]
340    pub title: String,
341    /// Content filtering - what to include
342    #[serde(default)]
343    pub include: SiteIncludeConfig,
344    /// Content filtering - what to exclude
345    #[serde(default)]
346    pub exclude: SiteExcludeConfig,
347    /// Feature toggles for different page types
348    #[serde(default)]
349    pub features: SiteFeaturesConfig,
350    /// Graph visualization options
351    #[serde(default)]
352    pub graph: SiteGraphConfig,
353    /// Timeline visualization options
354    #[serde(default)]
355    pub timeline: SiteTimelineConfig,
356}
357
358/// Configuration for a single agent (Claude account/command)
359#[derive(Debug, Deserialize, Clone)]
360pub struct AgentConfig {
361    /// Name of the agent (for display and attribution)
362    #[serde(default = "default_agent_name")]
363    pub name: String,
364    /// Shell command to invoke this agent (e.g., "claude", "claude-alt1")
365    #[serde(default = "default_agent_command")]
366    pub command: String,
367    /// Maximum concurrent instances for this agent
368    #[serde(default = "default_max_concurrent")]
369    pub max_concurrent: usize,
370    /// Weight for agent rotation selection (higher = more likely to be selected)
371    #[serde(default = "default_agent_weight")]
372    pub weight: usize,
373}
374
375impl Default for AgentConfig {
376    fn default() -> Self {
377        Self {
378            name: default_agent_name(),
379            command: default_agent_command(),
380            max_concurrent: default_max_concurrent(),
381            weight: default_agent_weight(),
382        }
383    }
384}
385
386/// Configuration for parallel execution with multiple agents
387#[derive(Debug, Deserialize, Clone)]
388pub struct ParallelConfig {
389    /// List of available agents (Claude accounts/commands)
390    #[serde(default)]
391    pub agents: Vec<AgentConfig>,
392    /// Delay in milliseconds between spawning each agent to avoid API rate limiting
393    #[serde(default = "default_stagger_delay_ms")]
394    pub stagger_delay_ms: u64,
395    /// Jitter in milliseconds for spawn delays (default: 20% of stagger_delay_ms)
396    #[serde(default = "default_stagger_jitter_ms")]
397    pub stagger_jitter_ms: u64,
398}
399
400impl ParallelConfig {
401    /// Calculate total capacity as sum of all agent max_concurrent values
402    pub fn total_capacity(&self) -> usize {
403        self.agents.iter().map(|a| a.max_concurrent).sum()
404    }
405}
406
407impl Default for ParallelConfig {
408    fn default() -> Self {
409        Self {
410            agents: vec![AgentConfig::default()],
411            stagger_delay_ms: default_stagger_delay_ms(),
412            stagger_jitter_ms: default_stagger_jitter_ms(),
413        }
414    }
415}
416
417#[derive(Debug, Clone, Deserialize)]
418pub struct DefaultsConfig {
419    #[serde(default = "default_prompt")]
420    pub prompt: String,
421    #[serde(default = "default_branch_prefix")]
422    pub branch_prefix: String,
423    /// Default model name to use when env vars are not set
424    #[serde(default)]
425    pub model: Option<String>,
426    /// Default model name for split operations (defaults to sonnet)
427    #[serde(default)]
428    pub split_model: Option<String>,
429    /// Default main branch name for merges (defaults to "main")
430    #[serde(default = "default_main_branch")]
431    pub main_branch: String,
432    /// Default provider (claude, ollama, openai)
433    #[serde(default)]
434    pub provider: ProviderType,
435    /// Agent rotation strategy for single spec execution
436    #[serde(default = "default_rotation_strategy_enum")]
437    pub rotation_strategy: RotationStrategy,
438    /// List of prompt extensions to append to all prompts
439    #[serde(default)]
440    pub prompt_extensions: Vec<String>,
441}
442
443impl Default for DefaultsConfig {
444    fn default() -> Self {
445        Self {
446            prompt: default_prompt(),
447            branch_prefix: default_branch_prefix(),
448            model: None,
449            split_model: None,
450            main_branch: default_main_branch(),
451            provider: ProviderType::Claude,
452            rotation_strategy: default_rotation_strategy_enum(),
453            prompt_extensions: vec![],
454        }
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn rotation_strategy_accepts_kebab_case() {
464        let json = r#""round-robin""#;
465        let strategy: RotationStrategy = serde_json::from_str(json).unwrap();
466        assert_eq!(strategy, RotationStrategy::RoundRobin);
467    }
468
469    #[test]
470    fn rotation_strategy_accepts_snake_case() {
471        let json = r#""round_robin""#;
472        let strategy: RotationStrategy = serde_json::from_str(json).unwrap();
473        assert_eq!(strategy, RotationStrategy::RoundRobin);
474    }
475}