Skip to main content

seshat_core/
config.rs

1use serde::{Deserialize, Serialize};
2
3/// Configuration for the scanning pipeline.
4#[derive(Debug, Clone, Serialize, Deserialize)]
5#[serde(default, rename_all = "snake_case")]
6pub struct ScanConfig {
7    /// Glob patterns (relative to project root) to exclude from **all**
8    /// discovery flows — source files, documentation ingestion, and any
9    /// future filesystem walks.
10    ///
11    /// Examples:
12    /// ```toml
13    /// [scan]
14    /// exclude_paths = [".opencode/**", "_bmad/**", "logs/**", "*.log"]
15    /// ```
16    ///
17    /// The old name `exclude_patterns` is accepted as a TOML alias for
18    /// backwards compatibility.
19    #[serde(alias = "exclude_patterns")]
20    pub exclude_paths: Vec<String>,
21    /// Maximum file size in kilobytes. Files larger than this are skipped.
22    pub max_file_size_kb: u64,
23    /// Whether to exclude separate submodule scans.
24    /// Defaults to `false` — submodules are scanned into their own DBs by default.
25    /// Root discovery always excludes submodule dirs (they get their own DBs);
26    /// this flag controls whether separate submodule scans happen at all.
27    #[serde(default)]
28    pub exclude_submodules: bool,
29    /// Top-level package names that belong to **this project** and should not
30    /// be treated as external dependencies.
31    ///
32    /// Useful for monorepos or projects where internal packages are imported
33    /// without a relative-path prefix (common in Python). For example, if
34    /// `from myawesomeapp.web import app` and `myawesomeapp` is a local package, add
35    /// `"myawesomeapp"` here so it is excluded from the external-dependency list.
36    ///
37    /// Applies to all languages, though it is most relevant for Python where
38    /// internal and external imports are syntactically identical.
39    ///
40    /// Example:
41    /// ```toml
42    /// [scan]
43    /// local_packages = ["myawesomeapp", "shared", "worker"]
44    /// ```
45    #[serde(default)]
46    pub local_packages: Vec<String>,
47    /// Maximum number of files allowed for auto-scan on `seshat serve`.
48    /// Projects exceeding this limit will not be auto-scanned; the user
49    /// must run `seshat scan` explicitly.
50    pub auto_scan_limit: usize,
51    /// Additional absolute paths that `seshat serve` should treat as
52    /// dangerous when deciding whether to auto-scan from a non-git cwd.
53    ///
54    /// Each entry matches when the cwd equals the entry **or** is a
55    /// descendant of it (component-wise prefix). Absolute paths only —
56    /// tilde (`~`) and environment variables are **not** expanded.
57    /// Relative entries are skipped at runtime with a warn-level log.
58    ///
59    /// Example:
60    /// ```toml
61    /// [scan]
62    /// additional_denylist_paths = ["/mnt/nfs", "/Volumes/BackupDrive"]
63    /// ```
64    #[serde(default)]
65    pub additional_denylist_paths: Vec<String>,
66}
67
68impl Default for ScanConfig {
69    fn default() -> Self {
70        Self {
71            exclude_paths: Vec::new(),
72            max_file_size_kb: 512,
73            exclude_submodules: false,
74            local_packages: Vec::new(),
75            auto_scan_limit: 50_000,
76            additional_denylist_paths: Vec::new(),
77        }
78    }
79}
80
81/// Configuration for the convention detection engine.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(default, rename_all = "snake_case")]
84pub struct DetectionConfig {
85    /// Confidence threshold for "Strong" weight.
86    pub confidence_strong: f64,
87    /// Confidence threshold for "Moderate" weight.
88    pub confidence_moderate: f64,
89    /// Confidence threshold for "Weak" weight.
90    pub confidence_weak: f64,
91    /// Maximum number of lines per code snippet.
92    pub max_snippet_lines: usize,
93    /// Age threshold (in days) below which a convention is considered Rising.
94    /// If the P90 commit date is fewer than this many days ago, trend = Rising.
95    pub trend_rising_days: u32,
96    /// Age threshold (in days) below which a convention is considered Stable.
97    /// If the P90 commit date is fewer than this many days ago but at least
98    /// `trend_rising_days`, trend = Stable. Beyond this threshold, trend = Declining.
99    pub trend_stable_days: u32,
100}
101
102impl Default for DetectionConfig {
103    fn default() -> Self {
104        Self {
105            confidence_strong: 0.85,
106            confidence_moderate: 0.50,
107            confidence_weak: 0.20,
108            max_snippet_lines: 20,
109            trend_rising_days: 90,
110            trend_stable_days: 365,
111        }
112    }
113}
114
115/// Configuration for automatic database backups.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(default, rename_all = "snake_case")]
118pub struct BackupConfig {
119    /// Whether automatic backups are enabled.
120    pub enabled: bool,
121    /// Maximum number of backup files to retain. Older backups beyond this
122    /// count are deleted.
123    pub retention_count: usize,
124    /// Minimum interval between backups, in hours.
125    pub interval_hours: u64,
126}
127
128impl Default for BackupConfig {
129    fn default() -> Self {
130        Self {
131            enabled: true,
132            retention_count: 3,
133            interval_hours: 24,
134        }
135    }
136}
137
138/// Configuration for the MCP server.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(default, rename_all = "snake_case")]
141pub struct ServerConfig {
142    /// Log level for the server.
143    pub log_level: String,
144    /// Host to bind the HTTP/SSE transport to.
145    pub host: String,
146    /// Port for the HTTP/SSE transport.
147    pub port: u16,
148    /// Enabled transports. Possible values: `"stdio"`, `"sse"`, `"http"`.
149    pub transports: Vec<String>,
150    /// Path to JSONL file for MCP tool call logging. `None` means disabled.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub call_log: Option<String>,
153}
154
155impl Default for ServerConfig {
156    fn default() -> Self {
157        Self {
158            log_level: "info".to_owned(),
159            host: "127.0.0.1".to_owned(),
160            port: 6174,
161            transports: vec!["stdio".to_owned(), "sse".to_owned(), "http".to_owned()],
162            call_log: None,
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn scan_config_defaults() {
173        let cfg = ScanConfig::default();
174        assert!(cfg.exclude_paths.is_empty());
175        assert_eq!(cfg.max_file_size_kb, 512);
176        assert_eq!(cfg.auto_scan_limit, 50_000);
177        assert!(cfg.additional_denylist_paths.is_empty());
178    }
179
180    #[test]
181    fn scan_config_deserializes_additional_denylist_paths_from_toml() {
182        let toml_src = r#"
183additional_denylist_paths = ["/mnt/nfs", "/Volumes/BackupDrive"]
184"#;
185        let cfg: ScanConfig = toml::from_str(toml_src).expect("deserialize");
186        assert_eq!(
187            cfg.additional_denylist_paths,
188            vec!["/mnt/nfs".to_owned(), "/Volumes/BackupDrive".to_owned()]
189        );
190    }
191
192    #[test]
193    fn scan_config_missing_additional_denylist_paths_defaults_to_empty() {
194        // Pre-existing TOML without the new field must keep deserializing
195        // and the field must default to an empty Vec.
196        let toml_src = r#"
197exclude_paths = ["target/**"]
198max_file_size_kb = 256
199auto_scan_limit = 10_000
200"#;
201        let cfg: ScanConfig = toml::from_str(toml_src).expect("deserialize");
202        assert_eq!(cfg.exclude_paths, vec!["target/**".to_owned()]);
203        assert_eq!(cfg.max_file_size_kb, 256);
204        assert_eq!(cfg.auto_scan_limit, 10_000);
205        assert!(cfg.additional_denylist_paths.is_empty());
206    }
207
208    #[test]
209    fn detection_config_defaults() {
210        let cfg = DetectionConfig::default();
211        assert!((cfg.confidence_strong - 0.85).abs() < f64::EPSILON);
212        assert!((cfg.confidence_moderate - 0.50).abs() < f64::EPSILON);
213        assert!((cfg.confidence_weak - 0.20).abs() < f64::EPSILON);
214        assert_eq!(cfg.max_snippet_lines, 20);
215        assert_eq!(cfg.trend_rising_days, 90);
216        assert_eq!(cfg.trend_stable_days, 365);
217    }
218
219    #[test]
220    fn server_config_defaults() {
221        let cfg = ServerConfig::default();
222        assert_eq!(cfg.log_level, "info");
223        assert_eq!(cfg.host, "127.0.0.1");
224        assert_eq!(cfg.port, 6174);
225        assert_eq!(cfg.transports, vec!["stdio", "sse", "http"]);
226        assert_eq!(cfg.call_log, None);
227    }
228
229    #[test]
230    fn backup_config_defaults() {
231        let cfg = BackupConfig::default();
232        assert!(cfg.enabled);
233        assert_eq!(cfg.retention_count, 3);
234        assert_eq!(cfg.interval_hours, 24);
235    }
236
237    #[test]
238    fn config_serialization_roundtrip() {
239        let cfg = DetectionConfig::default();
240        let json = serde_json::to_string(&cfg).expect("serialize");
241        let deserialized: DetectionConfig = serde_json::from_str(&json).expect("deserialize");
242        assert!((deserialized.confidence_strong - cfg.confidence_strong).abs() < f64::EPSILON);
243    }
244}