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 "tbd",
265 "todo",
266 "n/a",
267 "not applicable",
268 ];
269
270 !s.is_empty() && !placeholders.contains(&s.to_lowercase().as_str())
271}
272
273pub fn tier_name(score: u32) -> &'static str {
275 if score >= 100 {
276 "TROPHY"
277 } else if score >= 99 {
278 "GOLD"
279 } else if score >= 95 {
280 "SILVER"
281 } else if score >= 85 {
282 "BRONZE"
283 } else if score >= 70 {
284 "GREEN"
285 } else if score >= 55 {
286 "YELLOW"
287 } else if score >= 1 {
288 "RED"
289 } else {
290 "WHITE"
291 }
292}
293
294pub fn tier_symbol(score: u32) -> &'static str {
297 if score >= 100 {
298 "🏆"
299 } else if score >= 99 {
300 "★"
301 } else if score >= 95 {
302 "◆"
303 } else if score >= 85 {
304 "◇"
305 } else if score >= 55 {
306 "●"
309 } else if score >= 1 {
310 "○"
311 } else {
312 "♡"
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 const CLI_TROPHY: &str = r#"
324project:
325 name: my-cli
326 goal: Ship fast
327 main_language: Rust
328human_context:
329 who: Devs
330 what: CLI tool
331 why: Speed
332 where: crates.io
333 when: Now
334 how: Cargo
335stack:
336 framework: slotignored
337 css: slotignored
338 ui_library: slotignored
339 state: slotignored
340 backend: slotignored
341 api: slotignored
342 runtime: slotignored
343 db: slotignored
344 connection: slotignored
345 hosting: GitHub
346 build: cargo
347 cicd: GitHub Actions
348 monorepo_tool: slotignored
349 pkg_manager: slotignored
350 workspaces: slotignored
351 admin: slotignored
352 cache: slotignored
353 search: slotignored
354 storage: slotignored
355monorepo:
356 packages_count: slotignored
357 build_orchestrator: slotignored
358 versioning_strategy: slotignored
359 shared_configs: slotignored
360 remote_cache: slotignored
361"#;
362
363 #[test]
364 fn empty_yaml_scores_zero_against_33() {
365 let result = score("empty: true").unwrap();
366 assert_eq!(result.score, 0);
367 assert_eq!(result.populated, 0);
368 assert_eq!(result.total, 33);
369 assert_eq!(result.active, 33); assert_eq!(result.tier, "WHITE");
371 }
372
373 #[test]
374 fn invalid_yaml_returns_error() {
375 assert!(score("invalid: yaml: [").is_err());
376 }
377
378 #[test]
379 fn always_scores_against_33() {
380 for yaml in [
382 "project:\n name: x\n",
383 "app_type: enterprise\nproject:\n name: y\n",
384 ] {
385 let result = score(yaml).unwrap();
386 assert_eq!(result.total, 33);
387 assert_eq!(result.slots.len(), 33);
388 }
389 }
390
391 #[test]
392 fn slotignored_sets_the_active_denominator() {
393 let result = score(CLI_TROPHY).unwrap();
396 assert_eq!(result.ignored, 21);
397 assert_eq!(result.active, 12);
398 assert_eq!(result.populated, 12);
399 assert_eq!(result.score, 100);
400 assert_eq!(result.tier, "TROPHY");
401 }
402
403 #[test]
404 fn missing_slot_counts_against_score_unlike_slotignored() {
405 let yaml = CLI_TROPHY.replace(" build: cargo\n", "");
409 let result = score(&yaml).unwrap();
410 assert_eq!(result.ignored, 21);
411 assert_eq!(result.active, 12); assert_eq!(result.populated, 11);
413 assert!(result.score < 100); }
415
416 #[test]
417 fn legacy_aliases_score() {
418 let yaml = r#"
419project:
420 name: x
421stack:
422 frontend: Svelte
423 css_framework: Tailwind
424 state_management: Stores
425 api_type: REST
426 database: Postgres
427 package_manager: pnpm
428"#;
429 let result = score(yaml).unwrap();
430 let populated: Vec<&str> = result
431 .slots
432 .iter()
433 .filter(|(_, s)| *s == SlotState::Populated)
434 .map(|(n, _)| n.as_str())
435 .collect();
436 assert!(populated.contains(&"stack.framework"));
437 assert!(populated.contains(&"stack.css"));
438 assert!(populated.contains(&"stack.state"));
439 assert!(populated.contains(&"stack.api"));
440 assert!(populated.contains(&"stack.db"));
441 assert!(populated.contains(&"stack.pkg_manager"));
442 }
443
444 #[test]
445 fn placeholders_rejected() {
446 let yaml = "project:\n name: unknown\n goal: n/a\n";
447 let result = score(yaml).unwrap();
448 assert_eq!(result.populated, 0);
449 }
450
451 #[test]
452 fn tier_ladder_canonical() {
453 assert_eq!(tier_name(100), "TROPHY");
454 assert_eq!(tier_name(99), "GOLD");
455 assert_eq!(tier_name(95), "SILVER");
456 assert_eq!(tier_name(85), "BRONZE");
457 assert_eq!(tier_name(70), "GREEN");
458 assert_eq!(tier_name(55), "YELLOW");
459 assert_eq!(tier_name(1), "RED");
460 assert_eq!(tier_name(0), "WHITE");
461 assert_eq!(tier_symbol(100), "🏆");
463 assert_eq!(tier_symbol(99), "★");
464 assert_eq!(tier_symbol(95), "◆");
465 assert_eq!(tier_symbol(85), "◇");
466 assert_eq!(tier_symbol(0), "♡");
467 }
468
469 #[test]
470 fn full_33_all_populated_is_trophy() {
471 let mut yaml = String::from("project:\n name: x\n goal: y\n main_language: Rust\n");
473 yaml.push_str("human_context:\n");
474 for w in ["who", "what", "why", "where", "when", "how"] {
475 yaml.push_str(&format!(" {}: v\n", w));
476 }
477 yaml.push_str("stack:\n");
478 for s in [
479 "framework",
480 "css",
481 "ui_library",
482 "state",
483 "backend",
484 "api",
485 "runtime",
486 "db",
487 "connection",
488 "hosting",
489 "build",
490 "cicd",
491 "monorepo_tool",
492 "pkg_manager",
493 "workspaces",
494 "admin",
495 "cache",
496 "search",
497 "storage",
498 ] {
499 yaml.push_str(&format!(" {}: v\n", s));
500 }
501 yaml.push_str("monorepo:\n");
502 for m in [
503 "packages_count",
504 "build_orchestrator",
505 "versioning_strategy",
506 "shared_configs",
507 "remote_cache",
508 ] {
509 yaml.push_str(&format!(" {}: v\n", m));
510 }
511 let result = score(&yaml).unwrap();
512 assert_eq!(result.populated, 33);
513 assert_eq!(result.ignored, 0);
514 assert_eq!(result.active, 33);
515 assert_eq!(result.score, 100);
516 assert_eq!(result.tier, "TROPHY");
517 }
518
519 #[test]
520 fn to_json_shape() {
521 let json = score("project:\n name: x\n").unwrap().to_json();
522 assert!(json.contains("\"score\":"));
523 assert!(json.contains("\"total\":33"));
524 assert!(json.contains("\"tier\":\"RED\""));
525 assert!(json.contains("\"project.name\":\"populated\""));
526 }
527}