1use serde_yaml_ng::Value;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum SlotState {
25 Empty,
27 Populated,
29 Slotignored,
31}
32
33pub const TOTAL_SLOTS: u32 = 33;
36
37#[derive(Debug, Clone)]
39pub struct Mk4Result {
40 pub score: u32,
42 pub tier: String,
44 pub populated: u32,
46 pub ignored: u32,
48 pub active: u32,
50 pub total: u32,
52 pub slots: Vec<(String, SlotState)>,
54}
55
56impl Mk4Result {
57 pub fn to_json(&self) -> String {
59 let mut slots_json = String::from("{");
60 for (i, (name, state)) in self.slots.iter().enumerate() {
61 if i > 0 {
62 slots_json.push(',');
63 }
64 let state_str = match state {
65 SlotState::Populated => "populated",
66 SlotState::Empty => "empty",
67 SlotState::Slotignored => "slotignored",
68 };
69 slots_json.push_str(&format!("\"{}\":\"{}\"", name, state_str));
70 }
71 slots_json.push('}');
72
73 format!(
74 r#"{{"score":{},"tier":"{}","populated":{},"empty":{},"ignored":{},"active":{},"total":{},"slots":{}}}"#,
75 self.score,
76 self.tier,
77 self.populated,
78 self.total - self.populated - self.ignored,
79 self.ignored,
80 self.active,
81 self.total,
82 slots_json
83 )
84 }
85}
86
87pub fn score(yaml: &str) -> Result<Mk4Result, String> {
89 Mk4Scorer::new().calculate(yaml)
90}
91
92#[derive(Debug, Default)]
94pub struct Mk4Scorer;
95
96impl Mk4Scorer {
97 pub fn new() -> Self {
99 Self
100 }
101
102 pub fn calculate(&self, yaml: &str) -> Result<Mk4Result, String> {
104 let doc: Value =
105 serde_yaml_ng::from_str(yaml).map_err(|e| format!("YAML parse error: {}", e))?;
106
107 let mut populated: u32 = 0;
108 let mut ignored: u32 = 0;
109
110 let mut slots: Vec<(String, SlotState)> = Vec::with_capacity(SLOTS.len());
111 for slot_path in SLOTS {
112 let state = slot_state(&doc, slot_path);
113 match state {
114 SlotState::Populated => populated += 1,
115 SlotState::Slotignored => ignored += 1,
116 SlotState::Empty => (),
117 }
118 slots.push((slot_path.to_string(), state));
119 }
120
121 let active = TOTAL_SLOTS - ignored;
122 let score = if active == 0 {
123 0.0
124 } else {
125 (populated as f64 / active as f64) * 100.0
126 };
127 let score_rounded = score.round() as u32;
128
129 Ok(Mk4Result {
130 score: score_rounded,
131 tier: tier_name(score_rounded).to_string(),
132 populated,
133 ignored,
134 active,
135 total: TOTAL_SLOTS,
136 slots,
137 })
138 }
139}
140
141const SLOTS: &[&str] = &[
145 "project.name",
147 "project.goal",
148 "project.main_language",
149 "human_context.who",
151 "human_context.what",
152 "human_context.why",
153 "human_context.where",
154 "human_context.when",
155 "human_context.how",
156 "stack.framework",
158 "stack.css",
159 "stack.ui_library",
160 "stack.state",
161 "stack.backend",
163 "stack.api",
164 "stack.runtime",
165 "stack.db",
166 "stack.connection",
167 "stack.hosting",
169 "stack.build",
170 "stack.cicd",
171 "stack.monorepo_tool",
173 "stack.pkg_manager",
174 "stack.workspaces",
175 "monorepo.packages_count",
176 "monorepo.build_orchestrator",
177 "stack.admin",
179 "stack.cache",
180 "stack.search",
181 "stack.storage",
182 "monorepo.versioning_strategy",
184 "monorepo.shared_configs",
185 "monorepo.remote_cache",
186];
187
188fn legacy_alias_for(canonical: &str) -> Option<&'static str> {
191 match canonical {
192 "stack.framework" => Some("stack.frontend"),
193 "stack.css" => Some("stack.css_framework"),
194 "stack.state" => Some("stack.state_management"),
195 "stack.api" => Some("stack.api_type"),
196 "stack.db" => Some("stack.database"),
197 "stack.pkg_manager" => Some("stack.package_manager"),
198 _ => None,
199 }
200}
201
202fn slot_state(doc: &Value, path: &str) -> SlotState {
205 let state = walk_path_state(doc, path);
206 if matches!(state, SlotState::Empty) {
207 if let Some(legacy) = legacy_alias_for(path) {
208 return walk_path_state(doc, legacy);
209 }
210 }
211 state
212}
213
214fn walk_path_state(doc: &Value, path: &str) -> SlotState {
216 let mut current = doc;
217 for part in path.split('.') {
218 if let Some(next) = current.get(Value::String(part.to_string())) {
219 current = next;
220 } else {
221 return SlotState::Empty;
222 }
223 }
224
225 match current {
226 Value::String(s) => {
227 let s = s.trim();
228 if s == "slotignored" {
229 SlotState::Slotignored
230 } else if is_valid_populated_string(s) {
231 SlotState::Populated
232 } else {
233 SlotState::Empty
234 }
235 }
236 Value::Number(_) | Value::Bool(_) => SlotState::Populated,
237 Value::Sequence(seq) => {
238 if !seq.is_empty() {
239 SlotState::Populated
240 } else {
241 SlotState::Empty
242 }
243 }
244 Value::Mapping(map) => {
245 if !map.is_empty() {
246 SlotState::Populated
247 } else {
248 SlotState::Empty
249 }
250 }
251 _ => SlotState::Empty,
252 }
253}
254
255fn is_valid_populated_string(s: &str) -> bool {
257 let placeholders = [
258 "describe your project goal",
259 "development teams",
260 "cloud platform",
261 "null",
262 "none",
263 "unknown",
264 "n/a",
265 "not applicable",
266 ];
267
268 !s.is_empty() && !placeholders.contains(&s.to_lowercase().as_str())
269}
270
271pub fn tier_name(score: u32) -> &'static str {
273 if score >= 100 {
274 "TROPHY"
275 } else if score >= 99 {
276 "GOLD"
277 } else if score >= 95 {
278 "SILVER"
279 } else if score >= 85 {
280 "BRONZE"
281 } else if score >= 70 {
282 "GREEN"
283 } else if score >= 55 {
284 "YELLOW"
285 } else if score >= 1 {
286 "RED"
287 } else {
288 "WHITE"
289 }
290}
291
292pub fn tier_symbol(score: u32) -> &'static str {
295 if score >= 100 {
296 "🏆"
297 } else if score >= 99 {
298 "★"
299 } else if score >= 95 {
300 "◆"
301 } else if score >= 85 {
302 "◇"
303 } else if score >= 55 {
304 "●"
307 } else if score >= 1 {
308 "○"
309 } else {
310 "♡"
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 const CLI_TROPHY: &str = r#"
322project:
323 name: my-cli
324 goal: Ship fast
325 main_language: Rust
326human_context:
327 who: Devs
328 what: CLI tool
329 why: Speed
330 where: crates.io
331 when: Now
332 how: Cargo
333stack:
334 framework: slotignored
335 css: slotignored
336 ui_library: slotignored
337 state: slotignored
338 backend: slotignored
339 api: slotignored
340 runtime: slotignored
341 db: slotignored
342 connection: slotignored
343 hosting: GitHub
344 build: cargo
345 cicd: GitHub Actions
346 monorepo_tool: slotignored
347 pkg_manager: slotignored
348 workspaces: slotignored
349 admin: slotignored
350 cache: slotignored
351 search: slotignored
352 storage: slotignored
353monorepo:
354 packages_count: slotignored
355 build_orchestrator: slotignored
356 versioning_strategy: slotignored
357 shared_configs: slotignored
358 remote_cache: slotignored
359"#;
360
361 #[test]
362 fn empty_yaml_scores_zero_against_33() {
363 let result = score("empty: true").unwrap();
364 assert_eq!(result.score, 0);
365 assert_eq!(result.populated, 0);
366 assert_eq!(result.total, 33);
367 assert_eq!(result.active, 33); assert_eq!(result.tier, "WHITE");
369 }
370
371 #[test]
372 fn invalid_yaml_returns_error() {
373 assert!(score("invalid: yaml: [").is_err());
374 }
375
376 #[test]
377 fn always_scores_against_33() {
378 for yaml in [
380 "project:\n name: x\n",
381 "app_type: enterprise\nproject:\n name: y\n",
382 ] {
383 let result = score(yaml).unwrap();
384 assert_eq!(result.total, 33);
385 assert_eq!(result.slots.len(), 33);
386 }
387 }
388
389 #[test]
390 fn slotignored_sets_the_active_denominator() {
391 let result = score(CLI_TROPHY).unwrap();
394 assert_eq!(result.ignored, 21);
395 assert_eq!(result.active, 12);
396 assert_eq!(result.populated, 12);
397 assert_eq!(result.score, 100);
398 assert_eq!(result.tier, "TROPHY");
399 }
400
401 #[test]
402 fn missing_slot_counts_against_score_unlike_slotignored() {
403 let yaml = CLI_TROPHY.replace(" build: cargo\n", "");
407 let result = score(&yaml).unwrap();
408 assert_eq!(result.ignored, 21);
409 assert_eq!(result.active, 12); assert_eq!(result.populated, 11);
411 assert!(result.score < 100); }
413
414 #[test]
415 fn legacy_aliases_score() {
416 let yaml = r#"
417project:
418 name: x
419stack:
420 frontend: Svelte
421 css_framework: Tailwind
422 state_management: Stores
423 api_type: REST
424 database: Postgres
425 package_manager: pnpm
426"#;
427 let result = score(yaml).unwrap();
428 let populated: Vec<&str> = result
429 .slots
430 .iter()
431 .filter(|(_, s)| *s == SlotState::Populated)
432 .map(|(n, _)| n.as_str())
433 .collect();
434 assert!(populated.contains(&"stack.framework"));
435 assert!(populated.contains(&"stack.css"));
436 assert!(populated.contains(&"stack.state"));
437 assert!(populated.contains(&"stack.api"));
438 assert!(populated.contains(&"stack.db"));
439 assert!(populated.contains(&"stack.pkg_manager"));
440 }
441
442 #[test]
443 fn placeholders_rejected() {
444 let yaml = "project:\n name: unknown\n goal: n/a\n";
445 let result = score(yaml).unwrap();
446 assert_eq!(result.populated, 0);
447 }
448
449 #[test]
450 fn tier_ladder_canonical() {
451 assert_eq!(tier_name(100), "TROPHY");
452 assert_eq!(tier_name(99), "GOLD");
453 assert_eq!(tier_name(95), "SILVER");
454 assert_eq!(tier_name(85), "BRONZE");
455 assert_eq!(tier_name(70), "GREEN");
456 assert_eq!(tier_name(55), "YELLOW");
457 assert_eq!(tier_name(1), "RED");
458 assert_eq!(tier_name(0), "WHITE");
459 assert_eq!(tier_symbol(100), "🏆");
461 assert_eq!(tier_symbol(99), "★");
462 assert_eq!(tier_symbol(95), "◆");
463 assert_eq!(tier_symbol(85), "◇");
464 assert_eq!(tier_symbol(0), "♡");
465 }
466
467 #[test]
468 fn full_33_all_populated_is_trophy() {
469 let mut yaml = String::from("project:\n name: x\n goal: y\n main_language: Rust\n");
471 yaml.push_str("human_context:\n");
472 for w in ["who", "what", "why", "where", "when", "how"] {
473 yaml.push_str(&format!(" {}: v\n", w));
474 }
475 yaml.push_str("stack:\n");
476 for s in [
477 "framework",
478 "css",
479 "ui_library",
480 "state",
481 "backend",
482 "api",
483 "runtime",
484 "db",
485 "connection",
486 "hosting",
487 "build",
488 "cicd",
489 "monorepo_tool",
490 "pkg_manager",
491 "workspaces",
492 "admin",
493 "cache",
494 "search",
495 "storage",
496 ] {
497 yaml.push_str(&format!(" {}: v\n", s));
498 }
499 yaml.push_str("monorepo:\n");
500 for m in [
501 "packages_count",
502 "build_orchestrator",
503 "versioning_strategy",
504 "shared_configs",
505 "remote_cache",
506 ] {
507 yaml.push_str(&format!(" {}: v\n", m));
508 }
509 let result = score(&yaml).unwrap();
510 assert_eq!(result.populated, 33);
511 assert_eq!(result.ignored, 0);
512 assert_eq!(result.active, 33);
513 assert_eq!(result.score, 100);
514 assert_eq!(result.tier, "TROPHY");
515 }
516
517 #[test]
518 fn to_json_shape() {
519 let json = score("project:\n name: x\n").unwrap().to_json();
520 assert!(json.contains("\"score\":"));
521 assert!(json.contains("\"total\":33"));
522 assert!(json.contains("\"tier\":\"RED\""));
523 assert!(json.contains("\"project.name\":\"populated\""));
524 }
525}