1use crate::classify::classify;
8use crate::parse::Artifact;
9use crate::pycompat::first_nonempty_line;
10use crate::relationships::corpus_items;
11use crate::spec::{spec_for, ArtifactSpec, RELATIONSHIP_SECTIONS};
12use crate::validate::validate;
13
14pub struct FeatureStat {
16 pub path: String,
17 pub name: String,
18 pub valid: bool,
19 pub error_codes: Vec<String>,
20 pub requirements: usize,
21 pub success_metrics: usize,
22 pub risks: usize,
23}
24
25pub struct DecisionStat {
27 pub path: String,
28 pub name: String,
29 pub status: Option<String>,
30 pub category: Option<String>,
31}
32
33pub struct ValidityStat {
35 pub path: String,
36 pub name: String,
37 pub valid: bool,
38 pub error_codes: Vec<String>,
39}
40
41pub struct UnrecognizedStat {
43 pub path: String,
44 pub name: String,
45 pub confidence: f64,
46}
47
48pub struct PortfolioStats {
49 pub directory: String,
50 pub features: Vec<FeatureStat>,
51 pub decisions: Vec<DecisionStat>,
52 pub roadmaps: Vec<ValidityStat>,
53 pub prompts: Vec<ValidityStat>,
54 pub designs: Vec<ValidityStat>,
55 pub unrecognized: Vec<UnrecognizedStat>,
56 pub relationship_counts: Vec<(String, usize)>,
58}
59
60impl PortfolioStats {
61 pub fn files_found(&self) -> usize {
62 self.features.len()
63 }
64 pub fn valid_features(&self) -> usize {
65 self.features.iter().filter(|f| f.valid).count()
66 }
67 pub fn invalid_features(&self) -> usize {
68 self.features.iter().filter(|f| !f.valid).count()
69 }
70 pub fn total_requirements(&self) -> usize {
71 self.features.iter().map(|f| f.requirements).sum()
72 }
73 pub fn total_metrics(&self) -> usize {
74 self.features.iter().map(|f| f.success_metrics).sum()
75 }
76 pub fn total_risks(&self) -> usize {
77 self.features.iter().map(|f| f.risks).sum()
78 }
79 pub fn missing_metrics(&self) -> Vec<&str> {
81 self.features
82 .iter()
83 .filter(|f| f.success_metrics == 0)
84 .map(|f| f.name.as_str())
85 .collect()
86 }
87 pub fn missing_risks(&self) -> Vec<&str> {
88 self.features
89 .iter()
90 .filter(|f| f.risks == 0)
91 .map(|f| f.name.as_str())
92 .collect()
93 }
94 pub fn average_requirements(&self) -> f64 {
95 if self.features.is_empty() {
96 return 0.0;
97 }
98 self.total_requirements() as f64 / self.files_found() as f64
99 }
100 pub fn largest_feature(&self) -> Option<&FeatureStat> {
102 self.features.iter().reduce(|best, f| {
103 match f.requirements.cmp(&best.requirements) {
107 std::cmp::Ordering::Greater => f,
108 std::cmp::Ordering::Less => best,
109 std::cmp::Ordering::Equal => {
110 if neg_name_gt(&f.name, &best.name) {
111 f
112 } else {
113 best
114 }
115 }
116 }
117 })
118 }
119 pub fn requirements_by_feature(&self) -> Vec<&FeatureStat> {
121 let mut out: Vec<&FeatureStat> = self.features.iter().collect();
122 out.sort_by(|a, b| {
123 b.requirements
124 .cmp(&a.requirements)
125 .then_with(|| a.name.cmp(&b.name))
126 });
127 out
128 }
129 pub fn invalid(&self) -> Vec<&FeatureStat> {
130 self.features.iter().filter(|f| !f.valid).collect()
131 }
132 pub fn decision_count(&self) -> usize {
133 self.decisions.len()
134 }
135 pub fn decision_status_counts(&self) -> Vec<(String, usize)> {
136 bucket(&self.decisions, |d| d.status.as_deref(), "status")
137 }
138 pub fn decision_category_counts(&self) -> Vec<(String, usize)> {
139 bucket(&self.decisions, |d| d.category.as_deref(), "category")
140 }
141 pub fn roadmap_count(&self) -> usize {
142 self.roadmaps.len()
143 }
144 pub fn valid_roadmaps(&self) -> usize {
145 self.roadmaps.iter().filter(|r| r.valid).count()
146 }
147 pub fn invalid_roadmaps(&self) -> Vec<&ValidityStat> {
148 self.roadmaps.iter().filter(|r| !r.valid).collect()
149 }
150 pub fn prompt_count(&self) -> usize {
151 self.prompts.len()
152 }
153 pub fn valid_prompts(&self) -> usize {
154 self.prompts.iter().filter(|p| p.valid).count()
155 }
156 pub fn invalid_prompts(&self) -> Vec<&ValidityStat> {
157 self.prompts.iter().filter(|p| !p.valid).collect()
158 }
159 pub fn design_count(&self) -> usize {
160 self.designs.len()
161 }
162 pub fn valid_designs(&self) -> usize {
163 self.designs.iter().filter(|d| d.valid).count()
164 }
165 pub fn invalid_designs(&self) -> Vec<&ValidityStat> {
166 self.designs.iter().filter(|d| !d.valid).collect()
167 }
168 pub fn unrecognized_count(&self) -> usize {
169 self.unrecognized.len()
170 }
171 pub fn total_artifacts(&self) -> usize {
172 self.files_found()
173 + self.decision_count()
174 + self.roadmap_count()
175 + self.prompt_count()
176 + self.design_count()
177 }
178 pub fn is_empty(&self) -> bool {
179 self.total_artifacts() == 0 && self.unrecognized_count() == 0
180 }
181 pub fn has_meaningful_content(&self) -> bool {
182 self.valid_features() > 0
183 || self.decision_count() > 0
184 || self.valid_roadmaps() > 0
185 || self.valid_prompts() > 0
186 || self.valid_designs() > 0
187 }
188}
189
190fn neg_name_gt(a: &str, b: &str) -> bool {
196 let mut ai = a.chars();
197 let mut bi = b.chars();
198 loop {
199 match (ai.next(), bi.next()) {
200 (Some(ca), Some(cb)) => {
201 if ca != cb {
202 return (ca as u32) < (cb as u32);
204 }
205 }
206 (None, Some(_)) => return false,
210 (Some(_), None) => return true,
211 (None, None) => return false,
212 }
213 }
214}
215
216fn bucket<T>(
219 items: &[T],
220 get: impl Fn(&T) -> Option<&str>,
221 metadata_key: &str,
222) -> Vec<(String, usize)> {
223 let spec = spec_for("decision");
224 let order: &[String] = spec
225 .and_then(|s| s.metadata.iter().find(|(k, _)| k == metadata_key))
226 .map(|(_, v)| v.as_slice())
227 .unwrap_or(&[]);
228 let mut counts: Vec<(String, usize)> = Vec::new();
230 for item in items {
231 if let Some(value) = get(item) {
232 if value.is_empty() {
233 continue;
234 }
235 match counts.iter_mut().find(|(k, _)| k == value) {
236 Some((_, c)) => *c += 1,
237 None => counts.push((value.to_string(), 1)),
238 }
239 }
240 }
241 let mut ordered: Vec<(String, usize)> = Vec::new();
242 for v in order {
243 if let Some((_, c)) = counts.iter().find(|(k, _)| k == v) {
244 ordered.push((v.clone(), *c));
245 }
246 }
247 let mut remaining: Vec<&(String, usize)> = counts
249 .iter()
250 .filter(|(k, _)| !ordered.iter().any(|(ok, _)| ok == k))
251 .collect();
252 remaining.sort_by(|a, b| a.0.cmp(&b.0));
253 for (k, c) in remaining {
254 ordered.push((k.clone(), *c));
255 }
256 ordered
257}
258
259fn artifact_name(artifact: &Artifact, path: &str) -> String {
261 match &artifact.product.title {
262 Some(t) if !t.is_empty() => t.clone(),
263 _ => crate::identity::path_stem(path),
264 }
265}
266
267fn canonical_value(raw: &str, allowed: &[String]) -> String {
270 crate::spec::canonical_value(first_nonempty_line(raw), allowed)
271}
272
273fn error_codes(artifact: &Artifact, artifact_type: &str) -> Vec<String> {
276 validate(artifact, None, Some(artifact_type))
277 .into_iter()
278 .filter(|i| i.severity == "error")
279 .map(|i| i.code)
280 .collect()
281}
282
283fn present_relationship_sections(artifact: &Artifact, spec: &ArtifactSpec) -> Vec<String> {
286 let mut present = Vec::new();
287 for section in &spec.optional {
288 if !RELATIONSHIP_SECTIONS.iter().any(|(name, _)| name == section) {
289 continue;
290 }
291 if let Some(body) = artifact.section(section) {
292 if !body.is_empty() && !crate::relationships::parse_references(body).is_empty() {
293 present.push(section.clone());
294 }
295 }
296 }
297 present
298}
299
300fn decision_metadata(
302 artifact: &Artifact,
303 spec: &ArtifactSpec,
304) -> (Option<String>, Option<String>) {
305 let mut status = None;
306 let mut category = None;
307 for (field_name, allowed) in &spec.metadata {
308 if let Some(body) = artifact.section(field_name) {
309 if !body.is_empty() {
310 let value = canonical_value(body, allowed);
311 match field_name.as_str() {
312 "status" => status = Some(value),
313 "category" => category = Some(value),
314 _ => {}
315 }
316 }
317 }
318 }
319 (status, category)
320}
321
322pub fn collect_stats(directory: &str) -> PortfolioStats {
324 let mut stats = PortfolioStats {
325 directory: directory.to_string(),
326 features: Vec::new(),
327 decisions: Vec::new(),
328 roadmaps: Vec::new(),
329 prompts: Vec::new(),
330 designs: Vec::new(),
331 unrecognized: Vec::new(),
332 relationship_counts: Vec::new(),
333 };
334 let mut rel_counts: Vec<(String, usize)> = Vec::new();
337
338 for item in corpus_items(directory, true) {
339 let artifact = &item.artifact;
340 let path = &item.path;
341 let name = artifact_name(artifact, path);
342 let classification = classify(artifact);
343 let type_name = classification.artifact_type.as_str();
344 let spec = spec_for(type_name);
345
346 if let Some(spec) = spec {
347 for section in present_relationship_sections(artifact, spec) {
348 match rel_counts.iter_mut().find(|(k, _)| *k == section) {
349 Some((_, c)) => *c += 1,
350 None => rel_counts.push((section, 1)),
351 }
352 }
353 }
354
355 match type_name {
356 "decision" => {
357 let (status, category) =
358 decision_metadata(artifact, spec.expect("decision spec"));
359 stats.decisions.push(DecisionStat {
360 path: path.clone(),
361 name,
362 status,
363 category,
364 });
365 }
366 "roadmap" => {
367 let codes = error_codes(artifact, type_name);
368 stats.roadmaps.push(ValidityStat {
369 path: path.clone(),
370 name,
371 valid: codes.is_empty(),
372 error_codes: codes,
373 });
374 }
375 "prompt" => {
376 let codes = error_codes(artifact, type_name);
377 stats.prompts.push(ValidityStat {
378 path: path.clone(),
379 name,
380 valid: codes.is_empty(),
381 error_codes: codes,
382 });
383 }
384 "design" => {
385 let codes = error_codes(artifact, type_name);
386 stats.designs.push(ValidityStat {
387 path: path.clone(),
388 name,
389 valid: codes.is_empty(),
390 error_codes: codes,
391 });
392 }
393 "unknown" => {
394 stats.unrecognized.push(UnrecognizedStat {
395 path: path.clone(),
396 name,
397 confidence: classification.confidence,
398 });
399 }
400 _ => {
401 let codes = error_codes(artifact, type_name);
402 stats.features.push(FeatureStat {
403 path: path.clone(),
404 name,
405 valid: codes.is_empty(),
406 error_codes: codes,
407 requirements: artifact.product.requirements.len(),
408 success_metrics: artifact.product.success_metrics.len(),
409 risks: artifact.product.risks.len(),
410 });
411 }
412 }
413 }
414
415 for (space_name, _) in RELATIONSHIP_SECTIONS.iter() {
417 if let Some((_, c)) = rel_counts.iter().find(|(k, _)| k == space_name) {
418 stats.relationship_counts.push((space_name.to_string(), *c));
419 }
420 }
421 stats
422}
423
424#[cfg(test)]
425mod tests {
426 use super::neg_name_gt;
427
428 #[test]
432 fn neg_name_prefix_tie_prefers_longer() {
433 assert!(neg_name_gt("Feature With Broken Ref", "Feature"));
434 assert!(!neg_name_gt("Feature", "Feature With Broken Ref"));
435 assert!(neg_name_gt("Alpha", "Beta"));
437 assert!(!neg_name_gt("Beta", "Alpha"));
438 assert!(!neg_name_gt("Same", "Same"));
439 }
440}