Skip to main content

rac_engine/
output.rs

1//! Output renderers (validate + relationships surfaces), per
2//! PORT-CONTRACT.d/07: human text (code-point padding, TTY-gated ANSI),
3//! `--json` via `pyjson::dumps_indent2`, and SARIF 2.1.0.
4
5use 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
43/// The injectable version string (PORT-CONTRACT decision 6): `DECIDED_RS_VERSION`
44/// when set, otherwise the Cargo package version.
45pub fn rac_version() -> String {
46    // Precedence: a runtime `DECIDED_RS_VERSION` (the parity harness pins the
47    // oracle's exact string here) > the version compiled in at build time
48    // (`DECIDED_RS_VERSION` set when the release wheel builds, so a distributed
49    // binary reports the right version with no runtime pin, native-engine
50    // cutover) > the package version used by ordinary Cargo builds.
51    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
57// --- Minimal color (auto-disabled when not writing to a TTY) ----------------
58
59fn 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
95/// Code-point left-justify (Python `str.ljust`).
96fn 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
105/// `PASS  <file>` / `FAIL  <file>` bold header line.
106fn 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
114/// One issue as its two human lines: the severity line (`error` padded with
115/// three trailing spaces, `warning` with one, so the `[code]` column aligns)
116/// followed by the indented message line.
117fn 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
126// --- validate (single file) --------------------------------------------------
127
128fn 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
195// --- validate - --corpus -------------------------------------------------------
196
197/// `related_decisions` -> `Related Decisions`.
198fn 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
313// --- validate (directory) ------------------------------------------------------
314
315pub 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
446// --- SARIF ---------------------------------------------------------------------
447
448/// `urllib.parse.quote(uri, safe="/")` — percent-encode every byte outside
449/// `A-Za-z0-9_.-~/`, uppercase hex.
450fn 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
596/// The SARIF result's `(message text, percent-encoded uri)` for one
597/// relationship-validation finding — the oracle's `_relationship_result`.
598/// Shared with `decided gate`, which reads the SAME builder so the gate finding's
599/// message and path can never drift from `decided relationships --sarif`
600/// (the gate's relationship paths are therefore the ENCODED uri form).
601pub(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
657// --- relationships (inspection, non --validate) ------------------------------
658
659pub 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
734// --- relationships --validate ----------------------------------------------------
735
736fn 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
890// --- schema / templates ------------------------------------------------------
891
892pub 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
1003/// `_metadata_default(section, values)`.
1004fn 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
1014/// `_starter_body(ref, section, metadata_values)`.
1015fn 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    // template_sections = required + recommended.
1028    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
1056// --- diff --------------------------------------------------------------------
1057
1058/// One titled block of single-line `+`/`-` entries (added/removed).
1059fn 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    // Blank line between blocks.
1116    blocks.join("\n\n")
1117}
1118
1119/// `asdict(Requirement)` — field order `id, text, line`.
1120fn 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
1128/// `render_diff_json` — fixed key order; `old`/`new` echo the raw argv paths.
1129pub 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
1163// --- inspect -----------------------------------------------------------------
1164
1165/// Add a Relationships block when the artifact declares related artifacts.
1166fn 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
1180/// Add Status / Category / Supersedes lines when a decision declares them.
1181fn 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    // `if value` — truthy filter, so an empty string is dropped too.
1188    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
1230/// Python `f"{x:g}"` for the score arithmetic operands. `points`/`ceiling`
1231/// are small multiples of 0.5 (bounded by section counts), so the general
1232/// format never reaches scientific notation: integral values drop the
1233/// decimal, halves render as `n.5`.
1234fn 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
1242/// Explainable single-file output: matches, misses, and the score math.
1243pub fn render_inspect_verbose(result: &InspectionResult, scores: &[TypeScore]) -> String {
1244    // The TypeScore matching the chosen type, or scores[0] for Unknown.
1245    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
1328/// `InspectionResult.to_dict()` — additive-friendly: decision metadata and
1329/// `relationships` appear only when present.
1330pub 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    // `if value is not None` — an empty string IS emitted here (unlike human).
1355    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
1406// --- improve -----------------------------------------------------------------
1407
1408/// Shown when guidance cannot be produced (`_UNKNOWN_MESSAGE`).
1409const UNKNOWN_MESSAGE: &str =
1410    "Unable to generate improvement guidance.\nArtifact type could not be determined.";
1411
1412/// `_unsupported_message(result)` — a known but unsupported artifact type.
1413/// Unreachable today (all five specs support improve); ported for fidelity.
1414fn 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
1463/// `ImprovementResult.to_dict()` — `{type, missing_required,
1464/// missing_recommended, guidance}`, sections snake_cased.
1465pub 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
1492/// Markdown templates for the missing sections (required first).
1493pub 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
1544// --- stats -------------------------------------------------------------------
1545
1546const 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
1700/// `  <red path> — <error codes | "unknown">` invalid-list line.
1701fn 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
1849// --- portfolio ---------------------------------------------------------------
1850
1851/// Human `decided portfolio` output (`render_portfolio_human`).
1852pub 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
1943/// Human `decided index` output: the repository manifest (services/index.py).
1944pub 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    // Aligned columns: ID, type, title (— when absent), path last.
1958    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
1979/// JSON `decided index` output — `json.dumps(index.to_dict(), indent=2)`
1980/// (ensure_ascii default; identity-only contract, ADR-007).
1981pub 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
2004/// JSON `decided portfolio` output — `PortfolioSummary.to_dict()` (ADR-007).
2005pub fn render_portfolio_json(s: &PortfolioSummary) -> String {
2006    dumps_indent2(&portfolio_summary_value(s))
2007}
2008
2009/// `PortfolioSummary.to_dict()` as a `Value` — shared by the portfolio JSON
2010/// renderer and the index store's portfolio segment (ADR-103/104), so the
2011/// two cannot drift.
2012pub 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
2079// --- coverage ----------------------------------------------------------------
2080
2081/// Human `decided coverage` output (`render_coverage_human`).
2082pub 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
2126/// JSON `decided coverage` output — `json.dumps(report.to_dict(), indent=2,
2127/// ensure_ascii=False)`.
2128pub 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
2156// --- decisions-for -----------------------------------------------------------
2157
2158/// Human `decided decisions-for` output: aligned `id  status  title` rows with the
2159/// matching declared `## Applies To` entry under each, or a valid empty result.
2160pub 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
2213/// JSON `decided decisions-for` output — the same `ScopeLookupResult` payload the
2214/// MCP `find_decisions` path argument serializes (ADR-031).
2215pub fn render_decisions_for_json(result: &ScopeLookupResult) -> String {
2216    dumps_indent2(&scope_lookup_value(result))
2217}
2218
2219// --- review ------------------------------------------------------------------
2220
2221fn 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
2404// --- gate --------------------------------------------------------------------
2405
2406pub 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
2507/// One combined SARIF run over ALL gate findings, blocking and advisory
2508/// alike; the intrinsic severity drives the level, the enforcement class
2509/// lives only in the exit code. A relationship finding's path is already
2510/// the percent-encoded uri (shared builder), so it is quoted AGAIN here —
2511/// exactly the oracle's double `quote()` on that source.
2512pub 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
2527// --- sentry ------------------------------------------------------------------
2528
2529pub 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
2636// --- doctor ------------------------------------------------------------------
2637
2638pub fn render_doctor_human(report: &DoctorReport) -> String {
2639    // Plain strings throughout — the oracle's doctor renderer uses no color.
2640    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        // "ERROR  " is padded to WARNING's width (7), then the shared
2656        // two-space gap — so ERROR carries four spaces before the path.
2657        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
2685/// `json.dumps(..., indent=2, ensure_ascii=False)` — raw UTF-8, unlike
2686/// gate's default-ASCII dump.
2687pub 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
2704// --- export ------------------------------------------------------------------
2705
2706pub fn render_export_json(export: &CorpusExport) -> String {
2707    let mut corpus = Map::new();
2708    corpus.insert("name".into(), json!(export.corpus_name));
2709    // Stable export schema key and model field; the RAC spelling is durable
2710    // compatibility data, not a command/product alias.
2711    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
2750// --- export --agent-rules ------------------------------------------------
2751
2752/// Per-file state icon (`_AGENT_RULES_ICONS`): a write/update is a neutral
2753/// bullet, in-sync the green clear, stale/missing the red failure.
2754fn 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
2824/// `json.dumps(result.to_dict(), indent=2)` (ensure_ascii default).
2825pub 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
2905// --- resolve / find (PORT-CONTRACT.d/06 §3.1, §11–13) -------------------------
2906
2907/// `render_resolve_human`: the resolved-artifact card. Missing title renders
2908/// `—` (U+2014); the id is bold only on a tty.
2909pub 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
2919/// `ResolutionResult.to_dict()` for the failure outcomes — the `decided resolve
2920/// --json` error body, also served as the MCP structured lookup error
2921/// (`errors.from_resolution`, ADR-034).
2922pub 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)); // as given, unstripped
2927    if !result.duplicate_paths.is_empty() {
2928        m.insert("paths".into(), json!(result.duplicate_paths));
2929    }
2930    Value::Object(m)
2931}
2932
2933/// `render_resolve_json` — `ResolutionResult.to_dict()` with `indent=2`.
2934pub 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    // section/snippet/evidence/recency/tags are never set on the
2946    // resolution path — the keys stay absent.
2947    dumps_indent2(&Value::Object(m))
2948}
2949
2950/// The match `recency` dict: `{last_committed, age_days, stale}`, all three
2951/// keys always present, each null when unknown.
2952pub 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
2960/// The search-match `evidence` dict exactly as `decided find --json --explain`
2961/// serializes it: `{field, terms, tier, score, components:{bm25,
2962/// lexical_rank, graph_rank, inbound}}`.
2963pub 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
2978/// `ResolvedArtifact.to_dict(include_evidence=…)` — the search-match /
2979/// resolved-artifact dict in pinned key order (`id, type, title, path,
2980/// [section], [snippet], [evidence], [recency], [tags]`); conditional keys
2981/// are absent, never null (except `title`). Shared by the CLI `decided find`
2982/// renderers and the MCP tool payloads.
2983pub 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
3009/// `render_retrieve_human(payload)` — the human `decided retrieve` block
3010/// (ADR-113), rendered from the budget-shaped payload (post-truncation), so
3011/// the human view reflects exactly what the JSON face carries.
3012pub 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    // `i.get("status") or "—"` / `i.get("title") or "—"` — falsy ⇒ em dash.
3023    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
3102/// `SearchResult.to_dict(include_evidence=…)` — `{schema_version, query,
3103/// type, match_count, matches}`. Shared by `render_find_json` (which wraps
3104/// it in `indent=2` dumps) and the MCP search payloads (budget serializer).
3105pub 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
3124/// `render_find_json` — `SearchResult.to_dict(include_evidence=explain)`.
3125pub fn render_find_json(result: &SearchResult, explain: bool) -> String {
3126    dumps_indent2(&search_result_value(result, explain))
3127}
3128
3129/// `render_find_human` — aligned match rows, or a valid empty result
3130/// (PORT-CONTRACT.d/06 §13). `{query!r}` is Python string repr.
3131pub 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
3205// --- mcp-stats / usage (state read-back, ADR-040/ADR-046) --------------------
3206
3207/// Human `decided mcp-stats` output — what the local telemetry log says. An
3208/// empty or missing log is a valid answer, rendered as guidance. Note
3209/// `First Event:`/`Last Event:` print the Python value directly, so a
3210/// populated log whose events all lack a string `ts` prints `None`.
3211pub 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
3255/// JSON `decided mcp-stats` output — `json.dumps(to_dict(), indent=2)`, which
3256/// is `ensure_ascii=True` (the ONLY ascii-escaped payload of the three
3257/// state surfaces; `usage --json` and both share URLs use
3258/// `ensure_ascii=False`).
3259pub fn render_mcp_stats_json(summary: &crate::telemetry::TelemetrySummary) -> String {
3260    dumps_indent2(&crate::telemetry::summary_value(summary))
3261}
3262
3263/// Human `decided usage` output — the unified CLI + Guide read-back (ADR-046).
3264/// Two DIFFERENT error pluralizations by design: CLI rows use
3265/// `error`/`errors` (`'s' if errors != 1`), Guide rows always `error(s)`;
3266/// both suffixes appear only when the count is nonzero, with the
3267/// tool/command column left-justified to 16 code points.
3268pub 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
3319/// JSON `decided usage` output — the combined payload with
3320/// `ensure_ascii=False, indent=2` (raw UTF-8, unlike mcp-stats).
3321pub 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
3328// --- skill / hook (decided skill, decided hook — PORT-CONTRACT.d/15) -----------------
3329
3330/// Human `decided skill list`: bold header, blank line, `- <name ljust w>  <desc>`
3331/// rows in registry order; the name column width is computed dynamically as
3332/// `max(len(name))` (13 with the current set).
3333pub 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
3343/// JSON `decided skill list` (stable contract, ADR-007; `ensure_ascii=True`).
3344pub 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
3352/// Human `decided skill install`: one `Installed <name> skill: <path>` line per
3353/// skill, blank line, the discovery blurb.
3354pub 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
3368/// JSON `decided skill install` — note: no `bytes_written` (it is in the
3369/// oracle's model but NOT in `to_dict`, skill brief landmine 4).
3370pub 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
3379/// Human `decided hook list`: bold header, blank line, `- <style ljust w>  <desc>`
3380/// rows (style column width dynamic = 11 with the current set).
3381pub 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
3391/// JSON `decided hook list` (stable contract, ADR-007).
3392pub 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
3400/// Human `decided hook install`.
3401pub 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
3408/// JSON `decided hook install`.
3409pub 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
3417// --- scaffold writes (decided new / init / quickstart / migrate / rename —
3418// PORT-CONTRACT.d/16) ---------------------------------------------------------
3419
3420/// Human `decided new`: what was created, its identity, and the next step. The
3421/// path is the argv string VERBATIM (no pathlib normalization).
3422pub 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
3429/// JSON `decided new` (stable contract, ADR-007) — note: NO `bytes_written`
3430/// (in the oracle's dataclass but not its `to_dict`).
3431pub 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
3441/// Human `decided init`: the established identity namespace. The idempotent
3442/// verb carries its own colon (`Already initialized:`).
3443pub 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
3463/// JSON `decided init` (stable contract, ADR-007).
3464pub 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
3476/// Human `decided quickstart`: identity (`Initialized`/`Using`), first
3477/// artifact, next step.
3478pub 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
3487/// JSON `decided quickstart` (stable contract, ADR-007) — nested `artifact`.
3488pub 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
3502/// Human `decided migrate metadata`: assigned IDs and what remains. The
3503/// migrated path column is code-point ljust'd; the Skipped block appears
3504/// only when unknowns exist.
3505pub 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
3560/// JSON `decided migrate metadata` (stable contract, ADR-007).
3561pub 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
3589/// The stable refusal-reason phrases (`_RENAME_REASONS`).
3590fn 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
3618/// Human `decided rename` plan: a reviewable diff-hunk preview (dry run) or a
3619/// refusal (the CALLER routes refusals to stderr).
3620pub 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
3673/// JSON `decided rename` plan (stable additive contract, ADR-007) — emitted to
3674/// STDOUT for refusals too (unlike the human refusal, which goes stderr).
3675pub 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
3705/// Human `decided rename --apply` outcome.
3706pub 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
3723/// JSON `decided rename --apply` outcome (no `edits` array).
3724pub 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
3737// --- watchkeeper --------------------------------------------------------------
3738
3739use 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
3750/// `str(x)` for the optional fields Python interpolates directly — `None`
3751/// renders as the literal `None` (the duplicate-identifier annotation's
3752/// `reference 'None'` is contract bytes).
3753fn 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            // `issue.identifier or issue.path` — empty string is falsy.
3761            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
3780/// Human-readable `decided watchkeeper` output (v0.12.0).
3781pub 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    // Review verdict (v0.12.2).
3911    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    // Mirrors the `decided diff` JSON fields so the requirement-level shape is
3974    // the same wherever a diff appears.
3975    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
4021/// JSON `decided watchkeeper` output (stable contract, ADR-007).
4022pub 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
4112/// Repository-relative annotation path: `PurePosixPath(directory) / rel`.
4113fn wk_repo_path(report: &WatchkeeperReport, corpus_relative: &str) -> String {
4114    crate::walk::py_join(&report.directory, &[corpus_relative])
4115}
4116
4117/// The Markdown step-summary report (`decided watchkeeper --format github`).
4118pub 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
4232/// Workflow-command lines for the step log, one annotation per line.
4233/// Recommendation triggers annotate as errors; other warnings as warnings;
4234/// informational findings as notices. Deterministic order: delta-driven
4235/// errors first, then findings in report order.
4236pub 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        // Duplicate-identifier findings can span files; annotate the first.
4246        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}