Skip to main content

specloop_schemas/
lib.rs

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}