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