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