1use schemars::{JsonSchema, schema_for};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4
5pub const SCHEMA_NAMES: &[&str] = &[
6 "manifest",
7 "config",
8 "spec",
9 "finding",
10 "run-report",
11 "persona",
12 "business-rule",
13 "acceptance-criteria",
14 "critical-flow",
15 "risk",
16 "regression-test-suggestion",
17];
18
19#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
20#[serde(default)]
21pub struct SpecLoopConfig {
22 pub schema_version: String,
23 pub project: ProjectConfig,
24 pub agent: AgentConfig,
25 pub browser: BrowserConfig,
26 pub safety: SafetyConfig,
27 pub outputs: OutputsConfig,
28}
29
30impl Default for SpecLoopConfig {
31 fn default() -> Self {
32 Self {
33 schema_version: "0.1".to_string(),
34 project: ProjectConfig::default(),
35 agent: AgentConfig::default(),
36 browser: BrowserConfig::default(),
37 safety: SafetyConfig::default(),
38 outputs: OutputsConfig::default(),
39 }
40 }
41}
42
43#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
44#[serde(default)]
45pub struct ProjectConfig {
46 pub name: String,
47 #[serde(rename = "type")]
48 pub project_type: ProjectType,
49 pub target_url: String,
50 pub environment: Environment,
51}
52
53impl Default for ProjectConfig {
54 fn default() -> Self {
55 Self {
56 name: "My SaaS".to_string(),
57 project_type: ProjectType::WebApp,
58 target_url: "http://localhost:3000".to_string(),
59 environment: Environment::Local,
60 }
61 }
62}
63
64#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
65#[serde(rename_all = "kebab-case")]
66pub enum ProjectType {
67 #[default]
68 WebApp,
69 Api,
70 MobileApp,
71 Library,
72 Other,
73}
74
75#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
76#[serde(rename_all = "kebab-case")]
77pub enum Environment {
78 #[default]
79 Local,
80 Staging,
81 Production,
82}
83
84#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
85#[serde(default)]
86pub struct AgentConfig {
87 pub mode: AgentMode,
88 pub provider_agnostic: bool,
89 pub cold_context_cache: Vec<String>,
90 pub hot_context_sources: Vec<String>,
91}
92
93impl Default for AgentConfig {
94 fn default() -> Self {
95 Self {
96 mode: AgentMode::Exploratory,
97 provider_agnostic: true,
98 cold_context_cache: vec![
99 ".specloop/product.md".to_string(),
100 ".specloop/specs.md".to_string(),
101 ".specloop/business-rules.md".to_string(),
102 ".specloop/critical-flows.md".to_string(),
103 ],
104 hot_context_sources: vec![
105 ".specloop/risk-register.md".to_string(),
106 ".specloop/findings".to_string(),
107 ".specloop/reports".to_string(),
108 ],
109 }
110 }
111}
112
113#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
114#[serde(rename_all = "kebab-case")]
115pub enum AgentMode {
116 #[default]
117 Exploratory,
118 Regression,
119 Triage,
120 Documentation,
121}
122
123#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
124#[serde(default)]
125pub struct BrowserConfig {
126 pub engine: BrowserEngine,
127 pub fallback: BrowserEngine,
128 pub allow_browser_writes: bool,
129}
130
131impl Default for BrowserConfig {
132 fn default() -> Self {
133 Self {
134 engine: BrowserEngine::ChromeDevtoolsMcp,
135 fallback: BrowserEngine::Playwright,
136 allow_browser_writes: false,
137 }
138 }
139}
140
141#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
142#[serde(rename_all = "kebab-case")]
143pub enum BrowserEngine {
144 #[default]
145 ChromeDevtoolsMcp,
146 Playwright,
147 Shell,
148 None,
149}
150
151#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
152#[serde(default)]
153pub struct SafetyConfig {
154 pub read_only_by_default: bool,
155 pub block_destructive_actions: bool,
156 pub require_approval_for_writes: bool,
157 pub block_production_writes: bool,
158 pub sanitize_logs: bool,
159 pub persist_cookies: bool,
160 pub trusted_mcp_servers: Vec<String>,
161}
162
163impl Default for SafetyConfig {
164 fn default() -> Self {
165 Self {
166 read_only_by_default: true,
167 block_destructive_actions: true,
168 require_approval_for_writes: true,
169 block_production_writes: true,
170 sanitize_logs: true,
171 persist_cookies: false,
172 trusted_mcp_servers: vec!["chrome-devtools-mcp".to_string()],
173 }
174 }
175}
176
177#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
178#[serde(default)]
179pub struct OutputsConfig {
180 pub findings_dir: String,
181 pub reports_dir: String,
182 pub screenshots_dir: String,
183 pub traces_dir: String,
184}
185
186impl Default for OutputsConfig {
187 fn default() -> Self {
188 Self {
189 findings_dir: ".specloop/findings".to_string(),
190 reports_dir: ".specloop/reports".to_string(),
191 screenshots_dir: ".specloop/screenshots".to_string(),
192 traces_dir: ".specloop/traces".to_string(),
193 }
194 }
195}
196
197#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
198#[serde(default)]
199pub struct Manifest {
200 pub schema_version: String,
201 pub project: ProjectConfig,
202 pub specs: Vec<ProductSpec>,
203 pub personas: Vec<Persona>,
204 pub business_rules: Vec<BusinessRule>,
205 pub critical_flows: Vec<CriticalFlow>,
206 pub risks: Vec<Risk>,
207}
208
209impl Default for Manifest {
210 fn default() -> Self {
211 Self {
212 schema_version: "0.1".to_string(),
213 project: ProjectConfig::default(),
214 specs: vec![],
215 personas: vec![],
216 business_rules: vec![],
217 critical_flows: vec![],
218 risks: vec![],
219 }
220 }
221}
222
223#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
224pub struct ProductSpec {
225 pub id: String,
226 pub title: String,
227 pub area: String,
228 pub expected_behavior: String,
229 pub acceptance_criteria: Vec<AcceptanceCriterion>,
230}
231
232#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
233pub struct Persona {
234 pub id: String,
235 pub name: String,
236 pub goals: Vec<String>,
237 pub constraints: Vec<String>,
238}
239
240#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
241pub struct BusinessRule {
242 pub id: String,
243 pub title: String,
244 pub rule: String,
245 pub severity_if_broken: Severity,
246}
247
248#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
249pub struct AcceptanceCriterion {
250 pub id: String,
251 pub spec_id: String,
252 pub statement: String,
253 pub verification: String,
254}
255
256#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
257pub struct CriticalFlow {
258 pub id: String,
259 pub title: String,
260 pub persona_id: String,
261 pub steps: Vec<String>,
262 pub expected_outcome: String,
263 pub blocked_actions: Vec<String>,
264}
265
266#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
267pub struct Risk {
268 pub id: String,
269 pub title: String,
270 pub impact: Severity,
271 pub mitigation: String,
272}
273
274#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
275pub struct RegressionTestSuggestion {
276 pub title: String,
277 pub kind: RegressionKind,
278 pub steps: Vec<String>,
279 pub assertion: String,
280}
281
282#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
283#[serde(rename_all = "kebab-case")]
284pub enum RegressionKind {
285 Unit,
286 Integration,
287 #[default]
288 Playwright,
289 Manual,
290}
291
292#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
293pub struct Evidence {
294 pub kind: EvidenceKind,
295 pub location: String,
296 pub summary: String,
297}
298
299#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
300#[serde(rename_all = "kebab-case")]
301pub enum EvidenceKind {
302 Screenshot,
303 Trace,
304 Console,
305 Network,
306 Dom,
307 Stdout,
308 Stderr,
309 #[default]
310 Note,
311}
312
313#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
314pub struct Finding {
315 pub id: String,
316 pub title: String,
317 pub severity: Severity,
318 #[serde(rename = "type")]
319 pub finding_type: FindingType,
320 pub area: String,
321 pub persona: String,
322 pub related_spec: String,
323 pub related_business_rule: String,
324 pub expected_behavior: String,
325 pub actual_behavior: String,
326 pub reproduction_steps: Vec<String>,
327 pub evidence: Vec<Evidence>,
328 pub suspected_cause: String,
329 pub suggested_fix: String,
330 pub suggested_regression_test: RegressionTestSuggestion,
331 pub status: FindingStatus,
332 pub created_at: String,
333}
334
335#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
336pub enum Severity {
337 P0,
338 P1,
339 #[default]
340 P2,
341 P3,
342}
343
344#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
345#[serde(rename_all = "kebab-case")]
346pub enum FindingType {
347 Bug,
348 Ux,
349 BusinessRule,
350 SpecMismatch,
351 Security,
352 Accessibility,
353 Performance,
354 DataQuality,
355 #[default]
356 AiBehavior,
357}
358
359#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
360#[serde(rename_all = "kebab-case")]
361pub enum FindingStatus {
362 #[default]
363 Open,
364 Triaged,
365 Rejected,
366 InProgress,
367 Fixed,
368 Verified,
369}
370
371#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
372pub struct RunReport {
373 pub run_id: String,
374 pub project: String,
375 pub target_url: String,
376 pub mode: AgentMode,
377 pub status: RunStatus,
378 pub started_at: String,
379 pub finished_at: Option<String>,
380 pub findings: Vec<Finding>,
381 pub summary: String,
382}
383
384#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)]
385#[serde(rename_all = "kebab-case")]
386pub enum RunStatus {
387 #[default]
388 Planned,
389 Running,
390 Blocked,
391 Completed,
392}
393
394pub fn schema_for_name(name: &str) -> Option<Value> {
395 let schema = match name {
396 "manifest" => schema_for!(Manifest),
397 "config" => schema_for!(SpecLoopConfig),
398 "spec" => schema_for!(ProductSpec),
399 "finding" => schema_for!(Finding),
400 "run-report" => schema_for!(RunReport),
401 "persona" => schema_for!(Persona),
402 "business-rule" => schema_for!(BusinessRule),
403 "acceptance-criteria" => schema_for!(AcceptanceCriterion),
404 "critical-flow" => schema_for!(CriticalFlow),
405 "risk" => schema_for!(Risk),
406 "regression-test-suggestion" => schema_for!(RegressionTestSuggestion),
407 _ => return None,
408 };
409
410 serde_json::to_value(schema).ok()
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn default_config_is_safe() {
419 let config = SpecLoopConfig::default();
420
421 assert!(config.safety.read_only_by_default);
422 assert!(config.safety.block_destructive_actions);
423 assert!(!config.safety.persist_cookies);
424 }
425
426 #[test]
427 fn exposes_all_named_schemas() {
428 for name in SCHEMA_NAMES {
429 assert!(schema_for_name(name).is_some(), "{name}");
430 }
431 }
432}