1use std::fmt::Write;
6use std::io::IsTerminal;
7use std::sync::OnceLock;
8
9use serde_json::{json, Map, Value};
10
11use crate::classify::{TypeScore, CONFIDENCE_THRESHOLD};
12use crate::commands::{DirectoryValidation, StdinCorpusValidation, STATUS_INVALID};
13use crate::diff::Diff;
14use crate::doctor::{DoctorFinding, DoctorReport};
15use crate::export::{CorpusExport, DocumentsExport, GraphExport};
16use crate::gate::{GateFinding, GateReport};
17use crate::improve::ImprovementResult;
18use crate::inspect::{DirectoryInspection, InspectionResult};
19use crate::markdown::Requirement;
20use crate::parse::Issue;
21use crate::pycompat::{
22 py_float_repr, py_format_1f, py_format_percent0, py_repr_str, py_round, py_rstrip,
23};
24use crate::coverage::{CoverageReport, GAP_UNAPPLIED, GAP_UNSCHEDULED, GAP_UNSCOPED};
25use crate::portfolio::PortfolioSummary;
26use crate::pyjson::{dumps_compact, dumps_indent2, dumps_indent2_no_ascii, py_float};
27use crate::retrieve::{scope_lookup_value, ScopeLookupResult};
28use crate::relationships::{
29 RelationshipIssue, RelationshipReport, RelationshipValidation, ISSUE_DUPLICATE_IDENTIFIER,
30 ISSUE_EDGE_UNSUPPORTED, ISSUE_RELATIONSHIP_CYCLE, ISSUE_SCOPE_TARGET_NOT_FOUND,
31 ISSUE_SELF_REFERENCE, ISSUE_TARGET_AMBIGUOUS, ISSUE_TARGET_NOT_FOUND, ISSUE_TARGET_SUPERSEDED,
32 ISSUE_TARGET_TYPE_MISMATCH,
33};
34use crate::resolve::{
35 Evidence, Recency, ResolutionResult, ResolvedArtifact, SearchResult, OUTCOME_RESOLVED,
36};
37use crate::review::{ReviewIssue, ReviewReport};
38use crate::sentry::SentryReport;
39use crate::spec::{snake as spec_snake, spec_for, specs, ArtifactSpec};
40use crate::stats::PortfolioStats;
41use crate::validate::py_title;
42
43pub fn rac_version() -> String {
46 std::env::var("DECIDED_RS_VERSION")
52 .ok()
53 .or_else(|| option_env!("DECIDED_RS_VERSION").map(str::to_string))
54 .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string())
55}
56
57fn use_color() -> bool {
60 static USE_COLOR: OnceLock<bool> = OnceLock::new();
61 *USE_COLOR.get_or_init(|| std::io::stdout().is_terminal())
62}
63
64fn c(text: &str, code: &str) -> String {
65 if !use_color() {
66 text.to_string()
67 } else {
68 format!("\u{1b}[{code}m{text}\u{1b}[0m")
69 }
70}
71
72fn green(t: &str) -> String {
73 c(t, "32")
74}
75
76fn red(t: &str) -> String {
77 c(t, "31")
78}
79
80fn yellow(t: &str) -> String {
81 c(t, "33")
82}
83
84fn bold(t: &str) -> String {
85 c(t, "1")
86}
87
88fn loc(file: &str, line: Option<i64>) -> String {
89 match line {
90 Some(l) => format!("{file}:{l}"),
91 None => file.to_string(),
92 }
93}
94
95fn ljust(s: &str, w: usize) -> String {
97 let n = s.chars().count();
98 if n >= w {
99 s.to_string()
100 } else {
101 format!("{}{}", s, " ".repeat(w - n))
102 }
103}
104
105fn pass_fail_header(ok: bool, file: &str) -> String {
107 if ok {
108 green(&bold(&format!("PASS {file}")))
109 } else {
110 red(&bold(&format!("FAIL {file}")))
111 }
112}
113
114fn push_issue_lines(lines: &mut Vec<String>, severity: &str, code: &str, location: &str, message: &str) {
118 if severity == "error" {
119 lines.push(format!(" {} [{}] {}", red("error"), code, location));
120 } else {
121 lines.push(format!(" {} [{}] {}", yellow("warning"), code, location));
122 }
123 lines.push(format!(" {message}"));
124}
125
126fn issue_value(i: &Issue) -> Value {
129 let mut m = Map::new();
130 m.insert("severity".into(), json!(i.severity));
131 m.insert("code".into(), json!(i.code));
132 m.insert("message".into(), json!(i.message));
133 m.insert("line".into(), json!(i.line));
134 Value::Object(m)
135}
136
137pub fn render_validation_human(source_path: &str, issues: &[Issue]) -> String {
138 let errors: Vec<&Issue> = issues.iter().filter(|i| i.severity == "error").collect();
139 let warnings: Vec<&Issue> = issues.iter().filter(|i| i.severity == "warning").collect();
140 let file = if source_path.is_empty() {
141 "<input>"
142 } else {
143 source_path
144 };
145
146 let mut lines: Vec<String> = Vec::new();
147 lines.push(pass_fail_header(errors.is_empty(), file));
148
149 for issue in errors.iter().chain(&warnings) {
150 push_issue_lines(
151 &mut lines,
152 issue.severity,
153 &issue.code,
154 &loc(file, issue.line),
155 &issue.message,
156 );
157 }
158
159 lines.push(String::new());
160 lines.push(format!(
161 "{} error(s), {} warning(s).",
162 errors.len(),
163 warnings.len()
164 ));
165 lines.join("\n")
166}
167
168pub fn render_validation_json(source_path: &str, issues: &[Issue]) -> String {
169 let errors: Vec<Value> = issues
170 .iter()
171 .filter(|i| i.severity == "error")
172 .map(issue_value)
173 .collect();
174 let warnings: Vec<Value> = issues
175 .iter()
176 .filter(|i| i.severity == "warning")
177 .map(issue_value)
178 .collect();
179 let mut payload = Map::new();
180 payload.insert("schema_version".into(), json!("1"));
181 payload.insert(
182 "file".into(),
183 if source_path.is_empty() {
184 Value::Null
185 } else {
186 json!(source_path)
187 },
188 );
189 payload.insert("valid".into(), json!(errors.is_empty()));
190 payload.insert("errors".into(), Value::Array(errors));
191 payload.insert("warnings".into(), Value::Array(warnings));
192 dumps_indent2(&Value::Object(payload))
193}
194
195fn relationship_label(snake_section: &str) -> String {
199 py_title(&snake_section.replace('_', " "))
200}
201
202fn ref_issue_suffix(code: &str) -> &str {
203 match code {
204 ISSUE_TARGET_NOT_FOUND => "not found",
205 ISSUE_TARGET_AMBIGUOUS => "ambiguous",
206 ISSUE_SELF_REFERENCE => "self-reference",
207 ISSUE_TARGET_SUPERSEDED => "superseded",
208 ISSUE_TARGET_TYPE_MISMATCH => "wrong target type",
209 ISSUE_SCOPE_TARGET_NOT_FOUND => "path not found",
210 other => other,
211 }
212}
213
214pub fn render_stdin_corpus_human(result: &StdinCorpusValidation) -> String {
215 let file = if result.source_path.is_empty() {
216 "<input>"
217 } else {
218 &result.source_path
219 };
220 let errors: Vec<&Issue> = result
221 .structural_issues
222 .iter()
223 .filter(|i| i.severity == "error")
224 .collect();
225 let warnings: Vec<&Issue> = result
226 .structural_issues
227 .iter()
228 .filter(|i| i.severity == "warning")
229 .collect();
230 let rels = &result.relationship_issues;
231
232 let mut lines: Vec<String> = Vec::new();
233 lines.push(pass_fail_header(result.ok(), file));
234
235 for issue in errors.iter().chain(&warnings) {
236 push_issue_lines(
237 &mut lines,
238 issue.severity,
239 &issue.code,
240 &loc(file, issue.line),
241 &issue.message,
242 );
243 }
244
245 if !rels.is_empty() {
246 lines.push(String::new());
247 lines.push(bold("Corpus references"));
248 let mut current_section: Option<&str> = None;
249 for rel in rels {
250 let section = rel.relationship.as_deref();
251 if section != current_section {
252 current_section = section;
253 lines.push(format!(" {}:", relationship_label(section.unwrap_or(""))));
254 }
255 let suffix = ref_issue_suffix(&rel.code);
256 lines.push(red(&format!(
257 " \u{2717} {} {}",
258 rel.target.as_deref().unwrap_or(""),
259 suffix
260 )));
261 }
262 }
263
264 lines.push(String::new());
265 lines.push(format!(
266 "{} error(s), {} warning(s), {} corpus reference finding(s).",
267 errors.len(),
268 warnings.len(),
269 rels.len()
270 ));
271 lines.join("\n")
272}
273
274pub fn render_stdin_corpus_json(result: &StdinCorpusValidation) -> String {
275 let errors: Vec<Value> = result
276 .structural_issues
277 .iter()
278 .filter(|i| i.severity == "error")
279 .map(issue_value)
280 .collect();
281 let warnings: Vec<Value> = result
282 .structural_issues
283 .iter()
284 .filter(|i| i.severity == "warning")
285 .map(issue_value)
286 .collect();
287 let mut payload = Map::new();
288 payload.insert("schema_version".into(), json!("1"));
289 payload.insert(
290 "file".into(),
291 if result.source_path.is_empty() {
292 Value::Null
293 } else {
294 json!(result.source_path)
295 },
296 );
297 payload.insert("valid".into(), json!(result.ok()));
298 payload.insert("errors".into(), Value::Array(errors));
299 payload.insert("warnings".into(), Value::Array(warnings));
300 payload.insert(
301 "relationship_issues".into(),
302 Value::Array(
303 result
304 .relationship_issues
305 .iter()
306 .map(relationship_issue_value)
307 .collect(),
308 ),
309 );
310 dumps_indent2(&Value::Object(payload))
311}
312
313pub fn render_validate_dir_human(result: &DirectoryValidation) -> String {
316 let mut lines: Vec<String> = Vec::new();
317 for f in &result.files {
318 if f.status != STATUS_INVALID {
319 continue;
320 }
321 let display = match spec_for(&f.artifact_type) {
322 Some(spec) => spec.display.clone(),
323 None => f.artifact_type.clone(),
324 };
325 lines.push(format!("{} ({display})", pass_fail_header(false, &f.path)));
326 for issue in &f.issues {
327 if issue.severity != "error" {
328 continue;
329 }
330 push_issue_lines(
331 &mut lines,
332 issue.severity,
333 &issue.code,
334 &loc(&f.path, issue.line),
335 &issue.message,
336 );
337 }
338 lines.push(String::new());
339 }
340
341 if let Some(okf) = &result.okf {
342 if !okf.findings.is_empty() {
343 for finding in &okf.findings {
344 lines.push(format!(
345 "{} (OKF conformance)",
346 pass_fail_header(false, &finding.path)
347 ));
348 push_issue_lines(
349 &mut lines,
350 "error",
351 &finding.code,
352 &finding.path,
353 &finding.message,
354 );
355 lines.push(String::new());
356 }
357 }
358 }
359
360 let skipped = if result.skipped() > 0 {
361 format!(", {} skipped (unknown type)", result.skipped())
362 } else {
363 String::new()
364 };
365 let verdict = if result.ok() { green("PASS") } else { red("FAIL") };
366 let mut summary = format!(
367 "{} {} \u{2014} {} artifact(s) checked: {} valid, {} invalid{}.",
368 verdict,
369 result.directory,
370 result.checked(),
371 result.valid(),
372 result.invalid(),
373 skipped
374 );
375 if let Some(okf) = &result.okf {
376 if okf.ok() {
377 summary.push_str(" OKF v0.2: conformant.");
378 } else {
379 summary.push_str(&format!(
380 " OKF v0.2: {} conformance issue(s).",
381 okf.findings.len()
382 ));
383 }
384 }
385 lines.push(summary);
386 if result.checked() == 0 && result.skipped() == 0 {
387 lines.push(String::new());
388 lines.push("No artifacts yet \u{2014} create your first with: decided quickstart".to_string());
389 }
390 lines.join("\n")
391}
392
393pub fn render_validate_dir_json(result: &DirectoryValidation) -> String {
394 let mut summary = Map::new();
395 summary.insert("total_files".into(), json!(result.files.len()));
396 summary.insert("checked".into(), json!(result.checked()));
397 summary.insert("valid".into(), json!(result.valid()));
398 summary.insert("invalid".into(), json!(result.invalid()));
399 summary.insert("skipped_unknown".into(), json!(result.skipped()));
400
401 let files: Vec<Value> = result
402 .files
403 .iter()
404 .map(|f| {
405 let mut m = Map::new();
406 m.insert("path".into(), json!(f.path));
407 m.insert("artifact_type".into(), json!(f.artifact_type));
408 m.insert("status".into(), json!(f.status));
409 m.insert(
410 "issues".into(),
411 Value::Array(f.issues.iter().map(issue_value).collect()),
412 );
413 Value::Object(m)
414 })
415 .collect();
416
417 let mut payload = Map::new();
418 payload.insert("schema_version".into(), json!("1"));
419 payload.insert("directory".into(), json!(result.directory));
420 payload.insert("recursive".into(), json!(result.recursive));
421 payload.insert("summary".into(), Value::Object(summary));
422 payload.insert("valid".into(), json!(result.ok()));
423 payload.insert("files".into(), Value::Array(files));
424 if let Some(okf) = &result.okf {
425 let findings: Vec<Value> = okf
426 .findings
427 .iter()
428 .map(|f| {
429 let mut m = Map::new();
430 m.insert("code".into(), json!(f.code));
431 m.insert("path".into(), json!(f.path));
432 m.insert("message".into(), json!(f.message));
433 m.insert("severity".into(), json!(f.severity));
434 Value::Object(m)
435 })
436 .collect();
437 let mut o = Map::new();
438 o.insert("conformant".into(), json!(okf.ok()));
439 o.insert("artifacts_checked".into(), json!(okf.artifacts_checked));
440 o.insert("findings".into(), Value::Array(findings));
441 payload.insert("okf".into(), Value::Object(o));
442 }
443 dumps_indent2(&Value::Object(payload))
444}
445
446fn quote_uri(uri: &str) -> String {
451 let mut out = String::with_capacity(uri.len());
452 for b in uri.as_bytes() {
453 let ch = *b as char;
454 if b.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-' | '~' | '/') {
455 out.push(ch);
456 } else {
457 write!(out, "%{b:02X}").unwrap();
458 }
459 }
460 out
461}
462
463fn sarif_level(severity: &str) -> &'static str {
464 match severity {
465 "error" => "error",
466 "warning" => "warning",
467 "info" => "note",
468 _ => "warning",
469 }
470}
471
472struct SarifResult {
473 rule_id: String,
474 level: &'static str,
475 message: String,
476 uri: String,
477 line: Option<i64>,
478}
479
480fn sarif_document(mut results: Vec<SarifResult>) -> String {
481 results.sort_by(|a, b| {
482 a.uri
483 .cmp(&b.uri)
484 .then(a.line.unwrap_or(0).cmp(&b.line.unwrap_or(0)))
485 .then(a.rule_id.cmp(&b.rule_id))
486 .then(a.message.cmp(&b.message))
487 });
488
489 let mut rule_ids: Vec<&str> = results.iter().map(|r| r.rule_id.as_str()).collect();
490 rule_ids.sort();
491 rule_ids.dedup();
492 let rules: Vec<Value> = rule_ids
493 .iter()
494 .map(|code| {
495 let mut m = Map::new();
496 m.insert("id".into(), json!(code));
497 Value::Object(m)
498 })
499 .collect();
500
501 let result_values: Vec<Value> = results
502 .iter()
503 .map(|r| {
504 let mut artifact_location = Map::new();
505 artifact_location.insert("uri".into(), json!(r.uri));
506 let mut physical = Map::new();
507 physical.insert("artifactLocation".into(), Value::Object(artifact_location));
508 if let Some(line) = r.line {
509 let mut region = Map::new();
510 region.insert("startLine".into(), json!(line));
511 physical.insert("region".into(), Value::Object(region));
512 }
513 let mut location = Map::new();
514 location.insert("physicalLocation".into(), Value::Object(physical));
515
516 let mut message = Map::new();
517 message.insert("text".into(), json!(r.message));
518
519 let mut m = Map::new();
520 m.insert("ruleId".into(), json!(r.rule_id));
521 m.insert("level".into(), json!(r.level));
522 m.insert("message".into(), Value::Object(message));
523 m.insert(
524 "locations".into(),
525 Value::Array(vec![Value::Object(location)]),
526 );
527 Value::Object(m)
528 })
529 .collect();
530
531 let mut driver = Map::new();
532 driver.insert("name".into(), json!("decided"));
533 driver.insert(
534 "informationUri".into(),
535 json!("https://github.com/asdecided/core"),
536 );
537 driver.insert("version".into(), json!(rac_version()));
538 driver.insert("rules".into(), Value::Array(rules));
539
540 let mut tool = Map::new();
541 tool.insert("driver".into(), Value::Object(driver));
542
543 let mut run = Map::new();
544 run.insert("tool".into(), Value::Object(tool));
545 run.insert("results".into(), Value::Array(result_values));
546
547 let mut document = Map::new();
548 document.insert("version".into(), json!("2.1.0"));
549 document.insert(
550 "$schema".into(),
551 json!("https://json.schemastore.org/sarif-2.1.0.json"),
552 );
553 document.insert("runs".into(), Value::Array(vec![Value::Object(run)]));
554 dumps_indent2(&Value::Object(document))
555}
556
557pub fn render_validate_sarif(result: &DirectoryValidation) -> String {
558 let mut results: Vec<SarifResult> = Vec::new();
559 for file in &result.files {
560 for issue in &file.issues {
561 results.push(SarifResult {
562 rule_id: issue.code.clone(),
563 level: sarif_level(issue.severity),
564 message: issue.message.clone(),
565 uri: quote_uri(&file.path),
566 line: issue.line,
567 });
568 }
569 }
570 if let Some(okf) = &result.okf {
571 for finding in &okf.findings {
572 results.push(SarifResult {
573 rule_id: finding.code.clone(),
574 level: sarif_level(&finding.severity),
575 message: finding.message.clone(),
576 uri: quote_uri(&finding.path),
577 line: None,
578 });
579 }
580 }
581 sarif_document(results)
582}
583
584fn sarif_relationship_reason(code: &str) -> &str {
585 match code {
586 ISSUE_TARGET_NOT_FOUND => "target not found",
587 ISSUE_TARGET_AMBIGUOUS => "target is ambiguous",
588 ISSUE_SELF_REFERENCE => "self-reference",
589 ISSUE_TARGET_SUPERSEDED => "target is superseded",
590 ISSUE_TARGET_TYPE_MISMATCH => "target is the wrong artifact type",
591 ISSUE_SCOPE_TARGET_NOT_FOUND => "declared path does not exist in the repository",
592 other => other,
593 }
594}
595
596pub(crate) fn relationship_sarif_parts(issue: &RelationshipIssue) -> (String, String) {
602 let label = issue.relationship.as_deref().unwrap_or("").replace('_', " ");
603 let (message, uri) = if issue.code == ISSUE_DUPLICATE_IDENTIFIER {
604 let paths = issue.paths.clone().unwrap_or_default();
605 let message = format!(
606 "Duplicate artifact identifier '{}' in: {}",
607 issue.identifier.as_deref().unwrap_or(""),
608 paths.join(", ")
609 );
610 let uri = paths
611 .first()
612 .cloned()
613 .unwrap_or_else(|| issue.identifier.clone().unwrap_or_default());
614 (message, uri)
615 } else if issue.code == ISSUE_RELATIONSHIP_CYCLE {
616 let paths = issue.paths.clone().unwrap_or_default();
617 (
618 format!("{label} relationship cycle: {}", paths.join(" -> ")),
619 paths.first().cloned().unwrap_or_default(),
620 )
621 } else if issue.code == ISSUE_EDGE_UNSUPPORTED {
622 (
623 format!("{label} not supported for this artifact type"),
624 issue.source_path.clone().unwrap_or_default(),
625 )
626 } else {
627 let reason = sarif_relationship_reason(&issue.code);
628 (
629 format!(
630 "{label}: {} \u{2014} {reason}",
631 issue.target.as_deref().unwrap_or("")
632 ),
633 issue.source_path.clone().unwrap_or_default(),
634 )
635 };
636 (message, quote_uri(&uri))
637}
638
639pub fn render_relationships_sarif(validation: &RelationshipValidation) -> String {
640 let results: Vec<SarifResult> = validation
641 .issues
642 .iter()
643 .map(|issue| {
644 let (message, uri) = relationship_sarif_parts(issue);
645 SarifResult {
646 rule_id: issue.code.clone(),
647 level: sarif_level(crate::relationships::relationship_severity(&issue.code)),
648 message,
649 uri,
650 line: None,
651 }
652 })
653 .collect();
654 sarif_document(results)
655}
656
657pub fn render_relationships_json(report: &RelationshipReport) -> String {
660 let mut payload = Map::new();
661 payload.insert("directory".into(), json!(report.directory));
662 payload.insert("recursive".into(), json!(report.recursive));
663 payload.insert("total_files".into(), json!(report.total_files));
664 payload.insert(
665 "artifacts_with_relationships".into(),
666 json!(report.artifacts_with_relationships()),
667 );
668 payload.insert(
669 "relationship_count".into(),
670 json!(report.relationship_count()),
671 );
672 let mut counts = Map::new();
673 for (section, count) in report.counts() {
674 counts.insert(section, json!(count));
675 }
676 payload.insert("counts".into(), Value::Object(counts));
677 let artifacts: Vec<Value> = report
678 .artifacts
679 .iter()
680 .map(|artifact| {
681 let mut relationships = Map::new();
682 for (section, refs) in &artifact.relationships {
683 relationships.insert(section.clone(), json!(refs));
684 }
685 let mut m = Map::new();
686 m.insert("path".into(), json!(artifact.path));
687 m.insert("type".into(), json!(artifact.type_name));
688 m.insert("relationships".into(), Value::Object(relationships));
689 Value::Object(m)
690 })
691 .collect();
692 payload.insert("artifacts".into(), Value::Array(artifacts));
693 dumps_indent2(&Value::Object(payload))
694}
695
696pub fn render_relationships_human(report: &RelationshipReport) -> String {
697 let mut lines: Vec<String> = vec![
698 bold("Relationships"),
699 String::new(),
700 format!("Files Inspected: {}", report.total_files),
701 format!(
702 "Artifacts With Relationships: {}",
703 report.artifacts_with_relationships()
704 ),
705 format!("Relationships Found: {}", report.relationship_count()),
706 ];
707
708 let counts = report.counts();
709 if !counts.is_empty() {
710 lines.push(String::new());
711 lines.push(bold("By Type:"));
712 for (section, count) in &counts {
713 lines.push(format!("- {}: {count}", relationship_label(section)));
714 }
715 }
716
717 for artifact in &report.artifacts {
718 lines.push(String::new());
719 lines.push(artifact.path.clone());
720 for (section, refs) in &artifact.relationships {
721 lines.push(format!(" {}:", relationship_label(section)));
722 for reference in refs {
723 match report.labels.get(&crate::pycompat::py_casefold(reference)) {
724 Some(resolved) => lines.push(format!(" - {reference} \u{2014} {resolved}")),
725 None => lines.push(format!(" - {reference}")),
726 }
727 }
728 }
729 }
730
731 lines.join("\n")
732}
733
734fn relationship_issue_value(issue: &RelationshipIssue) -> Value {
737 let mut m = Map::new();
738 if issue.code == ISSUE_DUPLICATE_IDENTIFIER {
739 m.insert("identifier".into(), json!(issue.identifier));
740 m.insert("paths".into(), json!(issue.paths));
741 m.insert("code".into(), json!(issue.code));
742 } else if issue.code == ISSUE_EDGE_UNSUPPORTED {
743 m.insert("source_path".into(), json!(issue.source_path));
744 m.insert("relationship".into(), json!(issue.relationship));
745 m.insert("code".into(), json!(issue.code));
746 } else if issue.code == ISSUE_RELATIONSHIP_CYCLE {
747 m.insert("relationship".into(), json!(issue.relationship));
748 m.insert("paths".into(), json!(issue.paths));
749 m.insert("code".into(), json!(issue.code));
750 } else {
751 m.insert("source_path".into(), json!(issue.source_path));
752 m.insert("relationship".into(), json!(issue.relationship));
753 m.insert("target".into(), json!(issue.target));
754 m.insert("code".into(), json!(issue.code));
755 }
756 Value::Object(m)
757}
758
759pub fn render_relationship_validation_json(report: &RelationshipValidation) -> String {
760 let mut payload = Map::new();
761 payload.insert("directory".into(), json!(report.directory));
762 payload.insert("recursive".into(), json!(report.recursive));
763 payload.insert(
764 "relationships_checked".into(),
765 json!(report.relationships_checked),
766 );
767 payload.insert("validation_issues".into(), json!(report.issues.len()));
768 payload.insert(
769 "issues".into(),
770 Value::Array(
771 report
772 .issues
773 .iter()
774 .map(relationship_issue_value)
775 .collect(),
776 ),
777 );
778 dumps_indent2(&Value::Object(payload))
779}
780
781pub fn render_relationship_validation_human(report: &RelationshipValidation) -> String {
782 let mut lines: Vec<String> = vec![
783 bold("Relationship Validation"),
784 String::new(),
785 format!("Relationships Checked: {}", report.relationships_checked),
786 format!("Validation Issues: {}", report.issues.len()),
787 ];
788
789 let duplicates: Vec<&RelationshipIssue> = report
790 .issues
791 .iter()
792 .filter(|i| i.code == ISSUE_DUPLICATE_IDENTIFIER)
793 .collect();
794 let unsupported: Vec<&RelationshipIssue> = report
795 .issues
796 .iter()
797 .filter(|i| i.code == ISSUE_EDGE_UNSUPPORTED)
798 .collect();
799 let cycles: Vec<&RelationshipIssue> = report
800 .issues
801 .iter()
802 .filter(|i| i.code == ISSUE_RELATIONSHIP_CYCLE)
803 .collect();
804 let references: Vec<&RelationshipIssue> = report
805 .issues
806 .iter()
807 .filter(|i| {
808 i.code != ISSUE_DUPLICATE_IDENTIFIER
809 && i.code != ISSUE_EDGE_UNSUPPORTED
810 && i.code != ISSUE_RELATIONSHIP_CYCLE
811 })
812 .collect();
813
814 if !duplicates.is_empty() {
815 lines.push(String::new());
816 lines.push(bold("Duplicate Identifiers"));
817 for issue in &duplicates {
818 let paths = issue.paths.clone().unwrap_or_default();
819 lines.push(red(&format!(
820 "\u{2717} {} ({} files)",
821 issue.identifier.as_deref().unwrap_or(""),
822 paths.len()
823 )));
824 for p in &paths {
825 lines.push(format!(" - {p}"));
826 }
827 }
828 }
829
830 if !unsupported.is_empty() {
831 lines.push(String::new());
832 lines.push(bold("Unsupported Relationships"));
833 let mut current_source: Option<&str> = None;
834 for issue in &unsupported {
835 let source = issue.source_path.as_deref();
836 if source != current_source {
837 current_source = source;
838 lines.push(String::new());
839 lines.push(source.unwrap_or("<input>").to_string());
840 }
841 let label = relationship_label(issue.relationship.as_deref().unwrap_or(""));
842 lines.push(red(&format!(
843 " \u{2717} {label} not supported for this artifact type"
844 )));
845 }
846 }
847
848 if !cycles.is_empty() {
849 lines.push(String::new());
850 lines.push(bold("Relationship Cycles"));
851 for issue in &cycles {
852 let label = relationship_label(issue.relationship.as_deref().unwrap_or(""));
853 lines.push(red(&format!("\u{2717} {label} cycle:")));
854 for p in issue.paths.clone().unwrap_or_default() {
855 lines.push(format!(" - {p}"));
856 }
857 }
858 }
859
860 if !references.is_empty() {
861 lines.push(String::new());
862 lines.push(bold("Broken Relationships"));
863 let mut current_source: Option<&str> = None;
864 let mut current_section: Option<&str> = None;
865 for issue in &references {
866 let source = issue.source_path.as_deref();
867 if source != current_source {
868 current_source = source;
869 current_section = None;
870 lines.push(String::new());
871 lines.push(source.unwrap_or("<input>").to_string());
872 }
873 let section = issue.relationship.as_deref();
874 if section != current_section {
875 current_section = section;
876 lines.push(format!(" {}:", relationship_label(section.unwrap_or(""))));
877 }
878 let suffix = ref_issue_suffix(&issue.code);
879 lines.push(red(&format!(
880 " \u{2717} {} {}",
881 issue.target.as_deref().unwrap_or(""),
882 suffix
883 )));
884 }
885 }
886
887 lines.join("\n")
888}
889
890pub fn render_schema_list_human(names: &[&str]) -> String {
893 let mut lines = vec![bold("Available Schemas:")];
894 for name in names {
895 lines.push(format!("- {name}"));
896 }
897 lines.join("\n")
898}
899
900pub fn render_schema_list_json(names: &[&str]) -> String {
901 let mut m = Map::new();
902 m.insert(
903 "schemas".into(),
904 Value::Array(names.iter().map(|n| json!(n)).collect()),
905 );
906 dumps_indent2(&Value::Object(m))
907}
908
909pub fn render_unknown_schema(name: &str, available: &[&str]) -> String {
910 let mut lines = vec![
911 format!("Unknown schema: {name}"),
912 String::new(),
913 "Available schemas:".to_string(),
914 ];
915 for schema in available {
916 lines.push(format!("- {schema}"));
917 }
918 lines.join("\n")
919}
920
921fn snake_map_value(pairs: &[(String, Vec<String>)]) -> Value {
922 let mut m = Map::new();
923 for (section, values) in pairs {
924 m.insert(spec_snake(section), json!(values));
925 }
926 Value::Object(m)
927}
928
929pub fn render_schema_json(spec: &ArtifactSpec) -> String {
930 let mut m = Map::new();
931 m.insert("type".into(), json!(spec.name));
932 m.insert(
933 "required".into(),
934 Value::Array(spec.required.iter().map(|s| json!(spec_snake(s))).collect()),
935 );
936 m.insert(
937 "recommended".into(),
938 Value::Array(
939 spec.recommended
940 .iter()
941 .map(|s| json!(spec_snake(s)))
942 .collect(),
943 ),
944 );
945 m.insert(
946 "optional".into(),
947 Value::Array(spec.optional.iter().map(|s| json!(spec_snake(s))).collect()),
948 );
949 let mut descriptions = Map::new();
950 for (section, desc) in &spec.descriptions {
951 descriptions.insert(spec_snake(section), json!(desc));
952 }
953 m.insert("descriptions".into(), Value::Object(descriptions));
954 m.insert("guidance".into(), snake_map_value(&spec.guidance));
955 m.insert("metadata".into(), snake_map_value(&spec.metadata));
956 dumps_indent2(&Value::Object(m))
957}
958
959pub fn render_schema_human(spec: &ArtifactSpec) -> String {
960 let mut lines = vec![bold(&format!("Artifact Type: {}", spec.display)), String::new()];
961
962 let mut section_block = |title: &str, names: &[String]| {
963 lines.push(bold(title));
964 if names.is_empty() {
965 lines.push(" (none)".to_string());
966 lines.push(String::new());
967 return;
968 }
969 for name in names {
970 lines.push(format!(" - {}", py_title(name)));
971 if let Some((_, description)) =
972 spec.descriptions.iter().find(|(k, _)| k == name)
973 {
974 if !description.is_empty() {
975 lines.push(format!(" Description: {description}"));
976 }
977 }
978 if let Some((_, guidance)) = spec.guidance.iter().find(|(k, _)| k == name) {
979 if !guidance.is_empty() {
980 lines.push(" Guidance:".to_string());
981 for item in guidance {
982 lines.push(format!(" - {item}"));
983 }
984 }
985 }
986 }
987 lines.push(String::new());
988 };
989
990 section_block("Required Sections:", &spec.required);
991 section_block("Recommended Sections:", &spec.recommended);
992 section_block("Optional Sections:", &spec.optional);
993
994 if !spec.metadata.is_empty() {
995 lines.push(bold("Metadata Fields:"));
996 for (name, values) in &spec.metadata {
997 lines.push(format!(" - {}: {}", py_title(name), values.join(" | ")));
998 }
999 }
1000 lines.join("\n").trim_end().to_string()
1001}
1002
1003fn metadata_default(section: &str, values: &[String]) -> String {
1005 if section == "status" && values.iter().any(|v| v == "Proposed") {
1006 return "Proposed".to_string();
1007 }
1008 if section == "category" && values.iter().any(|v| v == "Other") {
1009 return "Other".to_string();
1010 }
1011 values.first().cloned().unwrap_or_else(|| "TODO".to_string())
1012}
1013
1014fn starter_body(spec: &ArtifactSpec, section: &str, metadata_values: &[String]) -> String {
1016 if !metadata_values.is_empty() {
1017 return metadata_default(section, metadata_values);
1018 }
1019 match spec.starter_bodies.iter().find(|(k, _)| k == section) {
1020 Some((_, body)) if !body.is_empty() => body.clone(),
1021 _ => format!("TODO: describe {section}."),
1022 }
1023}
1024
1025pub fn render_schema_template(spec: &ArtifactSpec) -> String {
1026 let mut blocks: Vec<String> = vec!["# Title".to_string()];
1027 let sections: Vec<&String> = spec.required.iter().chain(spec.recommended.iter()).collect();
1029 for section in sections {
1030 let metadata_values: &[String] = spec
1031 .metadata
1032 .iter()
1033 .find(|(k, _)| k == section)
1034 .map(|(_, v)| v.as_slice())
1035 .unwrap_or(&[]);
1036 let body = starter_body(spec, section, metadata_values);
1037 let mut block = format!("## {}\n\n{}", py_title(section), body);
1038 let mut comments: Vec<String> = Vec::new();
1039 if !metadata_values.is_empty() {
1040 comments.push(format!("Choose one: {}", metadata_values.join(" | ")));
1041 }
1042 if let Some((_, guidance)) = spec.guidance.iter().find(|(k, _)| k == section) {
1043 comments.extend(guidance.iter().cloned());
1044 }
1045 if !comments.is_empty() {
1046 let rendered: Vec<String> =
1047 comments.iter().map(|c| format!("<!-- {c} -->")).collect();
1048 block.push_str("\n\n");
1049 block.push_str(&rendered.join("\n"));
1050 }
1051 blocks.push(block);
1052 }
1053 format!("{}\n", blocks.join("\n\n"))
1054}
1055
1056fn diff_list_block(blocks: &mut Vec<String>, title: &str, items: &[String], sign: char) {
1060 if items.is_empty() {
1061 return;
1062 }
1063 let color: fn(&str) -> String = if sign == '+' { green } else { red };
1064 let mut lines = vec![bold(title), String::new()];
1065 lines.extend(items.iter().map(|item| color(&format!("{sign} {item}"))));
1066 blocks.push(lines.join("\n"));
1067}
1068
1069pub fn render_diff_human(d: &Diff) -> String {
1070 if d.is_empty() {
1071 return "No changes.".to_string();
1072 }
1073
1074 let mut blocks: Vec<String> = Vec::new();
1075
1076 let req_lines = |reqs: &[Requirement]| -> Vec<String> {
1077 reqs.iter().map(|r| format!("{} {}", r.id, r.text)).collect()
1078 };
1079
1080 diff_list_block(
1081 &mut blocks,
1082 "Added Requirements",
1083 &req_lines(&d.added_requirements),
1084 '+',
1085 );
1086 diff_list_block(
1087 &mut blocks,
1088 "Removed Requirements",
1089 &req_lines(&d.removed_requirements),
1090 '-',
1091 );
1092
1093 if !d.modified_requirements.is_empty() {
1094 let mut lines = vec![bold("Modified Requirements"), String::new()];
1095 for (i, c) in d.modified_requirements.iter().enumerate() {
1096 if i > 0 {
1097 lines.push(String::new());
1098 }
1099 lines.push(format!("~ {}", c.id));
1100 lines.push(String::new());
1101 lines.push("Before:".to_string());
1102 lines.push(red(&c.old_text));
1103 lines.push(String::new());
1104 lines.push("After:".to_string());
1105 lines.push(green(&c.new_text));
1106 }
1107 blocks.push(lines.join("\n"));
1108 }
1109
1110 diff_list_block(&mut blocks, "Added Metrics", &d.added_metrics, '+');
1111 diff_list_block(&mut blocks, "Removed Metrics", &d.removed_metrics, '-');
1112 diff_list_block(&mut blocks, "Added Risks", &d.added_risks, '+');
1113 diff_list_block(&mut blocks, "Removed Risks", &d.removed_risks, '-');
1114
1115 blocks.join("\n\n")
1117}
1118
1119fn requirement_value(r: &Requirement) -> Value {
1121 let mut m = Map::new();
1122 m.insert("id".into(), json!(r.id));
1123 m.insert("text".into(), json!(r.text));
1124 m.insert("line".into(), json!(r.line));
1125 Value::Object(m)
1126}
1127
1128pub fn render_diff_json(d: &Diff, old_path: &str, new_path: &str) -> String {
1130 let mut m = Map::new();
1131 m.insert("old".into(), json!(old_path));
1132 m.insert("new".into(), json!(new_path));
1133 m.insert(
1134 "added_requirements".into(),
1135 Value::Array(d.added_requirements.iter().map(requirement_value).collect()),
1136 );
1137 m.insert(
1138 "removed_requirements".into(),
1139 Value::Array(d.removed_requirements.iter().map(requirement_value).collect()),
1140 );
1141 m.insert(
1142 "modified_requirements".into(),
1143 Value::Array(
1144 d.modified_requirements
1145 .iter()
1146 .map(|c| {
1147 let mut cm = Map::new();
1148 cm.insert("id".into(), json!(c.id));
1149 cm.insert("old_text".into(), json!(c.old_text));
1150 cm.insert("new_text".into(), json!(c.new_text));
1151 Value::Object(cm)
1152 })
1153 .collect(),
1154 ),
1155 );
1156 m.insert("added_metrics".into(), json!(d.added_metrics));
1157 m.insert("removed_metrics".into(), json!(d.removed_metrics));
1158 m.insert("added_risks".into(), json!(d.added_risks));
1159 m.insert("removed_risks".into(), json!(d.removed_risks));
1160 dumps_indent2(&Value::Object(m))
1161}
1162
1163fn append_relationships(lines: &mut Vec<String>, result: &InspectionResult) {
1167 if result.relationships.is_empty() {
1168 return;
1169 }
1170 lines.push(String::new());
1171 lines.push(bold("Relationships:"));
1172 for (section, refs) in &result.relationships {
1173 lines.push(format!(" {}:", relationship_label(section)));
1174 for r in refs {
1175 lines.push(format!(" - {r}"));
1176 }
1177 }
1178}
1179
1180fn append_decision_metadata(lines: &mut Vec<String>, result: &InspectionResult) {
1182 let pairs = [
1183 ("Status", result.status.as_deref()),
1184 ("Category", result.category.as_deref()),
1185 ("Supersedes", result.supersedes.as_deref()),
1186 ];
1187 let shown: Vec<(&str, &str)> = pairs
1189 .iter()
1190 .filter_map(|(label, value)| value.filter(|v| !v.is_empty()).map(|v| (*label, v)))
1191 .collect();
1192 if !shown.is_empty() {
1193 lines.push(String::new());
1194 lines.push(bold("Decision Metadata:"));
1195 for (label, value) in shown {
1196 lines.push(format!(" {label}: {value}"));
1197 }
1198 }
1199}
1200
1201pub fn render_inspect_human(result: &InspectionResult) -> String {
1202 let mut lines = vec![
1203 bold(&format!(
1204 "Artifact Type: {}",
1205 py_title(&result.artifact_type)
1206 )),
1207 format!("Confidence: {}", py_format_percent0(result.confidence)),
1208 String::new(),
1209 bold("Present Sections:"),
1210 ];
1211 if result.present_sections.is_empty() {
1212 lines.push(" (none)".to_string());
1213 } else {
1214 for s in &result.present_sections {
1215 lines.push(green(&format!(" \u{2713} {}", py_title(s))));
1216 }
1217 }
1218 if !result.missing_sections.is_empty() {
1219 lines.push(String::new());
1220 lines.push(bold("Missing Sections:"));
1221 for s in &result.missing_sections {
1222 lines.push(red(&format!(" \u{2717} {}", py_title(s))));
1223 }
1224 }
1225 append_decision_metadata(&mut lines, result);
1226 append_relationships(&mut lines, result);
1227 lines.join("\n")
1228}
1229
1230fn format_g(x: f64) -> String {
1235 if x.fract() == 0.0 {
1236 format!("{}", x as i64)
1237 } else {
1238 py_float_repr(x)
1239 }
1240}
1241
1242pub fn render_inspect_verbose(result: &InspectionResult, scores: &[TypeScore]) -> String {
1244 let chosen = scores
1246 .iter()
1247 .find(|s| s.name == result.artifact_type)
1248 .or_else(|| scores.first());
1249
1250 let mut lines = vec![
1251 bold(&format!(
1252 "Artifact Type: {}",
1253 py_title(&result.artifact_type)
1254 )),
1255 format!("Confidence: {}", py_format_percent0(result.confidence)),
1256 ];
1257 let Some(chosen) = chosen else {
1258 return lines.join("\n");
1259 };
1260 if result.artifact_type == "unknown" {
1261 let display = spec_for(&chosen.name)
1262 .map(|s| s.display.clone())
1263 .unwrap_or_else(|| py_title(&chosen.name));
1264 lines.push(format!("Closest match: {display}"));
1265 }
1266
1267 let block = |title: &str, names: &[String], lines: &mut Vec<String>| {
1268 lines.push(String::new());
1269 lines.push(bold(title));
1270 if names.is_empty() {
1271 lines.push(" (none)".to_string());
1272 } else {
1273 for s in names {
1274 lines.push(green(&format!(" \u{2713} {}", py_title(s))));
1275 }
1276 }
1277 };
1278
1279 block("Required Matches:", &chosen.matched_required, &mut lines);
1280 block("Recommended Matches:", &chosen.matched_recommended, &mut lines);
1281 if !chosen.missing.is_empty() {
1282 lines.push(String::new());
1283 lines.push(bold("Missing:"));
1284 for s in &chosen.missing {
1285 lines.push(red(&format!(" \u{2717} {}", py_title(s))));
1286 }
1287 }
1288
1289 let req = chosen.matched_required.len();
1290 let rec = chosen.matched_recommended.len();
1291 lines.push(String::new());
1292 lines.push(format!(
1293 "{} {req} + 0.5 \u{d7} {rec} = {} / {} = {}",
1294 bold("Score:"),
1295 format_g(chosen.points),
1296 format_g(chosen.ceiling),
1297 py_float_repr(py_round(chosen.fit, 2))
1298 ));
1299 if result.artifact_type == "unknown" {
1300 lines.push(format!(
1301 "(below the {} threshold \u{2192} Unknown)",
1302 py_format_percent0(CONFIDENCE_THRESHOLD)
1303 ));
1304 }
1305 lines.join("\n")
1306}
1307
1308pub fn render_dir_inspect_human(d: &DirectoryInspection) -> String {
1309 let counts = d.counts();
1310 let count_of = |name: &str| -> usize {
1311 counts
1312 .iter()
1313 .find(|(n, _)| *n == name)
1314 .map(|(_, c)| *c)
1315 .unwrap_or(0)
1316 };
1317 let mut lines = vec![
1318 bold(&format!("Files Inspected: {}", d.total_files())),
1319 String::new(),
1320 ];
1321 for spec in specs() {
1322 lines.push(format!("{}s: {}", spec.display, count_of(&spec.name)));
1323 }
1324 lines.push(format!("Unknown: {}", count_of("unknown")));
1325 lines.join("\n")
1326}
1327
1328pub fn render_inspect_json(result: &InspectionResult) -> String {
1331 let mut m = Map::new();
1332 m.insert("type".into(), json!(result.artifact_type));
1333 m.insert("confidence".into(), py_float(result.confidence));
1334 m.insert(
1335 "present_sections".into(),
1336 Value::Array(
1337 result
1338 .present_sections
1339 .iter()
1340 .map(|s| json!(spec_snake(s)))
1341 .collect(),
1342 ),
1343 );
1344 m.insert(
1345 "missing_sections".into(),
1346 Value::Array(
1347 result
1348 .missing_sections
1349 .iter()
1350 .map(|s| json!(spec_snake(s)))
1351 .collect(),
1352 ),
1353 );
1354 for (key, value) in [
1356 ("status", &result.status),
1357 ("category", &result.category),
1358 ("supersedes", &result.supersedes),
1359 ] {
1360 if let Some(v) = value {
1361 m.insert(key.into(), json!(v));
1362 }
1363 }
1364 if !result.relationships.is_empty() {
1365 let mut rel = Map::new();
1366 for (section, refs) in &result.relationships {
1367 rel.insert(section.clone(), json!(refs));
1368 }
1369 m.insert("relationships".into(), Value::Object(rel));
1370 }
1371 dumps_indent2(&Value::Object(m))
1372}
1373
1374pub fn render_dir_inspect_json(d: &DirectoryInspection) -> String {
1375 let mut counts = Map::new();
1376 for (name, count) in d.counts() {
1377 counts.insert(name.to_string(), json!(count));
1378 }
1379 let mut summary = Map::new();
1380 summary.insert("total_files".into(), json!(d.total_files()));
1381 summary.insert("counts".into(), Value::Object(counts));
1382 summary.insert("unknown".into(), json!(d.unknown_count()));
1383 let mut m = Map::new();
1384 m.insert("schema_version".into(), json!("1"));
1385 m.insert("directory".into(), json!(d.directory));
1386 m.insert("recursive".into(), json!(d.recursive));
1387 m.insert("summary".into(), Value::Object(summary));
1388 m.insert(
1389 "files".into(),
1390 Value::Array(
1391 d.files
1392 .iter()
1393 .map(|f| {
1394 let mut fm = Map::new();
1395 fm.insert("path".into(), json!(f.path));
1396 fm.insert("type".into(), json!(f.artifact_type));
1397 fm.insert("confidence".into(), py_float(f.confidence));
1398 Value::Object(fm)
1399 })
1400 .collect(),
1401 ),
1402 );
1403 dumps_indent2(&Value::Object(m))
1404}
1405
1406const UNKNOWN_MESSAGE: &str =
1410 "Unable to generate improvement guidance.\nArtifact type could not be determined.";
1411
1412fn unsupported_message(result: &ImprovementResult) -> String {
1415 format!(
1416 "Artifact Type: {}\n\nImprovement guidance is not currently available for this artifact type.",
1417 py_title(&result.artifact_type)
1418 )
1419}
1420
1421pub fn render_improve_human(result: &ImprovementResult) -> String {
1422 if result.artifact_type == "unknown" {
1423 return UNKNOWN_MESSAGE.to_string();
1424 }
1425 if !result.supported {
1426 return unsupported_message(result);
1427 }
1428
1429 let mut lines = vec![
1430 bold(&format!(
1431 "Artifact Type: {}",
1432 py_title(&result.artifact_type)
1433 )),
1434 String::new(),
1435 ];
1436 if result.missing_required.is_empty() && result.missing_recommended.is_empty() {
1437 lines.push("Nothing to improve \u{2014} all expected sections present.".to_string());
1438 return lines.join("\n");
1439 }
1440
1441 let block = |title: &str, names: &[String], lines: &mut Vec<String>| {
1442 lines.push(bold(title));
1443 if names.is_empty() {
1444 lines.push(" (none)".to_string());
1445 } else {
1446 for s in names {
1447 lines.push(format!(" - {}", py_title(s)));
1448 if let Some((_, questions)) = result.guidance.iter().find(|(k, _)| k == s) {
1449 for q in questions {
1450 lines.push(format!(" \u{2022} {q}"));
1451 }
1452 }
1453 }
1454 }
1455 lines.push(String::new());
1456 };
1457
1458 block("Missing Required:", &result.missing_required, &mut lines);
1459 block("Missing Recommended:", &result.missing_recommended, &mut lines);
1460 py_rstrip(&lines.join("\n")).to_string()
1461}
1462
1463pub fn render_improve_json(result: &ImprovementResult) -> String {
1466 let mut m = Map::new();
1467 m.insert("type".into(), json!(result.artifact_type));
1468 m.insert(
1469 "missing_required".into(),
1470 Value::Array(
1471 result
1472 .missing_required
1473 .iter()
1474 .map(|s| json!(spec_snake(s)))
1475 .collect(),
1476 ),
1477 );
1478 m.insert(
1479 "missing_recommended".into(),
1480 Value::Array(
1481 result
1482 .missing_recommended
1483 .iter()
1484 .map(|s| json!(spec_snake(s)))
1485 .collect(),
1486 ),
1487 );
1488 m.insert("guidance".into(), snake_map_value(&result.guidance));
1489 dumps_indent2(&Value::Object(m))
1490}
1491
1492pub fn render_improve_template(result: &ImprovementResult) -> String {
1494 if result.artifact_type == "unknown" {
1495 return UNKNOWN_MESSAGE.to_string();
1496 }
1497 if !result.supported {
1498 return unsupported_message(result);
1499 }
1500
1501 let missing: Vec<&String> = result
1502 .missing_required
1503 .iter()
1504 .chain(result.missing_recommended.iter())
1505 .collect();
1506 if missing.is_empty() {
1507 return "# Nothing to add \u{2014} all expected sections present.".to_string();
1508 }
1509
1510 let mut blocks: Vec<String> = Vec::new();
1511 for section in missing {
1512 let mut block = format!("## {}\n\n_TODO_", py_title(section));
1513 if let Some((_, questions)) = result.guidance.iter().find(|(k, _)| k == section) {
1514 if !questions.is_empty() {
1515 let rendered: Vec<String> =
1516 questions.iter().map(|q| format!("<!-- {q} -->")).collect();
1517 block.push_str("\n\n");
1518 block.push_str(&rendered.join("\n"));
1519 }
1520 }
1521 blocks.push(block);
1522 }
1523 format!("{}\n", blocks.join("\n\n"))
1524}
1525
1526pub fn render_templates_human(names: &[&str]) -> String {
1527 let mut lines = vec![bold("Available artifact templates:"), String::new()];
1528 for name in names {
1529 lines.push(format!("- {name}"));
1530 }
1531 lines.join("\n")
1532}
1533
1534pub fn render_templates_json(names: &[&str]) -> String {
1535 let mut m = Map::new();
1536 m.insert("schema_version".into(), json!("1"));
1537 m.insert(
1538 "templates".into(),
1539 Value::Array(names.iter().map(|n| json!(n)).collect()),
1540 );
1541 dumps_indent2(&Value::Object(m))
1542}
1543
1544const EMPTY_CORPUS_HINT: &str = "No artifacts yet — create your first with: decided quickstart";
1547
1548fn invalid_files_json(items: &[(&str, &[String])]) -> Value {
1549 Value::Array(
1550 items
1551 .iter()
1552 .map(|(path, codes)| {
1553 let mut m = Map::new();
1554 m.insert("file".into(), json!(path));
1555 m.insert("errors".into(), json!(codes));
1556 Value::Object(m)
1557 })
1558 .collect(),
1559 )
1560}
1561
1562pub fn render_stats_json(s: &PortfolioStats) -> String {
1563 let mut payload = Map::new();
1564 payload.insert("directory".into(), json!(s.directory));
1565 payload.insert("empty".into(), json!(s.is_empty()));
1566 payload.insert("features".into(), json!(s.files_found()));
1567 payload.insert("valid_features".into(), json!(s.valid_features()));
1568 payload.insert("invalid_features".into(), json!(s.invalid_features()));
1569 payload.insert("requirements".into(), json!(s.total_requirements()));
1570 payload.insert("metrics".into(), json!(s.total_metrics()));
1571 payload.insert("risks".into(), json!(s.total_risks()));
1572 payload.insert(
1573 "features_missing_metrics".into(),
1574 json!(s.missing_metrics().len()),
1575 );
1576 payload.insert(
1577 "features_missing_risks".into(),
1578 json!(s.missing_risks().len()),
1579 );
1580 payload.insert("missing_metrics".into(), json!(s.missing_metrics()));
1581 payload.insert("missing_risks".into(), json!(s.missing_risks()));
1582 payload.insert(
1583 "average_requirements_per_feature".into(),
1584 crate::pyjson::py_float(py_round(s.average_requirements(), 1)),
1585 );
1586 payload.insert(
1587 "largest_feature".into(),
1588 match s.largest_feature() {
1589 Some(f) => {
1590 let mut m = Map::new();
1591 m.insert("name".into(), json!(f.name));
1592 m.insert("requirements".into(), json!(f.requirements));
1593 Value::Object(m)
1594 }
1595 None => Value::Null,
1596 },
1597 );
1598 payload.insert(
1599 "requirements_by_feature".into(),
1600 Value::Array(
1601 s.requirements_by_feature()
1602 .iter()
1603 .map(|f| {
1604 let mut m = Map::new();
1605 m.insert("name".into(), json!(f.name));
1606 m.insert("requirements".into(), json!(f.requirements));
1607 Value::Object(m)
1608 })
1609 .collect(),
1610 ),
1611 );
1612 let invalid: Vec<(&str, &[String])> = s
1613 .invalid()
1614 .iter()
1615 .map(|f| (f.path.as_str(), f.error_codes.as_slice()))
1616 .collect();
1617 payload.insert("invalid".into(), invalid_files_json(&invalid));
1618
1619 if !s.decisions.is_empty() {
1620 let mut m = Map::new();
1621 m.insert("count".into(), json!(s.decision_count()));
1622 let mut by_status = Map::new();
1623 for (k, c) in s.decision_status_counts() {
1624 by_status.insert(k, json!(c));
1625 }
1626 m.insert("by_status".into(), Value::Object(by_status));
1627 let mut by_category = Map::new();
1628 for (k, c) in s.decision_category_counts() {
1629 by_category.insert(k, json!(c));
1630 }
1631 m.insert("by_category".into(), Value::Object(by_category));
1632 payload.insert("decisions".into(), Value::Object(m));
1633 }
1634
1635 let mut family = |key: &str, count: usize, valid: usize, invalid: Vec<(&str, &[String])>| {
1636 let mut m = Map::new();
1637 m.insert("count".into(), json!(count));
1638 m.insert("valid".into(), json!(valid));
1639 m.insert("invalid".into(), invalid_files_json(&invalid));
1640 payload.insert(key.into(), Value::Object(m));
1641 };
1642
1643 if !s.roadmaps.is_empty() {
1644 let invalid: Vec<(&str, &[String])> = s
1645 .invalid_roadmaps()
1646 .iter()
1647 .map(|r| (r.path.as_str(), r.error_codes.as_slice()))
1648 .collect();
1649 family("roadmaps", s.roadmap_count(), s.valid_roadmaps(), invalid);
1650 }
1651 if !s.prompts.is_empty() {
1652 let invalid: Vec<(&str, &[String])> = s
1653 .invalid_prompts()
1654 .iter()
1655 .map(|p| (p.path.as_str(), p.error_codes.as_slice()))
1656 .collect();
1657 family("prompts", s.prompt_count(), s.valid_prompts(), invalid);
1658 }
1659 if !s.designs.is_empty() {
1660 let invalid: Vec<(&str, &[String])> = s
1661 .invalid_designs()
1662 .iter()
1663 .map(|d| (d.path.as_str(), d.error_codes.as_slice()))
1664 .collect();
1665 family("designs", s.design_count(), s.valid_designs(), invalid);
1666 }
1667
1668 if !s.unrecognized.is_empty() {
1669 let mut m = Map::new();
1670 m.insert("count".into(), json!(s.unrecognized_count()));
1671 m.insert(
1672 "files".into(),
1673 Value::Array(
1674 s.unrecognized
1675 .iter()
1676 .map(|u| {
1677 let mut fm = Map::new();
1678 fm.insert("file".into(), json!(u.path));
1679 fm.insert("name".into(), json!(u.name));
1680 fm.insert("confidence".into(), crate::pyjson::py_float(py_round(u.confidence, 2)));
1681 Value::Object(fm)
1682 })
1683 .collect(),
1684 ),
1685 );
1686 payload.insert("unrecognized".into(), Value::Object(m));
1687 }
1688
1689 if !s.relationship_counts.is_empty() {
1690 let mut m = Map::new();
1691 for (section, count) in &s.relationship_counts {
1692 m.insert(crate::spec::snake(section), json!(count));
1693 }
1694 payload.insert("relationships".into(), Value::Object(m));
1695 }
1696
1697 dumps_indent2(&Value::Object(payload))
1698}
1699
1700fn invalid_reason_line(path: &str, error_codes: &[String]) -> String {
1702 let reasons = if error_codes.is_empty() {
1703 "unknown".to_string()
1704 } else {
1705 error_codes.join(", ")
1706 };
1707 format!(" {} \u{2014} {reasons}", red(path))
1708}
1709
1710pub fn render_stats_human(s: &PortfolioStats) -> String {
1711 let mut lines: Vec<String> = vec![
1712 bold("Portfolio Overview"),
1713 "==================".to_string(),
1714 String::new(),
1715 format!("Features: {}", s.files_found()),
1716 format!("Requirements: {}", s.total_requirements()),
1717 format!("Metrics: {}", s.total_metrics()),
1718 format!("Risks: {}", s.total_risks()),
1719 String::new(),
1720 bold("Quality"),
1721 "=======".to_string(),
1722 String::new(),
1723 ];
1724
1725 let mut missing_block = |label: &str, names: &[&str]| {
1726 lines.push(format!("{label}: {}", names.len()));
1727 for name in names {
1728 lines.push(format!(" - {name}"));
1729 }
1730 };
1731 missing_block("Features Missing Metrics", &s.missing_metrics());
1732 missing_block("Features Missing Risks", &s.missing_risks());
1733 lines.push(format!(
1734 "Average Requirements Per Feature: {}",
1735 py_format_1f(s.average_requirements())
1736 ));
1737
1738 match s.largest_feature() {
1739 Some(f) => lines.push(format!(
1740 "Largest Feature: {} ({} requirements)",
1741 f.name, f.requirements
1742 )),
1743 None => lines.push("Largest Feature: (none)".to_string()),
1744 }
1745
1746 lines.push(String::new());
1747 lines.push(bold("Requirements by Feature"));
1748 lines.push("=======================".to_string());
1749 lines.push(String::new());
1750 let by_feature = s.requirements_by_feature();
1751 if !by_feature.is_empty() {
1752 let width = by_feature.iter().map(|f| f.name.chars().count()).max().unwrap_or(0) + 4;
1753 for f in &by_feature {
1754 lines.push(format!("{}{}", ljust(&f.name, width), f.requirements));
1755 }
1756 } else {
1757 lines.push("(none)".to_string());
1758 }
1759
1760 let invalid = s.invalid();
1761 if !invalid.is_empty() {
1762 lines.push(String::new());
1763 lines.push(bold(&format!("Invalid Features ({})", invalid.len())));
1764 for f in &invalid {
1765 lines.push(invalid_reason_line(&f.path, &f.error_codes));
1766 }
1767 }
1768
1769 if !s.decisions.is_empty() {
1770 lines.push(String::new());
1771 lines.push(bold("Decisions"));
1772 lines.push("=========".to_string());
1773 lines.push(String::new());
1774 lines.push(format!("Total: {}", s.decision_count()));
1775 let mut breakdown = |label: &str, counts: &[(String, usize)]| {
1776 lines.push(String::new());
1777 lines.push(bold(label));
1778 if counts.is_empty() {
1779 lines.push(" (none recorded)".to_string());
1780 } else {
1781 for (name, count) in counts {
1782 lines.push(format!(" - {name}: {count}"));
1783 }
1784 }
1785 };
1786 breakdown("Status", &s.decision_status_counts());
1787 breakdown("Category", &s.decision_category_counts());
1788 }
1789
1790 let mut family = |label: &str, underline: &str, count: usize, valid: usize, invalid_label: &str, invalid: &[&crate::stats::ValidityStat]| {
1791 lines.push(String::new());
1792 lines.push(bold(label));
1793 lines.push(underline.to_string());
1794 lines.push(String::new());
1795 lines.push(format!("Total: {count}"));
1796 lines.push(format!("Valid: {valid}"));
1797 if !invalid.is_empty() {
1798 lines.push(String::new());
1799 lines.push(bold(&format!("{invalid_label} ({})", invalid.len())));
1800 for r in invalid {
1801 lines.push(invalid_reason_line(&r.path, &r.error_codes));
1802 }
1803 }
1804 };
1805
1806 if !s.roadmaps.is_empty() {
1807 family("Roadmaps", "========", s.roadmap_count(), s.valid_roadmaps(), "Invalid Roadmaps", &s.invalid_roadmaps());
1808 }
1809 if !s.prompts.is_empty() {
1810 family("Prompts", "=======", s.prompt_count(), s.valid_prompts(), "Invalid Prompts", &s.invalid_prompts());
1811 }
1812 if !s.designs.is_empty() {
1813 family("Designs", "=======", s.design_count(), s.valid_designs(), "Invalid Designs", &s.invalid_designs());
1814 }
1815
1816 if !s.unrecognized.is_empty() {
1817 let count = s.unrecognized_count();
1818 let noun = if count == 1 { "document" } else { "documents" };
1819 lines.push(String::new());
1820 lines.push(bold("Unrecognized"));
1821 lines.push("============".to_string());
1822 lines.push(String::new());
1823 lines.push(format!(
1824 "{count} {noun} matched no known artifact schema (not errors — see ADR-010):"
1825 ));
1826 for u in &s.unrecognized {
1827 lines.push(format!(" {}", u.path));
1828 }
1829 }
1830
1831 if !s.relationship_counts.is_empty() {
1832 lines.push(String::new());
1833 lines.push(bold("Relationships"));
1834 lines.push("=============".to_string());
1835 lines.push(String::new());
1836 for (section, count) in &s.relationship_counts {
1837 lines.push(format!("Artifacts with {}: {count}", py_title(section)));
1838 }
1839 }
1840
1841 if s.is_empty() {
1842 lines.push(String::new());
1843 lines.push(EMPTY_CORPUS_HINT.to_string());
1844 }
1845
1846 lines.join("\n")
1847}
1848
1849pub fn render_portfolio_human(s: &PortfolioSummary) -> String {
1853 let mut lines: Vec<String> = vec![
1854 bold("Repository Summary"),
1855 "==================".to_string(),
1856 String::new(),
1857 format!("Directory: {}", s.directory),
1858 format!("Artifacts: {}", s.total_artifacts()),
1859 String::new(),
1860 bold("By Type"),
1861 "-------".to_string(),
1862 String::new(),
1863 ];
1864 for (type_name, count) in &s.by_type {
1865 if *count > 0 {
1866 lines.push(format!(" {:<14} {count}", py_title(type_name)));
1867 }
1868 }
1869
1870 lines.extend([
1871 String::new(),
1872 bold("Validation"),
1873 "----------".to_string(),
1874 String::new(),
1875 format!(" Valid: {}", s.valid_artifacts),
1876 format!(" Invalid: {}", s.invalid_artifacts),
1877 String::new(),
1878 bold("Completeness"),
1879 "------------".to_string(),
1880 String::new(),
1881 format!(
1882 " {} ({} / {} recommended slots filled)",
1883 py_format_percent0(s.completeness()),
1884 s.filled_slots,
1885 s.recommended_slots
1886 ),
1887 String::new(),
1888 bold("Relationships"),
1889 "-------------".to_string(),
1890 String::new(),
1891 format!(" Total: {}", s.relationships.total),
1892 format!(" Valid: {}", s.relationships.valid),
1893 format!(" Broken: {}", s.relationships.broken),
1894 format!(" Orphaned: {}", s.relationships.orphaned),
1895 format!(" Coverage: {}", py_format_percent0(s.relationships.coverage)),
1896 ]);
1897
1898 if !s.attention.is_empty() {
1899 lines.extend([
1900 String::new(),
1901 bold(&format!("Attention ({} items)", s.attention.len())),
1902 "----------".to_string(),
1903 String::new(),
1904 ]);
1905 for item in &s.attention {
1906 let icon = if item.severity == "error" {
1907 red("\u{2717}")
1908 } else {
1909 yellow("!")
1910 };
1911 lines.push(format!(" {icon} {}", item.identifier));
1912 lines.push(format!(" {}", item.message));
1913 }
1914 } else {
1915 lines.push(String::new());
1916 lines.push(green("\u{2713} No attention items."));
1917 }
1918
1919 let score = s.health_score();
1920 let colored = if score >= 80 {
1921 green(&score.to_string())
1922 } else if score >= 60 {
1923 yellow(&score.to_string())
1924 } else {
1925 red(&score.to_string())
1926 };
1927 lines.extend([
1928 String::new(),
1929 bold("Health Score"),
1930 "------------".to_string(),
1931 String::new(),
1932 format!(" {colored} / 100"),
1933 ]);
1934
1935 if s.total_artifacts() == 0 {
1936 lines.push(String::new());
1937 lines.push(EMPTY_CORPUS_HINT.to_string());
1938 }
1939
1940 lines.join("\n")
1941}
1942
1943pub fn render_index_human(index: &crate::index::RepositoryIndex) -> String {
1945 let mut lines = vec![
1946 bold("Repository Index"),
1947 "================".to_string(),
1948 String::new(),
1949 format!("Directory: {}", index.directory),
1950 format!("Artifacts: {}", index.artifacts.len()),
1951 String::new(),
1952 ];
1953 if index.artifacts.is_empty() {
1954 lines.push("(none)".to_string());
1955 return lines.join("\n");
1956 }
1957 let title_of = |e: &crate::index::IndexEntry| -> String {
1959 e.title.clone().unwrap_or_else(|| "\u{2014}".to_string())
1960 };
1961 let width = |f: &dyn Fn(&crate::index::IndexEntry) -> usize| -> usize {
1962 index.artifacts.iter().map(f).max().unwrap_or(0)
1963 };
1964 let id_w = width(&|e| e.id.chars().count());
1965 let type_w = width(&|e| e.artifact_type.chars().count());
1966 let title_w = width(&|e| title_of(e).chars().count());
1967 for e in &index.artifacts {
1968 lines.push(format!(
1969 " {} {} {} {}",
1970 ljust(&e.id, id_w),
1971 ljust(&e.artifact_type, type_w),
1972 ljust(&title_of(e), title_w),
1973 e.path
1974 ));
1975 }
1976 lines.join("\n")
1977}
1978
1979pub fn render_index_json(index: &crate::index::RepositoryIndex) -> String {
1982 let artifacts: Vec<Value> = index
1983 .artifacts
1984 .iter()
1985 .map(|e| {
1986 let mut m = Map::new();
1987 m.insert("id".into(), json!(e.id));
1988 m.insert("type".into(), json!(e.artifact_type));
1989 m.insert("title".into(), json!(e.title));
1990 m.insert("path".into(), json!(e.path));
1991 m.insert("aliases".into(), json!(e.aliases));
1992 Value::Object(m)
1993 })
1994 .collect();
1995 let mut payload = Map::new();
1996 payload.insert("schema_version".into(), json!("1"));
1997 payload.insert("directory".into(), json!(index.directory));
1998 payload.insert("recursive".into(), json!(index.recursive));
1999 payload.insert("artifact_count".into(), json!(index.artifacts.len()));
2000 payload.insert("artifacts".into(), Value::Array(artifacts));
2001 dumps_indent2(&Value::Object(payload))
2002}
2003
2004pub fn render_portfolio_json(s: &PortfolioSummary) -> String {
2006 dumps_indent2(&portfolio_summary_value(s))
2007}
2008
2009pub fn portfolio_summary_value(s: &PortfolioSummary) -> Value {
2013 let mut payload = Map::new();
2014 payload.insert("schema_version".into(), json!("1"));
2015 payload.insert("directory".into(), json!(s.directory));
2016 payload.insert("recursive".into(), json!(s.recursive));
2017 payload.insert("empty".into(), json!(s.total_artifacts() == 0));
2018
2019 let mut by_type = Map::new();
2020 for (t, c) in &s.by_type {
2021 by_type.insert(t.clone(), json!(c));
2022 }
2023 let mut artifacts = Map::new();
2024 artifacts.insert("total".into(), json!(s.total_artifacts()));
2025 artifacts.insert("by_type".into(), Value::Object(by_type));
2026 artifacts.insert("unknown_paths".into(), json!(s.unknown_paths));
2027 payload.insert("artifacts".into(), Value::Object(artifacts));
2028
2029 let mut validation = Map::new();
2030 validation.insert("valid".into(), json!(s.valid_artifacts));
2031 validation.insert("invalid".into(), json!(s.invalid_artifacts));
2032 payload.insert("validation".into(), Value::Object(validation));
2033
2034 let mut completeness = Map::new();
2035 completeness.insert("recommended_slots".into(), json!(s.recommended_slots));
2036 completeness.insert("filled".into(), json!(s.filled_slots));
2037 completeness.insert("ratio".into(), py_float(s.completeness()));
2038 payload.insert("completeness".into(), Value::Object(completeness));
2039
2040 let mut relationships = Map::new();
2041 relationships.insert("total".into(), json!(s.relationships.total));
2042 relationships.insert("valid".into(), json!(s.relationships.valid));
2043 relationships.insert("broken".into(), json!(s.relationships.broken));
2044 relationships.insert("orphaned".into(), json!(s.relationships.orphaned));
2045 relationships.insert("coverage".into(), py_float(s.relationships.coverage));
2046 payload.insert("relationships".into(), Value::Object(relationships));
2047
2048 let attention: Vec<Value> = s
2049 .attention
2050 .iter()
2051 .map(|item| {
2052 let mut m = Map::new();
2053 m.insert("path".into(), json!(item.path));
2054 m.insert("identifier".into(), json!(item.identifier));
2055 m.insert("severity".into(), json!(item.severity));
2056 m.insert("code".into(), json!(item.code));
2057 m.insert("message".into(), json!(item.message));
2058 Value::Object(m)
2059 })
2060 .collect();
2061 payload.insert("attention".into(), Value::Array(attention));
2062
2063 let mut health = Map::new();
2064 health.insert("score".into(), json!(s.health_score()));
2065 payload.insert("health".into(), Value::Object(health));
2066
2067 let mut validation_status = Map::new();
2068 validation_status.insert("artifacts_ok".into(), json!(s.invalid_artifacts == 0));
2069 validation_status.insert("relationships_ok".into(), json!(s.relationships_ok));
2070 validation_status.insert(
2071 "ok".into(),
2072 json!(s.invalid_artifacts == 0 && s.relationships_ok),
2073 );
2074 payload.insert("validation_status".into(), Value::Object(validation_status));
2075
2076 Value::Object(payload)
2077}
2078
2079pub fn render_coverage_human(report: &CoverageReport) -> String {
2083 let (unscheduled, unapplied, unscoped) = report.counts();
2084 let mut lines: Vec<String> = vec![
2085 format!("Traceability coverage \u{2014} {}", report.directory),
2086 String::new(),
2087 ];
2088 if report.gaps.is_empty() {
2089 lines.push(
2090 "\u{2713} No coverage gaps \u{2014} every artifact has its expected traceability edge."
2091 .to_string(),
2092 );
2093 return lines.join("\n");
2094 }
2095 let headings = [
2096 (
2097 GAP_UNSCHEDULED,
2098 "Unscheduled requirements (no roadmap schedules them)",
2099 ),
2100 (
2101 GAP_UNAPPLIED,
2102 "Unapplied decisions (no requirement or roadmap applies them)",
2103 ),
2104 (GAP_UNSCOPED, "Unscoped roadmaps (reference no requirement)"),
2105 ];
2106 for (gap_class, heading) in headings {
2107 let members: Vec<_> = report.gaps.iter().filter(|g| g.gap == gap_class).collect();
2108 if members.is_empty() {
2109 continue;
2110 }
2111 lines.push(format!("{heading}: {}", members.len()));
2112 for gap in members {
2113 lines.push(format!(" {} {}", gap.id, gap.path));
2114 }
2115 lines.push(String::new());
2116 }
2117 let total = report.gaps.len();
2118 lines.push(format!(
2119 "{total} coverage gap{} ({unscheduled} unscheduled, {unapplied} unapplied, \
2120{unscoped} unscoped) \u{2014} advisory, not a build failure.",
2121 if total != 1 { "s" } else { "" }
2122 ));
2123 lines.join("\n")
2124}
2125
2126pub fn render_coverage_json(report: &CoverageReport) -> String {
2129 let (unscheduled, unapplied, unscoped) = report.counts();
2130 let mut payload = Map::new();
2131 payload.insert("schema_version".into(), json!("1"));
2132 payload.insert("directory".into(), json!(report.directory));
2133 let gaps: Vec<Value> = report
2134 .gaps
2135 .iter()
2136 .map(|g| {
2137 let mut m = Map::new();
2138 m.insert("path".into(), json!(g.path));
2139 m.insert("id".into(), json!(g.id));
2140 m.insert("type".into(), json!(g.artifact_type));
2141 m.insert("gap".into(), json!(g.gap));
2142 m.insert("missing".into(), json!(g.missing));
2143 Value::Object(m)
2144 })
2145 .collect();
2146 payload.insert("gaps".into(), Value::Array(gaps));
2147 let mut summary = Map::new();
2148 summary.insert("unscheduled".into(), json!(unscheduled));
2149 summary.insert("unapplied".into(), json!(unapplied));
2150 summary.insert("unscoped".into(), json!(unscoped));
2151 summary.insert("total".into(), json!(report.gaps.len()));
2152 payload.insert("summary".into(), Value::Object(summary));
2153 dumps_indent2_no_ascii(&Value::Object(payload))
2154}
2155
2156pub fn render_decisions_for_human(result: &ScopeLookupResult) -> String {
2161 if result.decisions.is_empty() {
2162 if !result.in_repository {
2163 return format!(
2164 "{} is outside the repository \u{2014} no governing decisions.",
2165 py_repr_str(&result.query)
2166 );
2167 }
2168 return format!(
2169 "No decisions declare scope over {}.",
2170 py_repr_str(&result.query)
2171 );
2172 }
2173 let dash = "\u{2014}";
2174 let id_w = result
2175 .decisions
2176 .iter()
2177 .map(|d| d.id.chars().count())
2178 .max()
2179 .unwrap_or(0);
2180 let status_w = result
2181 .decisions
2182 .iter()
2183 .map(|d| {
2184 if d.status.is_empty() {
2185 dash.chars().count()
2186 } else {
2187 d.status.chars().count()
2188 }
2189 })
2190 .max()
2191 .unwrap_or(0);
2192 let indent = format!("{} {} ", " ".repeat(id_w), " ".repeat(status_w));
2193 let mut lines: Vec<String> = Vec::new();
2194 for d in &result.decisions {
2195 let status = if d.status.is_empty() { dash } else { &d.status };
2196 let title = if d.title.is_empty() { dash } else { &d.title };
2197 lines.push(format!(
2198 "{} {} {title}",
2199 ljust(&d.id, id_w),
2200 ljust(status, status_w)
2201 ));
2202 lines.push(format!("{indent}\u{21b3} applies to: {}", d.matching_entry));
2203 }
2204 lines.push(String::new());
2205 lines.push(format!(
2206 "{} decision(s) govern {}.",
2207 result.decisions.len(),
2208 py_repr_str(&result.query)
2209 ));
2210 lines.join("\n")
2211}
2212
2213pub fn render_decisions_for_json(result: &ScopeLookupResult) -> String {
2216 dumps_indent2(&scope_lookup_value(result))
2217}
2218
2219fn priority_label(priority: i64) -> &'static str {
2222 match priority {
2223 1 => "Invalid artifacts",
2224 2 => "Broken relationships",
2225 3 => "Unrecognized artifacts",
2226 4 => "Missing recommended information",
2227 5 => "Write cadence",
2228 6 => "Possible drift (review recommended)",
2229 _ => "",
2230 }
2231}
2232
2233fn review_issue_value(i: &ReviewIssue) -> Value {
2234 let mut m = Map::new();
2235 m.insert("priority".into(), json!(i.priority));
2236 m.insert("severity".into(), json!(i.severity));
2237 m.insert("path".into(), json!(i.path));
2238 m.insert("identifier".into(), json!(i.identifier));
2239 m.insert("code".into(), json!(i.code));
2240 m.insert("message".into(), json!(i.message));
2241 m.insert("action".into(), json!(i.action));
2242 m.insert("impact".into(), json!(i.impact));
2243 Value::Object(m)
2244}
2245
2246pub fn render_review_json(r: &ReviewReport) -> String {
2247 let p = &r.portfolio;
2248 let mut payload = Map::new();
2249 payload.insert("schema_version".into(), json!("1"));
2250 payload.insert("directory".into(), json!(r.directory));
2251 payload.insert("recursive".into(), json!(r.recursive));
2252 payload.insert("ok".into(), json!(r.ok()));
2253 payload.insert("empty".into(), json!(p.total_artifacts() == 0));
2254
2255 let mut by_type = Map::new();
2256 for (t, c) in &p.by_type {
2257 by_type.insert(t.clone(), json!(c));
2258 }
2259 let mut artifacts = Map::new();
2260 artifacts.insert("total".into(), json!(p.total_artifacts()));
2261 artifacts.insert("by_type".into(), Value::Object(by_type));
2262 artifacts.insert("unknown_paths".into(), json!(p.unknown_paths));
2263 payload.insert("artifacts".into(), Value::Object(artifacts));
2264
2265 let mut validation = Map::new();
2266 validation.insert("valid".into(), json!(p.valid_artifacts));
2267 validation.insert("invalid".into(), json!(p.invalid_artifacts));
2268 payload.insert("validation".into(), Value::Object(validation));
2269
2270 let mut relationships = Map::new();
2271 relationships.insert("total".into(), json!(p.relationships.total));
2272 relationships.insert("valid".into(), json!(p.relationships.valid));
2273 relationships.insert("broken".into(), json!(p.relationships.broken));
2274 relationships.insert("orphaned".into(), json!(p.relationships.orphaned));
2275 relationships.insert("coverage".into(), py_float(p.relationships.coverage));
2276 payload.insert("relationships".into(), Value::Object(relationships));
2277
2278 let mut health = Map::new();
2279 health.insert("score".into(), json!(p.health_score()));
2280 payload.insert("health".into(), Value::Object(health));
2281
2282 payload.insert(
2283 "issues".into(),
2284 Value::Array(r.issues.iter().map(review_issue_value).collect()),
2285 );
2286 payload.insert("actions".into(), json!(r.actions()));
2287 dumps_indent2(&Value::Object(payload))
2288}
2289
2290pub fn render_review_human(r: &ReviewReport) -> String {
2291 let p = &r.portfolio;
2292 let mut lines: Vec<String> = vec![
2293 bold("Repository Review"),
2294 "=================".to_string(),
2295 String::new(),
2296 format!("Directory: {}", r.directory),
2297 format!("Artifacts: {}", p.total_artifacts()),
2298 String::new(),
2299 ];
2300 for (type_name, count) in &p.by_type {
2301 if *count > 0 {
2302 lines.push(format!(" {:<14} {count}", py_title(type_name)));
2303 }
2304 }
2305
2306 lines.extend([
2307 String::new(),
2308 bold("Validation"),
2309 "----------".to_string(),
2310 String::new(),
2311 format!(" Valid: {}", p.valid_artifacts),
2312 format!(" Invalid: {}", p.invalid_artifacts),
2313 String::new(),
2314 bold("Relationships"),
2315 "-------------".to_string(),
2316 String::new(),
2317 format!(" Total: {}", p.relationships.total),
2318 format!(" Valid: {}", p.relationships.valid),
2319 format!(" Broken: {}", p.relationships.broken),
2320 ]);
2321
2322 if !r.issues.is_empty() {
2323 lines.extend([
2324 String::new(),
2325 bold(&format!("Issues ({})", r.issues.len())),
2326 "------".to_string(),
2327 ]);
2328 for priority in 1..=6 {
2329 let group: Vec<&ReviewIssue> =
2330 r.issues.iter().filter(|i| i.priority == priority).collect();
2331 if group.is_empty() {
2332 continue;
2333 }
2334 lines.push(String::new());
2335 lines.push(format!(
2336 " Priority {priority} \u{2014} {}:",
2337 priority_label(priority)
2338 ));
2339 for issue in group {
2340 let icon = match issue.severity.as_str() {
2341 "error" => red("\u{2717}"),
2342 "warning" => yellow("!"),
2343 _ => "\u{00b7}".to_string(),
2344 };
2345 lines.push(format!(" {icon} {}", issue.identifier));
2346 lines.push(format!(" {}", issue.message));
2347 }
2348 }
2349 lines.extend([
2350 String::new(),
2351 bold("Suggested Actions"),
2352 "-----------------".to_string(),
2353 String::new(),
2354 ]);
2355 for (n, action) in r.actions().iter().enumerate() {
2356 lines.push(format!(" {}. {action}", n + 1));
2357 }
2358 } else {
2359 lines.push(String::new());
2360 lines.push(green("\u{2713} Nothing needs attention."));
2361 }
2362
2363 let score = p.health_score();
2364 let colored = if score >= 80 {
2365 green(&score.to_string())
2366 } else if score >= 60 {
2367 yellow(&score.to_string())
2368 } else {
2369 red(&score.to_string())
2370 };
2371 lines.extend([
2372 String::new(),
2373 bold("Health Score"),
2374 "------------".to_string(),
2375 String::new(),
2376 format!(" {colored} / 100"),
2377 ]);
2378 if p.total_artifacts() == 0 {
2379 lines.push(String::new());
2380 lines.push(EMPTY_CORPUS_HINT.to_string());
2381 }
2382 lines.join("\n")
2383}
2384
2385pub fn render_review_sarif(r: &ReviewReport) -> String {
2386 let results: Vec<SarifResult> = r
2387 .issues
2388 .iter()
2389 .map(|issue| SarifResult {
2390 rule_id: issue.code.clone(),
2391 level: sarif_level(&issue.severity),
2392 message: if issue.action.is_empty() {
2393 issue.message.clone()
2394 } else {
2395 format!("{} \u{2014} {}", issue.message, issue.action)
2396 },
2397 uri: quote_uri(&issue.path),
2398 line: None,
2399 })
2400 .collect();
2401 sarif_document(results)
2402}
2403
2404pub fn render_gate_human(report: &GateReport) -> String {
2407 let blocking = report.blocking();
2408 let advisory = report.advisory();
2409 let mut lines: Vec<String> = vec![
2410 bold("Corpus Gate"),
2411 "===========".to_string(),
2412 String::new(),
2413 format!("Directory: {}", report.directory),
2414 format!("Blocking: {}", blocking.len()),
2415 format!("Advisory: {}", advisory.len()),
2416 ];
2417 if let Some(coverage) = &report.code_coverage {
2418 lines.push(format!(
2419 "Code adoption: {}/{} ({:.1}%)",
2420 coverage.constrained_decisions,
2421 coverage.live_decisions,
2422 coverage.corpus_adoption_percent
2423 ));
2424 lines.push(format!(
2425 "Eligible coverage: {}/{} ({:.1}%; {} rules, {} unclassified)",
2426 coverage.constrained_decisions,
2427 coverage.eligible_decisions,
2428 coverage.eligible_coverage_percent,
2429 coverage.active_rules,
2430 coverage.unclassified_decisions
2431 ));
2432 }
2433
2434 let mut emit_group = |group: &[&GateFinding], title: &str, icon: &str| {
2435 if group.is_empty() {
2436 return;
2437 }
2438 let header = format!("{title} ({})", group.len());
2439 lines.push(String::new());
2440 lines.push(bold(&header));
2441 lines.push("-".repeat(header.chars().count()));
2442 for f in group {
2443 lines.push(format!(" {icon} {}", loc(&f.path, f.line)));
2444 lines.push(format!(" [{}] {}: {}", f.source, f.code, f.message));
2445 }
2446 };
2447
2448 emit_group(&blocking, "Blocking", &red("\u{2717}"));
2449 emit_group(&advisory, "Advisory", &yellow("!"));
2450
2451 lines.push(String::new());
2452 if report.ok() {
2453 lines.push(green("\u{2713} Gate passed \u{2014} nothing blocking."));
2454 } else {
2455 lines.push(red(&format!(
2456 "\u{2717} Gate failed \u{2014} {} blocking finding(s).",
2457 blocking.len()
2458 )));
2459 }
2460 lines.join("\n")
2461}
2462
2463fn gate_finding_value(f: &GateFinding) -> Value {
2464 let mut m = Map::new();
2465 m.insert("source".into(), json!(f.source));
2466 m.insert("code".into(), json!(f.code));
2467 m.insert("severity".into(), json!(f.severity));
2468 m.insert("enforcement".into(), json!(f.enforcement));
2469 m.insert("path".into(), json!(f.path));
2470 m.insert("line".into(), json!(f.line));
2471 m.insert("message".into(), json!(f.message));
2472 Value::Object(m)
2473}
2474
2475pub fn render_gate_json(report: &GateReport) -> String {
2476 let mut payload = Map::new();
2477 payload.insert("schema_version".into(), json!("1"));
2478 payload.insert("directory".into(), json!(report.directory));
2479 payload.insert("recursive".into(), json!(report.recursive));
2480 payload.insert("ok".into(), json!(report.ok()));
2481 payload.insert("blocking_count".into(), json!(report.blocking().len()));
2482 payload.insert("advisory_count".into(), json!(report.advisory().len()));
2483 payload.insert(
2484 "findings".into(),
2485 Value::Array(report.findings.iter().map(gate_finding_value).collect()),
2486 );
2487 if let Some(coverage) = &report.code_coverage {
2488 payload.insert(
2489 "code_coverage".into(),
2490 json!({
2491 "live_decisions": coverage.live_decisions,
2492 "classified_decisions": coverage.classified_decisions,
2493 "unclassified_decisions": coverage.unclassified_decisions,
2494 "eligible_decisions": coverage.eligible_decisions,
2495 "constrained_decisions": coverage.constrained_decisions,
2496 "active_rules": coverage.active_rules,
2497 "percent": coverage.corpus_adoption_percent,
2498 "metric": "corpus_adoption",
2499 "corpus_adoption_percent": coverage.corpus_adoption_percent,
2500 "eligible_coverage_percent": coverage.eligible_coverage_percent,
2501 }),
2502 );
2503 }
2504 dumps_indent2(&Value::Object(payload))
2505}
2506
2507pub fn render_gate_sarif(report: &GateReport) -> String {
2513 let results: Vec<SarifResult> = report
2514 .findings
2515 .iter()
2516 .map(|f| SarifResult {
2517 rule_id: f.code.clone(),
2518 level: sarif_level(&f.severity),
2519 message: f.message.clone(),
2520 uri: quote_uri(&f.path),
2521 line: f.line,
2522 })
2523 .collect();
2524 sarif_document(results)
2525}
2526
2527pub fn render_sentry_human(report: &SentryReport) -> String {
2530 let mut lines = vec![
2531 bold("Code Sentry"),
2532 "===========".to_string(),
2533 String::new(),
2534 format!("Corpus: {}", report.corpus),
2535 format!("Repository: {}", report.repository),
2536 format!(
2537 "Corpus adoption: {}/{} ({:.1}%)",
2538 report.constrained_decisions,
2539 report.live_decisions,
2540 report.corpus_adoption_percent()
2541 ),
2542 format!(
2543 "Eligibility: {} eligible, {} constrained, {} unclassified",
2544 report.eligible_decisions,
2545 report.constrained_decisions,
2546 report.unclassified_decisions()
2547 ),
2548 format!(
2549 "Eligible coverage: {}/{} ({:.1}%)",
2550 report.constrained_decisions,
2551 report.eligible_decisions,
2552 report.eligible_coverage_percent()
2553 ),
2554 format!("Active rules: {}", report.active_rules),
2555 format!("Violations: {}", report.findings.len()),
2556 ];
2557 for finding in &report.findings {
2558 lines.push(String::new());
2559 lines.push(format!(" {} {}", red("\u{2717}"), loc(&finding.path, finding.line)));
2560 lines.push(format!(
2561 " [{}] {}",
2562 finding.rule_id.as_deref().unwrap_or(finding.code),
2563 finding.message
2564 ));
2565 lines.push(format!(" decision: {}", finding.decision_path));
2566 }
2567 lines.push(String::new());
2568 if report.ok() {
2569 lines.push(green("\u{2713} Sentry passed."));
2570 } else {
2571 lines.push(red(&format!(
2572 "\u{2717} Sentry failed \u{2014} {} finding(s).",
2573 report.findings.len()
2574 )));
2575 }
2576 lines.join("\n")
2577}
2578
2579pub fn render_sentry_json(report: &SentryReport) -> String {
2580 let findings: Vec<Value> = report
2581 .findings
2582 .iter()
2583 .map(|finding| {
2584 json!({
2585 "code": finding.code,
2586 "decision_path": finding.decision_path,
2587 "rule_id": finding.rule_id,
2588 "path": finding.path,
2589 "line": finding.line,
2590 "message": finding.message,
2591 })
2592 })
2593 .collect();
2594 dumps_indent2(&json!({
2595 "schema_version": "1",
2596 "corpus": report.corpus,
2597 "repository": report.repository,
2598 "base": report.base,
2599 "full_tree": report.full_tree,
2600 "ok": report.ok(),
2601 "coverage": {
2602 "live_decisions": report.live_decisions,
2603 "classified_decisions": report.classified_decisions,
2604 "unclassified_decisions": report.unclassified_decisions(),
2605 "eligible_decisions": report.eligible_decisions,
2606 "constrained_decisions": report.constrained_decisions,
2607 "active_rules": report.active_rules,
2608 "percent": report.corpus_adoption_percent(),
2609 "metric": "corpus_adoption",
2610 "corpus_adoption_percent": report.corpus_adoption_percent(),
2611 "eligible_coverage_percent": report.eligible_coverage_percent(),
2612 },
2613 "findings": findings,
2614 }))
2615}
2616
2617pub fn render_sentry_sarif(report: &SentryReport) -> String {
2618 sarif_document(
2619 report
2620 .findings
2621 .iter()
2622 .map(|finding| SarifResult {
2623 rule_id: finding.code.to_string(),
2624 level: "error",
2625 message: format!(
2626 "{} (decision: {})",
2627 finding.message, finding.decision_path
2628 ),
2629 uri: quote_uri(&finding.path),
2630 line: finding.line,
2631 })
2632 .collect(),
2633 )
2634}
2635
2636pub fn render_doctor_human(report: &DoctorReport) -> String {
2639 let mut lines: Vec<String> = vec![
2641 format!("Repository health: {}", report.directory),
2642 String::new(),
2643 ];
2644 if report.findings.is_empty() {
2645 lines.push("\u{2713} No issues found.".to_string());
2646 return lines.join("\n");
2647 }
2648 lines.push(format!(
2649 "{} error(s), {} warning(s)",
2650 report.error_count(),
2651 report.warning_count()
2652 ));
2653 lines.push(String::new());
2654 for finding in &report.findings {
2655 let label = if finding.severity == "error" {
2658 "ERROR "
2659 } else {
2660 "WARNING"
2661 };
2662 lines.push(format!("{label} {}", finding.path));
2663 lines.push(format!(" [{}] {}", finding.code, finding.problem));
2664 lines.push(format!(" fix: {}", finding.fix));
2665 lines.push(String::new());
2666 }
2667 lines.push(if report.ok() {
2668 "\u{2713} No errors (warnings are advisory).".to_string()
2669 } else {
2670 "\u{2717} Errors present.".to_string()
2671 });
2672 lines.join("\n")
2673}
2674
2675fn doctor_finding_value(f: &DoctorFinding) -> Value {
2676 let mut m = Map::new();
2677 m.insert("path".into(), json!(f.path));
2678 m.insert("code".into(), json!(f.code));
2679 m.insert("severity".into(), json!(f.severity));
2680 m.insert("problem".into(), json!(f.problem));
2681 m.insert("fix".into(), json!(f.fix));
2682 Value::Object(m)
2683}
2684
2685pub fn render_doctor_json(report: &DoctorReport) -> String {
2688 let mut payload = Map::new();
2689 payload.insert("schema_version".into(), json!("1"));
2690 payload.insert("directory".into(), json!(report.directory));
2691 payload.insert("hub_threshold".into(), json!(report.hub_threshold));
2692 payload.insert("ok".into(), json!(report.ok()));
2693 let mut summary = Map::new();
2694 summary.insert("errors".into(), json!(report.error_count()));
2695 summary.insert("warnings".into(), json!(report.warning_count()));
2696 payload.insert("summary".into(), Value::Object(summary));
2697 payload.insert(
2698 "findings".into(),
2699 Value::Array(report.findings.iter().map(doctor_finding_value).collect()),
2700 );
2701 dumps_indent2_no_ascii(&Value::Object(payload))
2702}
2703
2704pub fn render_export_json(export: &CorpusExport) -> String {
2707 let mut corpus = Map::new();
2708 corpus.insert("name".into(), json!(export.corpus_name));
2709 corpus.insert("rac_version".into(), json!(export.rac_version));
2712 corpus.insert("artifact_count".into(), json!(export.artifact_count()));
2713
2714 let artifacts: Vec<Value> = export
2715 .artifacts
2716 .iter()
2717 .map(|a| {
2718 let mut m = Map::new();
2719 m.insert("id".into(), json!(a.id));
2720 m.insert("aliases".into(), json!(a.aliases));
2721 m.insert("type".into(), json!(a.artifact_type));
2722 m.insert("status".into(), json!(a.status));
2723 m.insert("title".into(), json!(a.title));
2724 m.insert("path".into(), json!(a.path));
2725 m.insert("body_html".into(), json!(a.body_html));
2726 Value::Object(m)
2727 })
2728 .collect();
2729
2730 let relationships: Vec<Value> = export
2731 .relationships
2732 .iter()
2733 .map(|e| {
2734 let mut m = Map::new();
2735 m.insert("from".into(), json!(e.from));
2736 m.insert("to".into(), json!(e.to));
2737 m.insert("type".into(), json!(e.edge_type));
2738 Value::Object(m)
2739 })
2740 .collect();
2741
2742 let mut payload = Map::new();
2743 payload.insert("schema_version".into(), json!("1"));
2744 payload.insert("corpus".into(), Value::Object(corpus));
2745 payload.insert("artifacts".into(), Value::Array(artifacts));
2746 payload.insert("relationships".into(), Value::Array(relationships));
2747 dumps_indent2(&Value::Object(payload))
2748}
2749
2750fn agent_rules_icon(state: &str) -> String {
2755 match state {
2756 crate::agent_rules::STATE_WRITTEN => "+".to_string(),
2757 crate::agent_rules::STATE_UPDATED => "~".to_string(),
2758 crate::agent_rules::STATE_IN_SYNC => green("\u{2713}"),
2759 crate::agent_rules::STATE_STALE | crate::agent_rules::STATE_MISSING => {
2760 red("\u{2717}")
2761 }
2762 _ => "\u{00b7}".to_string(),
2763 }
2764}
2765
2766pub fn render_agent_rules_human(result: &crate::agent_rules::AgentRulesResult) -> String {
2767 let checking = result.mode == "check";
2768 let title = if checking {
2769 "Agent Rules \u{2014} drift check"
2770 } else {
2771 "Agent Rules"
2772 };
2773 let mut lines = vec![
2774 bold(title),
2775 "=".repeat(title.chars().count()),
2776 String::new(),
2777 format!("Corpus digest: {}", result.digest),
2778 format!("Output root: {}", result.root),
2779 String::new(),
2780 ];
2781 for f in &result.files {
2782 lines.push(format!(" {} {} [{}]", agent_rules_icon(f.state), f.path, f.state));
2783 }
2784 lines.push(String::new());
2785 if checking {
2786 if result.drifted() {
2787 let stale = result
2788 .files
2789 .iter()
2790 .filter(|f| {
2791 f.state == crate::agent_rules::STATE_STALE
2792 || f.state == crate::agent_rules::STATE_MISSING
2793 })
2794 .count();
2795 lines.push(red(&format!(
2796 "\u{2717} Drift \u{2014} {stale} file(s) stale or missing the block."
2797 )));
2798 lines.push(" Regenerate: decided export --agent-rules".to_string());
2799 } else {
2800 lines.push(green(
2801 "\u{2713} In sync \u{2014} every present target matches the corpus.",
2802 ));
2803 }
2804 } else {
2805 let written = result
2806 .files
2807 .iter()
2808 .filter(|f| {
2809 f.state == crate::agent_rules::STATE_WRITTEN
2810 || f.state == crate::agent_rules::STATE_UPDATED
2811 })
2812 .count();
2813 if written > 0 {
2814 lines.push(green(&format!("\u{2713} Wrote/updated {written} file(s).")));
2815 } else {
2816 lines.push(green(
2817 "\u{2713} All targets already in sync \u{2014} nothing to write.",
2818 ));
2819 }
2820 }
2821 lines.join("\n")
2822}
2823
2824pub fn render_agent_rules_json(result: &crate::agent_rules::AgentRulesResult) -> String {
2826 let files: Vec<Value> = result
2827 .files
2828 .iter()
2829 .map(|f| {
2830 let mut m = Map::new();
2831 m.insert("client".into(), json!(f.client));
2832 m.insert("path".into(), json!(f.path));
2833 m.insert("state".into(), json!(f.state));
2834 Value::Object(m)
2835 })
2836 .collect();
2837 let mut payload = Map::new();
2838 payload.insert("mode".into(), json!(result.mode));
2839 payload.insert("digest".into(), json!(result.digest));
2840 payload.insert("root".into(), json!(result.root));
2841 payload.insert("files".into(), Value::Array(files));
2842 dumps_indent2(&Value::Object(payload))
2843}
2844
2845pub fn render_documents_jsonl(export: &DocumentsExport) -> String {
2846 export
2847 .documents
2848 .iter()
2849 .map(|d| {
2850 let mut meta = Map::new();
2851 meta.insert("path".into(), json!(d.path));
2852 meta.insert("aliases".into(), json!(d.aliases));
2853 meta.insert("tags".into(), json!(d.tags));
2854 meta.insert("source".into(), json!(export.corpus_name));
2855 let mut m = Map::new();
2856 m.insert("schema_version".into(), json!("1"));
2857 m.insert("id".into(), json!(d.id));
2858 m.insert("type".into(), json!(d.artifact_type));
2859 m.insert("status".into(), json!(d.status));
2860 m.insert("title".into(), json!(d.title));
2861 m.insert("text".into(), json!(d.text));
2862 m.insert("metadata".into(), Value::Object(meta));
2863 dumps_compact(&Value::Object(m))
2864 })
2865 .collect::<Vec<_>>()
2866 .join("\n")
2867}
2868
2869pub fn render_graph_json(export: &GraphExport) -> String {
2870 let nodes: Vec<Value> = export
2871 .nodes
2872 .iter()
2873 .map(|n| {
2874 let mut m = Map::new();
2875 m.insert("id".into(), json!(n.id));
2876 m.insert("type".into(), json!(n.artifact_type));
2877 m.insert("status".into(), json!(n.status));
2878 m.insert("title".into(), json!(n.title));
2879 Value::Object(m)
2880 })
2881 .collect();
2882 let edges: Vec<Value> = export
2883 .edges
2884 .iter()
2885 .map(|e| {
2886 let mut m = Map::new();
2887 m.insert("source".into(), json!(e.source));
2888 m.insert("target".into(), json!(e.target));
2889 m.insert("type".into(), json!(e.edge_type));
2890 m.insert("directed".into(), json!(e.directed));
2891 m.insert("resolved".into(), json!(e.resolved));
2892 m.insert("external".into(), json!(e.external));
2893 m.insert("provider".into(), json!(e.provider));
2894 Value::Object(m)
2895 })
2896 .collect();
2897 let mut payload = Map::new();
2898 payload.insert("schema_version".into(), json!("1"));
2899 payload.insert("source".into(), json!(export.corpus_name));
2900 payload.insert("nodes".into(), Value::Array(nodes));
2901 payload.insert("edges".into(), Value::Array(edges));
2902 dumps_indent2(&Value::Object(payload))
2903}
2904
2905pub fn render_resolve_human(artifact: &ResolvedArtifact) -> String {
2910 format!(
2911 "{}\n\nType: {}\nTitle: {}\nPath: {}",
2912 bold(&artifact.id),
2913 artifact.artifact_type,
2914 artifact.title.as_deref().filter(|t| !t.is_empty()).unwrap_or("\u{2014}"),
2915 artifact.path
2916 )
2917}
2918
2919pub fn resolution_error_value(result: &ResolutionResult) -> Value {
2923 let mut m = Map::new();
2924 m.insert("schema_version".into(), json!("1"));
2925 m.insert("error".into(), json!(result.outcome));
2926 m.insert("id".into(), json!(result.artifact_id)); if !result.duplicate_paths.is_empty() {
2928 m.insert("paths".into(), json!(result.duplicate_paths));
2929 }
2930 Value::Object(m)
2931}
2932
2933pub fn render_resolve_json(result: &ResolutionResult) -> String {
2935 if result.outcome != OUTCOME_RESOLVED {
2936 return dumps_indent2(&resolution_error_value(result));
2937 }
2938 let artifact = result.artifact.as_ref().expect("resolved implies artifact");
2939 let mut m = Map::new();
2940 m.insert("schema_version".into(), json!("1"));
2941 m.insert("id".into(), json!(artifact.id));
2942 m.insert("type".into(), json!(artifact.artifact_type));
2943 m.insert("title".into(), json!(artifact.title));
2944 m.insert("path".into(), json!(artifact.path));
2945 dumps_indent2(&Value::Object(m))
2948}
2949
2950pub fn recency_value(recency: &Recency) -> Value {
2953 let mut m = Map::new();
2954 m.insert("last_committed".into(), json!(recency.last_committed));
2955 m.insert("age_days".into(), json!(recency.age_days));
2956 m.insert("stale".into(), json!(recency.stale));
2957 Value::Object(m)
2958}
2959
2960pub fn evidence_value(e: &Evidence) -> Value {
2964 let mut ev = Map::new();
2965 ev.insert("field".into(), json!(e.field));
2966 ev.insert("terms".into(), json!(e.terms));
2967 ev.insert("tier".into(), json!(e.tier));
2968 ev.insert("score".into(), py_float(e.score));
2969 let mut components = Map::new();
2970 components.insert("bm25".into(), py_float(e.bm25));
2971 components.insert("lexical_rank".into(), json!(e.lexical_rank));
2972 components.insert("graph_rank".into(), json!(e.graph_rank));
2973 components.insert("inbound".into(), json!(e.inbound));
2974 ev.insert("components".into(), Value::Object(components));
2975 Value::Object(ev)
2976}
2977
2978pub fn find_match_value(m: &ResolvedArtifact, include_evidence: bool) -> Value {
2984 let mut obj = Map::new();
2985 obj.insert("id".into(), json!(m.id));
2986 obj.insert("type".into(), json!(m.artifact_type));
2987 obj.insert("title".into(), json!(m.title));
2988 obj.insert("path".into(), json!(m.path));
2989 if let Some(section) = &m.section {
2990 obj.insert("section".into(), json!(section));
2991 }
2992 if let Some(snippet) = &m.snippet {
2993 obj.insert("snippet".into(), json!(snippet));
2994 }
2995 if include_evidence {
2996 if let Some(e) = &m.evidence {
2997 obj.insert("evidence".into(), evidence_value(e));
2998 }
2999 }
3000 if let Some(recency) = &m.recency {
3001 obj.insert("recency".into(), recency_value(recency));
3002 }
3003 if !m.tags.is_empty() {
3004 obj.insert("tags".into(), json!(m.tags));
3005 }
3006 Value::Object(obj)
3007}
3008
3009pub fn render_retrieve_human(payload: &Value) -> String {
3013 let empty: Vec<Value> = Vec::new();
3014 let items = payload
3015 .get("items")
3016 .and_then(Value::as_array)
3017 .unwrap_or(&empty);
3018 let task = payload.get("task").and_then(Value::as_str).unwrap_or("");
3019 if items.is_empty() {
3020 return format!("No grounding for {}.", py_repr_str(task));
3021 }
3022 let disp = |item: &Value, key: &str| -> String {
3024 match item.get(key).and_then(Value::as_str) {
3025 Some(s) if !s.is_empty() => s.to_string(),
3026 _ => "\u{2014}".to_string(),
3027 }
3028 };
3029 let id_of = |item: &Value| item["id"].as_str().unwrap_or("").to_string();
3030 let id_w = items
3031 .iter()
3032 .map(|i| id_of(i).chars().count())
3033 .max()
3034 .unwrap_or(0);
3035 let status_w = items
3036 .iter()
3037 .map(|i| disp(i, "status").chars().count())
3038 .max()
3039 .unwrap_or(0);
3040 let indent = format!("{} {} ", " ".repeat(id_w), " ".repeat(status_w));
3041 let mut lines: Vec<String> = Vec::new();
3042 for item in items {
3043 lines.push(format!(
3044 "{} {} {}",
3045 ljust(&id_of(item), id_w),
3046 ljust(&disp(item, "status"), status_w),
3047 disp(item, "title"),
3048 ));
3049 let empty_map = Map::new();
3050 let provenance = item
3051 .get("provenance")
3052 .and_then(Value::as_object)
3053 .unwrap_or(&empty_map);
3054 let via = provenance
3055 .get("channels")
3056 .and_then(Value::as_array)
3057 .map(|cs| {
3058 cs.iter()
3059 .filter_map(Value::as_str)
3060 .collect::<Vec<_>>()
3061 .join("+")
3062 })
3063 .unwrap_or_default();
3064 let detail = if let Some(entry) = provenance.get("matching_entry") {
3065 format!(" [applies to: {}]", entry.as_str().unwrap_or(""))
3066 } else if let Some(evidence) = provenance.get("evidence") {
3067 let field = evidence["field"].as_str().unwrap_or("");
3068 let terms = evidence["terms"]
3069 .as_array()
3070 .map(|ts| {
3071 ts.iter()
3072 .filter_map(Value::as_str)
3073 .collect::<Vec<_>>()
3074 .join(",")
3075 })
3076 .unwrap_or_default();
3077 format!(" [field={field} terms={terms}]")
3078 } else {
3079 String::new()
3080 };
3081 lines.push(format!("{indent}\u{21b3} via: {via}{detail}"));
3082 if let Some(replaced) = provenance.get("superseded").and_then(Value::as_array) {
3083 for r in replaced {
3084 lines.push(format!("{indent} replaces: {}", r.as_str().unwrap_or("")));
3085 }
3086 }
3087 }
3088 lines.push(String::new());
3089 let mut summary = format!("{} item(s) for {}.", items.len(), py_repr_str(task));
3090 if payload
3091 .get("truncated")
3092 .and_then(Value::as_bool)
3093 .unwrap_or(false)
3094 {
3095 let omitted = payload.get("omitted").and_then(Value::as_i64).unwrap_or(0);
3096 summary.push_str(&format!(" (truncated; {omitted} item(s) omitted)"));
3097 }
3098 lines.push(summary);
3099 lines.join("\n")
3100}
3101
3102pub fn search_result_value(result: &SearchResult, include_evidence: bool) -> Value {
3106 let mut m = Map::new();
3107 m.insert("schema_version".into(), json!("1"));
3108 m.insert("query".into(), json!(result.query));
3109 m.insert("type".into(), json!(result.artifact_type));
3110 m.insert("match_count".into(), json!(result.matches.len()));
3111 m.insert(
3112 "matches".into(),
3113 Value::Array(
3114 result
3115 .matches
3116 .iter()
3117 .map(|mm| find_match_value(mm, include_evidence))
3118 .collect(),
3119 ),
3120 );
3121 Value::Object(m)
3122}
3123
3124pub fn render_find_json(result: &SearchResult, explain: bool) -> String {
3126 dumps_indent2(&search_result_value(result, explain))
3127}
3128
3129pub fn render_find_human(result: &SearchResult, explain: bool) -> String {
3132 if result.matches.is_empty() {
3133 return format!("No artifacts match {}.", py_repr_str(&result.query));
3134 }
3135 let id_w = result
3136 .matches
3137 .iter()
3138 .map(|m| m.id.chars().count())
3139 .max()
3140 .unwrap_or(0);
3141 let type_w = result
3142 .matches
3143 .iter()
3144 .map(|m| m.artifact_type.chars().count())
3145 .max()
3146 .unwrap_or(0);
3147 let indent = format!("{} {} ", " ".repeat(id_w), " ".repeat(type_w));
3148 let mut lines: Vec<String> = Vec::new();
3149 for m in &result.matches {
3150 let mut row = format!(
3151 "{} {} {}",
3152 ljust(&m.id, id_w),
3153 ljust(&m.artifact_type, type_w),
3154 m.title.as_deref().filter(|t| !t.is_empty()).unwrap_or("\u{2014}")
3155 );
3156 if let Some(recency) = &m.recency {
3157 if recency.stale == Some(true) {
3158 let marker = match recency.age_days {
3159 Some(age) => format!(" \u{26a0} stale ({age}d)"),
3160 None => " \u{26a0} stale".to_string(),
3161 };
3162 row.push_str(&yellow(&marker));
3163 }
3164 }
3165 lines.push(row);
3166 if let Some(snippet) = &m.snippet {
3167 let section = match m.section.as_deref() {
3168 Some(s) if !s.is_empty() => format!("{s}: "),
3169 _ => String::new(),
3170 };
3171 lines.push(format!("{indent}\u{21b3} {section}{snippet}"));
3172 }
3173 if explain {
3174 if let Some(e) = &m.evidence {
3175 let mut attribution =
3176 format!("field={} terms={}", e.field, e.terms.join(","));
3177 if let Some(snippet) = &m.snippet {
3178 let where_ = match m.section.as_deref() {
3179 Some(s) if !s.is_empty() => format!("{s}: "),
3180 _ => String::new(),
3181 };
3182 attribution.push_str(&format!(" [{where_}{snippet}]"));
3183 }
3184 lines.push(format!("{indent}\u{2022} {attribution}"));
3185 lines.push(format!(
3186 "{indent} score={} bm25={} lexical_rank={} graph_rank={} inbound={}",
3187 py_float_repr(e.score),
3188 py_float_repr(e.bm25),
3189 e.lexical_rank,
3190 e.graph_rank,
3191 e.inbound
3192 ));
3193 }
3194 }
3195 }
3196 lines.push(String::new());
3197 lines.push(format!(
3198 "{} match(es) for {}.",
3199 result.matches.len(),
3200 py_repr_str(&result.query)
3201 ));
3202 lines.join("\n")
3203}
3204
3205pub fn render_mcp_stats_human(summary: &crate::telemetry::TelemetrySummary) -> String {
3212 let mut lines = vec![
3213 bold("MCP Usage (compatibility read-back)"),
3214 "===============".to_string(),
3215 String::new(),
3216 format!("Log: {}", summary.path),
3217 ];
3218 if summary.event_count == 0 {
3219 lines.push(String::new());
3220 lines.push("No telemetry recorded.".to_string());
3221 lines.push("The native decided-mcp server does not record usage telemetry; this command only reads an existing compatibility log.".to_string());
3222 if summary.skipped_lines != 0 {
3223 lines.push(String::new());
3224 lines.push(format!("Skipped Unreadable Lines: {}", summary.skipped_lines));
3225 }
3226 return lines.join("\n");
3227 }
3228 lines.push(format!("Events: {}", summary.event_count));
3229 lines.push(format!("Sessions: {}", summary.session_count));
3230 lines.push(format!(
3231 "First Event: {}",
3232 summary.first_ts.as_deref().unwrap_or("None")
3233 ));
3234 lines.push(format!(
3235 "Last Event: {}",
3236 summary.last_ts.as_deref().unwrap_or("None")
3237 ));
3238 lines.push(String::new());
3239 lines.push(bold("Tool Usage"));
3240 lines.push("==========".to_string());
3241 lines.push(String::new());
3242 for tool in &summary.tools {
3243 lines.push(format!(
3244 " {}: {} call(s), {} error(s), {} truncated, avg {} ms",
3245 tool.tool, tool.calls, tool.errors, tool.truncated, tool.avg_duration_ms
3246 ));
3247 }
3248 if summary.skipped_lines != 0 {
3249 lines.push(String::new());
3250 lines.push(format!("Skipped Unreadable Lines: {}", summary.skipped_lines));
3251 }
3252 lines.join("\n")
3253}
3254
3255pub fn render_mcp_stats_json(summary: &crate::telemetry::TelemetrySummary) -> String {
3260 dumps_indent2(&crate::telemetry::summary_value(summary))
3261}
3262
3263pub fn render_usage_human(
3269 cli: &crate::usage::UsageSummary,
3270 guide: &crate::telemetry::TelemetrySummary,
3271) -> String {
3272 let mut lines = vec!["RAC usage".to_string(), String::new()];
3273 if cli.total == 0 {
3274 lines.push(
3275 "No CLI usage recorded \u{2014} telemetry is off (enable with `decided telemetry on`)."
3276 .to_string(),
3277 );
3278 } else {
3279 lines.push(format!(
3280 "CLI commands: {} calls across {} session(s)",
3281 cli.total, cli.sessions
3282 ));
3283 for c in &cli.commands {
3284 let errs = if c.errors != 0 {
3285 format!(
3286 " ({} error{})",
3287 c.errors,
3288 if c.errors != 1 { "s" } else { "" }
3289 )
3290 } else {
3291 String::new()
3292 };
3293 lines.push(format!(" {} {}{}", ljust(&c.command, 16), c.calls, errs));
3294 }
3295 if !cli.recent.is_empty() {
3296 let trend: Vec<String> = cli
3297 .recent
3298 .iter()
3299 .map(|(day, n)| format!("{day}: {n}"))
3300 .collect();
3301 lines.push(format!(" recent: {}", trend.join(", ")));
3302 }
3303 }
3304 if !guide.tools.is_empty() {
3305 lines.push(String::new());
3306 lines.push("Guide MCP tools:".to_string());
3307 for tool in &guide.tools {
3308 let errs = if tool.errors != 0 {
3309 format!(" ({} error(s))", tool.errors)
3310 } else {
3311 String::new()
3312 };
3313 lines.push(format!(" {} {}{}", ljust(&tool.tool, 16), tool.calls, errs));
3314 }
3315 }
3316 lines.join("\n")
3317}
3318
3319pub fn render_usage_json(
3322 cli: &crate::usage::UsageSummary,
3323 guide: &crate::telemetry::TelemetrySummary,
3324) -> String {
3325 dumps_indent2_no_ascii(&crate::usage::combined_value(cli, guide))
3326}
3327
3328pub fn render_skill_list_human() -> String {
3334 let specs = &crate::skill::BUNDLED_SKILLS;
3335 let mut lines = vec![bold("Bundled agent skills:"), String::new()];
3336 let name_w = specs.iter().map(|s| s.name.chars().count()).max().unwrap_or(0);
3337 for spec in specs {
3338 lines.push(format!("- {} {}", ljust(spec.name, name_w), spec.description));
3339 }
3340 lines.join("\n")
3341}
3342
3343pub fn render_skill_list_json() -> String {
3345 let skills: Vec<Value> = crate::skill::BUNDLED_SKILLS
3346 .iter()
3347 .map(|s| json!({"skill": s.name, "description": s.description}))
3348 .collect();
3349 dumps_indent2(&json!({"schema_version": "1", "skills": skills}))
3350}
3351
3352pub fn render_skill_install_human(installation: &crate::skill::SkillInstallation) -> String {
3355 let mut lines: Vec<String> = installation
3356 .skills
3357 .iter()
3358 .map(|s| format!("Installed {} skill: {}", s.skill, s.path))
3359 .collect();
3360 lines.push(String::new());
3361 lines.push(
3362 "Claude Code discovers skills automatically from .claude/skills/ in the project."
3363 .to_string(),
3364 );
3365 lines.join("\n")
3366}
3367
3368pub fn render_skill_install_json(installation: &crate::skill::SkillInstallation) -> String {
3371 let skills: Vec<Value> = installation
3372 .skills
3373 .iter()
3374 .map(|s| json!({"skill": s.skill, "path": s.path}))
3375 .collect();
3376 dumps_indent2(&json!({"schema_version": "1", "installed": true, "skills": skills}))
3377}
3378
3379pub fn render_hook_list_human() -> String {
3382 let specs = &crate::hook::BUNDLED_HOOKS;
3383 let mut lines = vec![bold("Bundled git hooks:"), String::new()];
3384 let style_w = specs.iter().map(|s| s.style.chars().count()).max().unwrap_or(0);
3385 for spec in specs {
3386 lines.push(format!("- {} {}", ljust(spec.style, style_w), spec.description));
3387 }
3388 lines.join("\n")
3389}
3390
3391pub fn render_hook_list_json() -> String {
3393 let hooks: Vec<Value> = crate::hook::BUNDLED_HOOKS
3394 .iter()
3395 .map(|h| json!({"style": h.style, "description": h.description}))
3396 .collect();
3397 dumps_indent2(&json!({"schema_version": "1", "hooks": hooks}))
3398}
3399
3400pub fn render_hook_install_human(installation: &crate::hook::InstalledHook) -> String {
3402 format!(
3403 "Installed {} git hook: {}\n\nGit runs it automatically on each commit. Remove the file to stop it.",
3404 installation.style, installation.path
3405 )
3406}
3407
3408pub fn render_hook_install_json(installation: &crate::hook::InstalledHook) -> String {
3410 dumps_indent2(&json!({
3411 "schema_version": "1",
3412 "installed": true,
3413 "hook": {"style": installation.style, "path": installation.path}
3414 }))
3415}
3416
3417pub fn render_new_human(created: &crate::scaffold::CreatedArtifact) -> String {
3423 format!(
3424 "Created {} artifact: {}\nID: {}\n\nEdit the TODO placeholders, then check it with: decided validate {}",
3425 created.artifact_type, created.path, created.id, created.path
3426 )
3427}
3428
3429pub fn render_new_json(created: &crate::scaffold::CreatedArtifact) -> String {
3432 dumps_indent2(&json!({
3433 "schema_version": "1",
3434 "created": true,
3435 "type": created.artifact_type,
3436 "path": created.path,
3437 "id": created.id,
3438 }))
3439}
3440
3441pub fn render_init_human(result: &crate::scaffold::InitResult) -> String {
3444 let verb = if result.created {
3445 "Initialized"
3446 } else {
3447 "Already initialized:"
3448 };
3449 let mut lines = vec![
3450 format!("{verb} repository key {}", result.repository_key),
3451 format!("Config: {}", result.config_path),
3452 ];
3453 if let Some(profile) = &result.profile {
3454 lines.push(format!("Profile: {profile}"));
3455 }
3456 if let Some(url) = &result.org_endpoint {
3457 lines.push(format!("Org endpoint: {url}"));
3458 }
3459 lines.extend(result.files_written.iter().map(|p| format!("Wrote: {p}")));
3460 lines.join("\n")
3461}
3462
3463pub fn render_init_json(result: &crate::scaffold::InitResult) -> String {
3465 dumps_indent2(&json!({
3466 "schema_version": "1",
3467 "repository_key": result.repository_key,
3468 "config_path": result.config_path,
3469 "created": result.created,
3470 "profile": result.profile,
3471 "files_written": result.files_written,
3472 "org_endpoint": result.org_endpoint,
3473 }))
3474}
3475
3476pub fn render_quickstart_human(result: &crate::scaffold::QuickstartResult) -> String {
3479 let verb = if result.created { "Initialized" } else { "Using" };
3480 let artifact = &result.artifact;
3481 format!(
3482 "{verb} repository key {}\nCreated {} artifact: {}\nID: {}\n\nNext: edit the TODO placeholders, then run: decided validate {}",
3483 result.repository_key, artifact.artifact_type, artifact.path, artifact.id, artifact.path
3484 )
3485}
3486
3487pub fn render_quickstart_json(result: &crate::scaffold::QuickstartResult) -> String {
3489 dumps_indent2(&json!({
3490 "schema_version": "1",
3491 "repository_key": result.repository_key,
3492 "config_path": result.config_path,
3493 "created": result.created,
3494 "artifact": {
3495 "type": result.artifact.artifact_type,
3496 "path": result.artifact.path,
3497 "id": result.artifact.id,
3498 },
3499 }))
3500}
3501
3502pub fn render_migrate_human(report: &crate::scaffold::MigrationReport) -> String {
3506 use crate::scaffold::{STATUS_MIGRATED, STATUS_SKIPPED_UNKNOWN};
3507 let mut lines: Vec<String> = Vec::new();
3508 if report.dry_run {
3509 lines.push(bold("Dry run \u{2014} no files were written."));
3510 lines.push(String::new());
3511 }
3512
3513 let migrated: Vec<_> = report
3514 .files
3515 .iter()
3516 .filter(|f| f.status == STATUS_MIGRATED)
3517 .collect();
3518 let unknown: Vec<_> = report
3519 .files
3520 .iter()
3521 .filter(|f| f.status == STATUS_SKIPPED_UNKNOWN)
3522 .collect();
3523
3524 let verb = if report.dry_run { "Would migrate" } else { "Migrated" };
3525 if migrated.is_empty() {
3526 lines.push(format!("{verb} 0 artifact(s) \u{2014} nothing to migrate."));
3527 } else {
3528 lines.push(bold(&format!("{verb} {} artifact(s):", migrated.len())));
3529 let path_w = migrated.iter().map(|f| f.path.chars().count()).max().unwrap_or(0);
3530 for f in &migrated {
3531 lines.push(format!(
3532 " {} {} ({})",
3533 ljust(&f.path, path_w),
3534 f.id.as_deref().unwrap_or(""),
3535 f.artifact_type.as_deref().unwrap_or("")
3536 ));
3537 }
3538 }
3539
3540 if !unknown.is_empty() {
3541 lines.push(String::new());
3542 lines.push(bold(&format!(
3543 "Skipped {} unrecognized document(s):",
3544 unknown.len()
3545 )));
3546 lines.extend(unknown.iter().map(|f| format!(" - {}", f.path)));
3547 }
3548
3549 lines.push(String::new());
3550 lines.push(format!(
3551 "{} file(s): {} migrated, {} already canonical, {} skipped (unknown type).",
3552 report.files.len(),
3553 report.migrated(),
3554 report.already_canonical(),
3555 report.skipped_unknown()
3556 ));
3557 lines.join("\n")
3558}
3559
3560pub fn render_migrate_json(report: &crate::scaffold::MigrationReport) -> String {
3562 let files: Vec<Value> = report
3563 .files
3564 .iter()
3565 .map(|f| {
3566 json!({
3567 "path": f.path,
3568 "status": f.status,
3569 "id": f.id,
3570 "type": f.artifact_type,
3571 })
3572 })
3573 .collect();
3574 dumps_indent2(&json!({
3575 "schema_version": "1",
3576 "directory": report.directory,
3577 "recursive": report.recursive,
3578 "dry_run": report.dry_run,
3579 "summary": {
3580 "total_files": report.files.len(),
3581 "migrated": report.migrated(),
3582 "already_canonical": report.already_canonical(),
3583 "skipped_unknown": report.skipped_unknown(),
3584 },
3585 "files": files,
3586 }))
3587}
3588
3589fn rename_reason_phrase(reason: Option<&str>) -> String {
3591 match reason {
3592 Some(crate::rename::REASON_OLD_NOT_FOUND) => "no artifact resolves to that id".to_string(),
3593 Some(crate::rename::REASON_OLD_AMBIGUOUS) => {
3594 "the id is ambiguous \u{2014} it resolves to more than one artifact".to_string()
3595 }
3596 Some(crate::rename::REASON_NEW_COLLIDES) => {
3597 "the new id already names another artifact".to_string()
3598 }
3599 Some(crate::rename::REASON_NEW_INVALID) => {
3600 "the new id is not a valid identifier".to_string()
3601 }
3602 Some(crate::rename::REASON_OLD_FILENAME_ONLY) => {
3603 "the id is only a filename-derived alias \u{2014} there is no in-file identity to \
3604 rewrite, and renaming files is out of scope"
3605 .to_string()
3606 }
3607 Some(crate::rename::REASON_SYMLINK_PATH) => {
3608 "one or more mutation paths are symlinks".to_string()
3609 }
3610 Some(crate::rename::REASON_PATH_OUTSIDE_ROOT) => {
3611 "a mutation path is unresolved or outside the corpus root".to_string()
3612 }
3613 Some(other) => other.to_string(),
3614 None => "unknown".to_string(),
3615 }
3616}
3617
3618pub fn render_rename_human(plan: &crate::rename::RenamePlan) -> String {
3621 let header = format!("Rename {} -> {}", plan.old_ref, plan.new_ref);
3622 if !plan.ok {
3623 let reason = rename_reason_phrase(plan.reason);
3624 let path = match plan.reason {
3625 Some(crate::rename::REASON_SYMLINK_PATH)
3626 | Some(crate::rename::REASON_PATH_OUTSIDE_ROOT) => plan
3627 .target_path
3628 .as_deref()
3629 .map(|path| format!(" Path: {path}."))
3630 .unwrap_or_default(),
3631 _ => String::new(),
3632 };
3633 return format!(
3634 "{header}\n\n{}",
3635 red(&format!("\u{2717} Refused: {reason}.{path}"))
3636 );
3637 }
3638 let mut lines = vec![header.clone(), "=".repeat(header.chars().count()), String::new()];
3639 lines.push(format!(
3640 "Target: {} (identity field: {})",
3641 plan.target_path.as_deref().unwrap_or(""),
3642 plan.identity_field.unwrap_or("")
3643 ));
3644 lines.push(format!(
3645 "{} inbound reference(s), {} identity edit across {} file(s).",
3646 plan.reference_edits(),
3647 plan.identity_edits(),
3648 plan.files_changed()
3649 ));
3650 lines.push(String::new());
3651 let mut current: Option<&str> = None;
3652 for edit in &plan.edits {
3653 if current != Some(edit.path.as_str()) {
3654 current = Some(edit.path.as_str());
3655 lines.push(format!(" {}", edit.path));
3656 }
3657 lines.push(format!(
3658 " L{} {}",
3659 edit.line,
3660 red(&format!("\u{2717} {}", edit.old_line))
3661 ));
3662 lines.push(format!(
3663 " L{} {}",
3664 edit.line,
3665 green(&format!("\u{2713} {}", edit.new_line))
3666 ));
3667 }
3668 lines.push(String::new());
3669 lines.push("Dry run \u{2014} pass --apply to write these edits.".to_string());
3670 lines.join("\n")
3671}
3672
3673pub fn render_rename_json(plan: &crate::rename::RenamePlan) -> String {
3676 let edits: Vec<Value> = plan
3677 .edits
3678 .iter()
3679 .map(|e| {
3680 json!({
3681 "path": e.path,
3682 "line": e.line,
3683 "old_line": e.old_line,
3684 "new_line": e.new_line,
3685 "kind": e.kind,
3686 })
3687 })
3688 .collect();
3689 dumps_indent2(&json!({
3690 "directory": plan.directory,
3691 "recursive": plan.recursive,
3692 "old_ref": plan.old_ref,
3693 "new_ref": plan.new_ref,
3694 "ok": plan.ok,
3695 "reason": plan.reason,
3696 "target_path": plan.target_path,
3697 "identity_field": plan.identity_field,
3698 "files_changed": plan.files_changed(),
3699 "reference_edits": plan.reference_edits(),
3700 "identity_edits": plan.identity_edits(),
3701 "edits": edits,
3702 }))
3703}
3704
3705pub fn render_rename_result_human(result: &crate::rename::RenameResult) -> String {
3707 let header = format!("Rename {} -> {}", result.old_ref, result.new_ref);
3708 if !result.applied {
3709 return format!(
3710 "{header}\n\n{}",
3711 red("\u{2717} Nothing applied (the plan was refused).")
3712 );
3713 }
3714 format!(
3715 "{header}\n\n{}",
3716 green(&format!(
3717 "\u{2713} Applied: {} reference(s) and {} identity edit across {} file(s).",
3718 result.reference_edits, result.identity_edits, result.files_changed
3719 ))
3720 )
3721}
3722
3723pub fn render_rename_result_json(result: &crate::rename::RenameResult) -> String {
3725 dumps_indent2(&json!({
3726 "directory": result.directory,
3727 "old_ref": result.old_ref,
3728 "new_ref": result.new_ref,
3729 "applied": result.applied,
3730 "target_path": result.target_path,
3731 "files_changed": result.files_changed,
3732 "reference_edits": result.reference_edits,
3733 "identity_edits": result.identity_edits,
3734 }))
3735}
3736
3737use crate::compare::{RelationshipIssueRef, CHANGE_ADDED, CHANGE_MODIFIED};
3740use crate::intent::{IntentFinding, SEVERITY_WARNING as WK_SEVERITY_WARNING};
3741use crate::watchkeeper::{
3742 is_recommending, WatchkeeperReport, REASON_BROKEN_RELATIONSHIP,
3743 REASON_VALIDATION_REGRESSION,
3744};
3745
3746fn wk_delta(base: usize, head: usize) -> String {
3747 format!("{base} \u{2192} {head}")
3748}
3749
3750fn py_opt_str(value: &Option<String>) -> &str {
3754 value.as_deref().unwrap_or("None")
3755}
3756
3757fn wk_issue_phrase(issue: &RelationshipIssueRef) -> String {
3758 match &issue.relationship {
3759 None => {
3760 let subject = match issue.identifier.as_deref() {
3762 Some(id) if !id.is_empty() => id,
3763 _ => issue.path.as_str(),
3764 };
3765 format!("{subject}: {}", issue.code)
3766 }
3767 Some(relationship) => {
3768 let label = py_title(&relationship.replace('_', " "));
3769 format!(
3770 "{} \u{2014} {} reference '{}' ({})",
3771 issue.path,
3772 label,
3773 py_opt_str(&issue.target),
3774 issue.code
3775 )
3776 }
3777 }
3778}
3779
3780pub fn render_watchkeeper_human(report: &WatchkeeperReport) -> String {
3782 let comparison = &report.comparison;
3783 let mut lines: Vec<String> = vec![
3784 bold("RAC Watchkeeper"),
3785 "===============".to_string(),
3786 String::new(),
3787 format!("Directory: {}", report.directory),
3788 format!("Comparing: {} \u{2192} {}", report.base, report.head),
3789 String::new(),
3790 bold("Changed Artifacts"),
3791 "-----------------".to_string(),
3792 String::new(),
3793 ];
3794 if !comparison.changes.is_empty() {
3795 for change in &comparison.changes {
3796 let icon = match change.change {
3797 CHANGE_ADDED => "+",
3798 CHANGE_MODIFIED => "~",
3799 _ => "-",
3800 };
3801 lines.push(format!(" {icon} {} ({})", change.path, change.type_name));
3802 }
3803 } else {
3804 lines.push(" No product artifact changes detected.".to_string());
3805 }
3806
3807 let validation = &comparison.validation;
3808 lines.push(String::new());
3809 lines.push(bold("Validation"));
3810 lines.push("----------".to_string());
3811 lines.push(String::new());
3812 lines.push(format!(
3813 " Valid: {}",
3814 wk_delta(validation.base_valid, validation.head_valid)
3815 ));
3816 lines.push(format!(
3817 " Invalid: {}",
3818 wk_delta(validation.base_invalid, validation.head_invalid)
3819 ));
3820 if !validation.newly_invalid.is_empty() {
3821 lines.push(String::new());
3822 lines.push(" Newly invalid:".to_string());
3823 for path in &validation.newly_invalid {
3824 lines.push(format!(" {} {path}", red("\u{2717}")));
3825 }
3826 }
3827 if !validation.newly_valid.is_empty() {
3828 lines.push(String::new());
3829 lines.push(" Newly valid:".to_string());
3830 for path in &validation.newly_valid {
3831 lines.push(format!(" {} {path}", green("\u{2713}")));
3832 }
3833 }
3834
3835 let relationships = &comparison.relationships;
3836 lines.push(String::new());
3837 lines.push(bold("Relationships"));
3838 lines.push("-------------".to_string());
3839 lines.push(String::new());
3840 lines.push(format!(
3841 " Total: {}",
3842 wk_delta(relationships.base.total, relationships.head.total)
3843 ));
3844 lines.push(format!(
3845 " Valid: {}",
3846 wk_delta(relationships.base.valid, relationships.head.valid)
3847 ));
3848 lines.push(format!(
3849 " Broken: {}",
3850 wk_delta(relationships.base.broken, relationships.head.broken)
3851 ));
3852 if !relationships.new_issues.is_empty() {
3853 lines.push(String::new());
3854 lines.push(" New issues:".to_string());
3855 for issue in &relationships.new_issues {
3856 lines.push(format!(" {} {}", yellow("!"), wk_issue_phrase(issue)));
3857 }
3858 }
3859 if !relationships.resolved_issues.is_empty() {
3860 lines.push(String::new());
3861 lines.push(" Resolved issues:".to_string());
3862 for issue in &relationships.resolved_issues {
3863 lines.push(format!(
3864 " {} {}",
3865 green("\u{2713}"),
3866 wk_issue_phrase(issue)
3867 ));
3868 }
3869 }
3870
3871 let stats = &comparison.stats;
3872 lines.push(String::new());
3873 lines.push(bold("Repository Changes"));
3874 lines.push("------------------".to_string());
3875 lines.push(String::new());
3876 for (type_name, (base_count, head_count)) in &stats.by_type {
3877 if *base_count != 0 || *head_count != 0 {
3878 lines.push(format!(
3879 " {} {}",
3880 ljust(&py_title(type_name), 14),
3881 wk_delta(*base_count, *head_count)
3882 ));
3883 }
3884 }
3885 lines.push(format!(
3886 " {} {}",
3887 ljust("Total", 14),
3888 wk_delta(stats.total.0, stats.total.1)
3889 ));
3890
3891 if !report.findings.is_empty() {
3892 lines.push(String::new());
3893 lines.push(bold(&format!("Findings ({})", report.findings.len())));
3894 lines.push("--------".to_string());
3895 for finding in &report.findings {
3896 let icon = if finding.severity == WK_SEVERITY_WARNING {
3897 yellow("!")
3898 } else {
3899 "\u{b7}".to_string()
3900 };
3901 lines.push(String::new());
3902 lines.push(format!(" {icon} [{}] {}", finding.code, finding.path));
3903 lines.push(format!(" {}", finding.detail));
3904 for line in &finding.evidence {
3905 lines.push(format!(" {line}"));
3906 }
3907 }
3908 }
3909
3910 lines.push(String::new());
3912 lines.push(bold("Review"));
3913 lines.push("------".to_string());
3914 lines.push(String::new());
3915 if report.review_recommended() {
3916 lines.push(format!(" {}", yellow("Review recommended.")));
3917 lines.push(String::new());
3918 lines.push(" Reasons:".to_string());
3919 lines.push(String::new());
3920 for rec in &report.recommendations {
3921 lines.push(format!(" \u{b7} {} [{}]", rec.reason, rec.code));
3922 }
3923 } else {
3924 lines.push(format!(
3925 " {}",
3926 green("\u{2713} Nothing requiring attention.")
3927 ));
3928 }
3929
3930 lines.join("\n")
3931}
3932
3933fn wk_summary_value(summary: &crate::relationships::RelationshipSummary) -> Value {
3934 let mut m = Map::new();
3935 m.insert("total".into(), json!(summary.total));
3936 m.insert("valid".into(), json!(summary.valid));
3937 m.insert("broken".into(), json!(summary.broken));
3938 m.insert("orphaned".into(), json!(summary.orphaned));
3939 m.insert("coverage".into(), py_float(summary.coverage));
3940 Value::Object(m)
3941}
3942
3943fn wk_issue_value(issue: &RelationshipIssueRef) -> Value {
3944 let mut m = Map::new();
3945 m.insert("code".into(), json!(issue.code));
3946 m.insert("relationship".into(), json!(issue.relationship));
3947 m.insert("target".into(), json!(issue.target));
3948 m.insert("path".into(), json!(issue.path));
3949 m.insert("identifier".into(), json!(issue.identifier));
3950 Value::Object(m)
3951}
3952
3953fn wk_finding_value(finding: &IntentFinding) -> Value {
3954 let mut m = Map::new();
3955 m.insert("code".into(), json!(finding.code));
3956 m.insert("severity".into(), json!(finding.severity));
3957 m.insert("path".into(), json!(finding.path));
3958 m.insert("identifier".into(), json!(finding.identifier));
3959 m.insert("detail".into(), json!(finding.detail));
3960 m.insert("evidence".into(), json!(finding.evidence));
3961 Value::Object(m)
3962}
3963
3964fn wk_requirement_value(r: &Requirement) -> Value {
3965 let mut m = Map::new();
3966 m.insert("id".into(), json!(r.id));
3967 m.insert("text".into(), json!(r.text));
3968 m.insert("line".into(), json!(r.line));
3969 Value::Object(m)
3970}
3971
3972fn wk_diff_value(diff: &Diff) -> Value {
3973 let mut m = Map::new();
3976 m.insert(
3977 "added_requirements".into(),
3978 Value::Array(diff.added_requirements.iter().map(wk_requirement_value).collect()),
3979 );
3980 m.insert(
3981 "removed_requirements".into(),
3982 Value::Array(diff.removed_requirements.iter().map(wk_requirement_value).collect()),
3983 );
3984 m.insert(
3985 "modified_requirements".into(),
3986 Value::Array(
3987 diff.modified_requirements
3988 .iter()
3989 .map(|c| {
3990 let mut cm = Map::new();
3991 cm.insert("id".into(), json!(c.id));
3992 cm.insert("old_text".into(), json!(c.old_text));
3993 cm.insert("new_text".into(), json!(c.new_text));
3994 Value::Object(cm)
3995 })
3996 .collect(),
3997 ),
3998 );
3999 m.insert("added_metrics".into(), json!(diff.added_metrics));
4000 m.insert("removed_metrics".into(), json!(diff.removed_metrics));
4001 m.insert("added_risks".into(), json!(diff.added_risks));
4002 m.insert("removed_risks".into(), json!(diff.removed_risks));
4003 Value::Object(m)
4004}
4005
4006fn wk_change_value(change: &crate::compare::ArtifactChange) -> Value {
4007 let mut m = Map::new();
4008 m.insert("change".into(), json!(change.change));
4009 m.insert("type".into(), json!(change.type_name));
4010 m.insert("id".into(), json!(change.id));
4011 m.insert("title".into(), json!(change.title));
4012 m.insert("path".into(), json!(change.path));
4013 m.insert("base_status".into(), json!(change.base_status));
4014 m.insert("head_status".into(), json!(change.head_status));
4015 if let Some(diff) = &change.diff {
4016 m.insert("diff".into(), wk_diff_value(diff));
4017 }
4018 Value::Object(m)
4019}
4020
4021pub fn render_watchkeeper_json(report: &WatchkeeperReport) -> String {
4023 let comparison = &report.comparison;
4024 let validation = &comparison.validation;
4025 let relationships = &comparison.relationships;
4026 let stats = &comparison.stats;
4027
4028 let mut root = Map::new();
4029 root.insert("schema_version".into(), json!("1"));
4030 root.insert("directory".into(), json!(report.directory));
4031 root.insert("base".into(), json!(report.base));
4032 root.insert("head".into(), json!(report.head));
4033 root.insert(
4034 "changes".into(),
4035 Value::Array(comparison.changes.iter().map(wk_change_value).collect()),
4036 );
4037
4038 let mut val = Map::new();
4039 let mut val_base = Map::new();
4040 val_base.insert("valid".into(), json!(validation.base_valid));
4041 val_base.insert("invalid".into(), json!(validation.base_invalid));
4042 val.insert("base".into(), Value::Object(val_base));
4043 let mut val_head = Map::new();
4044 val_head.insert("valid".into(), json!(validation.head_valid));
4045 val_head.insert("invalid".into(), json!(validation.head_invalid));
4046 val.insert("head".into(), Value::Object(val_head));
4047 val.insert("newly_invalid".into(), json!(validation.newly_invalid));
4048 val.insert("newly_valid".into(), json!(validation.newly_valid));
4049 root.insert("validation".into(), Value::Object(val));
4050
4051 let mut rel = Map::new();
4052 rel.insert("base".into(), wk_summary_value(&relationships.base));
4053 rel.insert("head".into(), wk_summary_value(&relationships.head));
4054 rel.insert(
4055 "new_issues".into(),
4056 Value::Array(relationships.new_issues.iter().map(wk_issue_value).collect()),
4057 );
4058 rel.insert(
4059 "resolved_issues".into(),
4060 Value::Array(
4061 relationships
4062 .resolved_issues
4063 .iter()
4064 .map(wk_issue_value)
4065 .collect(),
4066 ),
4067 );
4068 root.insert("relationships".into(), Value::Object(rel));
4069
4070 let mut stats_map = Map::new();
4071 let mut total = Map::new();
4072 total.insert("base".into(), json!(stats.total.0));
4073 total.insert("head".into(), json!(stats.total.1));
4074 stats_map.insert("total".into(), Value::Object(total));
4075 let mut by_type = Map::new();
4076 for (type_name, (base_count, head_count)) in &stats.by_type {
4077 let mut counts = Map::new();
4078 counts.insert("base".into(), json!(base_count));
4079 counts.insert("head".into(), json!(head_count));
4080 by_type.insert(type_name.clone(), Value::Object(counts));
4081 }
4082 stats_map.insert("by_type".into(), Value::Object(by_type));
4083 root.insert("stats".into(), Value::Object(stats_map));
4084
4085 root.insert(
4086 "findings".into(),
4087 Value::Array(report.findings.iter().map(wk_finding_value).collect()),
4088 );
4089
4090 let mut review = Map::new();
4091 review.insert("recommended".into(), json!(report.review_recommended()));
4092 review.insert(
4093 "reasons".into(),
4094 Value::Array(
4095 report
4096 .recommendations
4097 .iter()
4098 .map(|rec| {
4099 let mut rm = Map::new();
4100 rm.insert("code".into(), json!(rec.code));
4101 rm.insert("reason".into(), json!(rec.reason));
4102 Value::Object(rm)
4103 })
4104 .collect(),
4105 ),
4106 );
4107 root.insert("review".into(), Value::Object(review));
4108
4109 dumps_indent2(&Value::Object(root))
4110}
4111
4112fn wk_repo_path(report: &WatchkeeperReport, corpus_relative: &str) -> String {
4114 crate::walk::py_join(&report.directory, &[corpus_relative])
4115}
4116
4117pub fn render_watchkeeper_github(report: &WatchkeeperReport) -> String {
4119 let comparison = &report.comparison;
4120 let validation = &comparison.validation;
4121 let relationships = &comparison.relationships;
4122 let stats = &comparison.stats;
4123
4124 let mut lines: Vec<String> = vec![
4125 "# RAC Watchkeeper".to_string(),
4126 String::new(),
4127 format!(
4128 "Comparing `{}` \u{2192} `{}` in `{}`.",
4129 report.base, report.head, report.directory
4130 ),
4131 String::new(),
4132 "## Changed artifacts".to_string(),
4133 String::new(),
4134 ];
4135 if !comparison.changes.is_empty() {
4136 lines.push("| Change | Artifact | Type |".to_string());
4137 lines.push("| --- | --- | --- |".to_string());
4138 for change in &comparison.changes {
4139 let label = match change.change {
4140 CHANGE_ADDED => "Added",
4141 CHANGE_MODIFIED => "Modified",
4142 _ => "Removed",
4143 };
4144 lines.push(format!(
4145 "| {label} | `{}` | {} |",
4146 change.path, change.type_name
4147 ));
4148 }
4149 } else {
4150 lines.push("No product artifact changes detected.".to_string());
4151 }
4152
4153 lines.push(String::new());
4154 lines.push("## Repository deltas".to_string());
4155 lines.push(String::new());
4156 lines.push("| Measure | Base | Head |".to_string());
4157 lines.push("| --- | --- | --- |".to_string());
4158 lines.push(format!(
4159 "| Valid artifacts | {} | {} |",
4160 validation.base_valid, validation.head_valid
4161 ));
4162 lines.push(format!(
4163 "| Invalid artifacts | {} | {} |",
4164 validation.base_invalid, validation.head_invalid
4165 ));
4166 lines.push(format!(
4167 "| Relationships | {} | {} |",
4168 relationships.base.total, relationships.head.total
4169 ));
4170 lines.push(format!(
4171 "| Broken relationships | {} | {} |",
4172 relationships.base.broken, relationships.head.broken
4173 ));
4174 lines.push(format!("| Artifacts | {} | {} |", stats.total.0, stats.total.1));
4175 if !validation.newly_invalid.is_empty() {
4176 lines.push(String::new());
4177 lines.push("Newly invalid:".to_string());
4178 lines.push(String::new());
4179 for path in &validation.newly_invalid {
4180 lines.push(format!("- `{path}`"));
4181 }
4182 }
4183 if !relationships.new_issues.is_empty() {
4184 lines.push(String::new());
4185 lines.push("New relationship issues:".to_string());
4186 lines.push(String::new());
4187 for issue in &relationships.new_issues {
4188 lines.push(format!(
4189 "- `{}` \u{2014} `{}` ({})",
4190 issue.path,
4191 py_opt_str(&issue.target),
4192 issue.code
4193 ));
4194 }
4195 }
4196
4197 if !report.findings.is_empty() {
4198 lines.push(String::new());
4199 lines.push(format!("## Findings ({})", report.findings.len()));
4200 lines.push(String::new());
4201 for finding in &report.findings {
4202 let marker = if finding.severity == WK_SEVERITY_WARNING {
4203 "\u{26a0}\u{fe0f}"
4204 } else {
4205 "\u{2139}\u{fe0f}"
4206 };
4207 lines.push(format!(
4208 "- {marker} **{}** \u{2014} `{}`: {}",
4209 finding.code, finding.path, finding.detail
4210 ));
4211 }
4212 }
4213
4214 lines.push(String::new());
4215 lines.push("## Verdict".to_string());
4216 lines.push(String::new());
4217 if report.review_recommended() {
4218 lines.push("**Review recommended.**".to_string());
4219 lines.push(String::new());
4220 lines.push("Reasons:".to_string());
4221 lines.push(String::new());
4222 for rec in &report.recommendations {
4223 lines.push(format!("- {} (`{}`)", rec.reason, rec.code));
4224 }
4225 } else {
4226 lines.push("\u{2705} Nothing requiring attention.".to_string());
4227 }
4228
4229 lines.join("\n")
4230}
4231
4232pub fn watchkeeper_annotations(report: &WatchkeeperReport) -> Vec<String> {
4237 let mut lines: Vec<String> = Vec::new();
4238 for path in &report.comparison.validation.newly_invalid {
4239 lines.push(format!(
4240 "::error file={}::{REASON_VALIDATION_REGRESSION}: Artifact became invalid.",
4241 wk_repo_path(report, path)
4242 ));
4243 }
4244 for issue in &report.comparison.relationships.new_issues {
4245 let file_path = issue.path.split(", ").next().unwrap_or(&issue.path);
4247 lines.push(format!(
4248 "::error file={}::{REASON_BROKEN_RELATIONSHIP}: reference '{}' ({})",
4249 wk_repo_path(report, file_path),
4250 py_opt_str(&issue.target),
4251 issue.code
4252 ));
4253 }
4254 for finding in &report.findings {
4255 let command = if is_recommending(finding.code) {
4256 "error"
4257 } else if finding.severity == WK_SEVERITY_WARNING {
4258 "warning"
4259 } else {
4260 "notice"
4261 };
4262 lines.push(format!(
4263 "::{command} file={}::{}: {}",
4264 wk_repo_path(report, &finding.path),
4265 finding.code,
4266 finding.detail
4267 ));
4268 }
4269 lines
4270}