1use crate::error::{Error, Result};
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12pub enum Tier {
13 T0,
14 T1,
15 T2,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum CadenceClass {
22 Fast,
24 Batch,
26 Slow,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum Capability {
34 Forks,
35 Telemetry,
36 Embeddings,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum TargetClass {
43 Memory,
44 Query,
45 Prompt,
46 Host,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum AutoApplyClass {
55 Never,
58 StructuralCuration,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "lowercase")]
66pub enum TrustClass {
67 Builtin,
69 Command,
71 Llm,
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(tag = "kind", rename_all = "snake_case")]
78pub enum ParamSpec {
79 Bool {
80 name: String,
81 default: bool,
82 description: String,
83 },
84 Int {
85 name: String,
86 default: i64,
87 min: i64,
88 max: i64,
89 description: String,
90 },
91 Float {
92 name: String,
93 default: f64,
94 min: f64,
95 max: f64,
96 description: String,
97 },
98 Str {
99 name: String,
100 default: String,
101 max_len: usize,
102 description: String,
103 },
104 Enum {
105 name: String,
106 default: String,
107 choices: Vec<String>,
108 description: String,
109 },
110 Duration {
112 name: String,
113 default_secs: i64,
114 description: String,
115 },
116}
117
118impl ParamSpec {
119 pub fn name(&self) -> &str {
120 match self {
121 ParamSpec::Bool { name, .. }
122 | ParamSpec::Int { name, .. }
123 | ParamSpec::Float { name, .. }
124 | ParamSpec::Str { name, .. }
125 | ParamSpec::Enum { name, .. }
126 | ParamSpec::Duration { name, .. } => name,
127 }
128 }
129
130 fn default_value(&self) -> Value {
131 match self {
132 ParamSpec::Bool { default, .. } => Value::Bool(*default),
133 ParamSpec::Int { default, .. } => Value::from(*default),
134 ParamSpec::Float { default, .. } => Value::from(*default),
135 ParamSpec::Str { default, .. } => Value::from(default.clone()),
136 ParamSpec::Enum { default, .. } => Value::from(default.clone()),
137 ParamSpec::Duration { default_secs, .. } => Value::from(*default_secs),
138 }
139 }
140
141 fn validate(&self, v: &Value) -> Result<()> {
143 let bad = |m: String| Err(Error::ParamInvalid(format!("{}: {m}", self.name())));
144 match self {
145 ParamSpec::Bool { .. } => {
146 if !v.is_boolean() {
147 return bad(format!("expected bool, got {v}"));
148 }
149 }
150 ParamSpec::Int { min, max, .. } => {
151 let n = v.as_i64().ok_or_else(|| {
152 Error::ParamInvalid(format!("{}: expected integer, got {v}", self.name()))
153 })?;
154 if n < *min || n > *max {
155 return bad(format!("{n} out of range [{min}, {max}]"));
156 }
157 }
158 ParamSpec::Float { min, max, .. } => {
159 let n = v.as_f64().ok_or_else(|| {
160 Error::ParamInvalid(format!("{}: expected number, got {v}", self.name()))
161 })?;
162 if n < *min || n > *max {
163 return bad(format!("{n} out of range [{min}, {max}]"));
164 }
165 }
166 ParamSpec::Str { max_len, .. } => {
167 let s = v.as_str().ok_or_else(|| {
168 Error::ParamInvalid(format!("{}: expected string, got {v}", self.name()))
169 })?;
170 if s.chars().count() > *max_len {
171 return bad(format!(
172 "length {} exceeds max {max_len}",
173 s.chars().count()
174 ));
175 }
176 }
177 ParamSpec::Enum { choices, .. } => {
178 let s = v.as_str().ok_or_else(|| {
179 Error::ParamInvalid(format!("{}: expected string, got {v}", self.name()))
180 })?;
181 if !choices.iter().any(|c| c == s) {
182 return bad(format!("{s:?} not in {choices:?}"));
183 }
184 }
185 ParamSpec::Duration { .. } => {
186 let n = v.as_i64().ok_or_else(|| {
187 Error::ParamInvalid(format!(
188 "{}: expected integer seconds, got {v}",
189 self.name()
190 ))
191 })?;
192 if n < 0 {
193 return bad(format!("duration {n} must be non-negative"));
194 }
195 }
196 }
197 Ok(())
198 }
199}
200
201#[derive(Debug, Clone, Default, PartialEq)]
205pub struct Params(Map<String, Value>);
206
207impl Params {
208 pub fn get_bool(&self, name: &str) -> bool {
209 self.0.get(name).and_then(Value::as_bool).unwrap_or(false)
210 }
211 pub fn get_int(&self, name: &str) -> i64 {
212 self.0.get(name).and_then(Value::as_i64).unwrap_or(0)
213 }
214 pub fn get_float(&self, name: &str) -> f64 {
215 self.0.get(name).and_then(Value::as_f64).unwrap_or(0.0)
216 }
217 pub fn get_str(&self, name: &str) -> &str {
218 self.0.get(name).and_then(Value::as_str).unwrap_or("")
219 }
220 pub fn get_duration_secs(&self, name: &str) -> i64 {
221 self.get_int(name)
222 }
223 pub fn snapshot(&self) -> Map<String, Value> {
226 self.0.clone()
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct AnalyzerManifest {
233 pub id: String,
235 pub title: String,
236 pub description: String,
237 pub tier: Tier,
238 pub cadence: CadenceClass,
239 #[serde(default)]
240 pub requires: Vec<Capability>,
241 pub target_classes: Vec<TargetClass>,
242 pub auto_apply: AutoApplyClass,
243 pub trust_class: TrustClass,
244 #[serde(default)]
245 pub params: Vec<ParamSpec>,
246 pub default_on: bool,
249}
250
251impl AnalyzerManifest {
252 pub fn family(&self) -> &str {
256 analyzer_family(&self.id)
257 }
258
259 pub fn resolve_params(&self, overrides: &Map<String, Value>) -> Result<Params> {
262 for key in overrides.keys() {
263 if !self.params.iter().any(|p| p.name() == key) {
264 return Err(Error::ParamInvalid(format!("unknown parameter {key:?}")));
265 }
266 }
267 let mut resolved = Map::new();
268 for spec in &self.params {
269 let value = match overrides.get(spec.name()) {
270 Some(v) => {
271 spec.validate(v)?;
272 v.clone()
273 }
274 None => spec.default_value(),
275 };
276 resolved.insert(spec.name().to_string(), value);
277 }
278 Ok(Params(resolved))
279 }
280}
281
282pub fn analyzer_family(id: &str) -> &str {
284 id.split('/').next().unwrap_or(id)
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use serde_json::json;
291
292 fn spec_manifest() -> AnalyzerManifest {
293 AnalyzerManifest {
294 id: "loop.duplicate_sweep/1".into(),
295 title: "Duplicate sweep".into(),
296 description: "d".into(),
297 tier: Tier::T0,
298 cadence: CadenceClass::Batch,
299 requires: vec![],
300 target_classes: vec![TargetClass::Memory],
301 auto_apply: AutoApplyClass::StructuralCuration,
302 trust_class: TrustClass::Builtin,
303 params: vec![
304 ParamSpec::Float {
305 name: "jaccard".into(),
306 default: 0.9,
307 min: 0.0,
308 max: 1.0,
309 description: "near-dup threshold".into(),
310 },
311 ParamSpec::Int {
312 name: "window_days".into(),
313 default: 30,
314 min: 1,
315 max: 365,
316 description: "lookback".into(),
317 },
318 ],
319 default_on: true,
320 }
321 }
322
323 #[test]
324 fn family_strips_major() {
325 assert_eq!(
326 analyzer_family("loop.duplicate_sweep/1"),
327 "loop.duplicate_sweep"
328 );
329 assert_eq!(
330 analyzer_family("loop.duplicate_sweep/2"),
331 "loop.duplicate_sweep"
332 );
333 assert_eq!(spec_manifest().family(), "loop.duplicate_sweep");
334 }
335
336 #[test]
337 fn resolve_fills_defaults() {
338 let p = spec_manifest().resolve_params(&Map::new()).unwrap();
339 assert_eq!(p.get_float("jaccard"), 0.9);
340 assert_eq!(p.get_int("window_days"), 30);
341 }
342
343 #[test]
344 fn resolve_validates_overrides() {
345 let m = spec_manifest();
346 let mut ov = Map::new();
347 ov.insert("jaccard".into(), json!(1.5));
348 assert!(
349 m.resolve_params(&ov).is_err(),
350 "out-of-range float rejected"
351 );
352
353 let mut ok = Map::new();
354 ok.insert("jaccard".into(), json!(0.95));
355 assert_eq!(m.resolve_params(&ok).unwrap().get_float("jaccard"), 0.95);
356 }
357
358 #[test]
359 fn resolve_rejects_unknown_keys() {
360 let mut ov = Map::new();
361 ov.insert("bogus".into(), json!(1));
362 assert!(spec_manifest().resolve_params(&ov).is_err());
363 }
364}