Skip to main content

dotrepo_cli/
format.rs

1use anyhow::{bail, Result};
2use dotrepo_core::{
3    ClaimHandoffOutcome, ClaimInspectionReport, ConflictRelationship, DoctorOwnershipHonesty,
4    DoctorRecommendedMode, DoctorSurface, ManagedFileState, SelectionReason, SurfacePreviewReport,
5    TrustReport,
6};
7use dotrepo_schema::Trust;
8use serde_json::Value;
9
10pub fn format_trust_report(report: &TrustReport) -> String {
11    let selected = &report.selection.record;
12    let mut lines = vec![
13        format!(
14            "selected: {} ({:?}, {:?})",
15            selected.manifest_path, selected.record.mode, selected.record.status
16        ),
17        format!(
18            "selection reason: {}",
19            format_selection_reason(report.selection.reason)
20        ),
21    ];
22
23    append_record_details(
24        &mut lines,
25        "",
26        selected.record.source.as_deref(),
27        selected.record.trust.as_ref(),
28        selected.claim.as_ref(),
29    );
30
31    if !report.conflicts.is_empty() {
32        lines.push("conflicts:".into());
33        for conflict in &report.conflicts {
34            lines.push(format!(
35                "- {} ({:?}, {:?})",
36                conflict.record.manifest_path,
37                conflict.record.record.mode,
38                conflict.record.record.status
39            ));
40            lines.push(format!(
41                "  relationship: {}",
42                format_conflict_relationship(conflict.relationship)
43            ));
44            lines.push(format!(
45                "  reason: {}",
46                format_selection_reason(conflict.reason)
47            ));
48            append_record_details(
49                &mut lines,
50                "  ",
51                conflict.record.record.source.as_deref(),
52                conflict.record.record.trust.as_ref(),
53                conflict.record.claim.as_ref(),
54            );
55        }
56    }
57
58    lines.join("\n")
59}
60
61pub fn format_claim_report(report: &ClaimInspectionReport) -> String {
62    let mut lines = vec![
63        format!("claim: {}", report.claim_path),
64        format!("state: {:?}", report.state),
65        format!("kind: {:?}", report.kind),
66        format!(
67            "identity: {}/{}/{}",
68            report.identity.host, report.identity.owner, report.identity.repo
69        ),
70        format!(
71            "claimant: {} ({})",
72            report.claimant.display_name, report.claimant.asserted_role
73        ),
74    ];
75
76    if let Some(contact) = &report.claimant.contact {
77        lines.push(format!("contact: {}", contact));
78    }
79    if let Some(review_path) = &report.review_path {
80        lines.push(format!("review: {}", review_path));
81    }
82    if let Some(handoff) = report.target.handoff {
83        lines.push(format!("handoff: {}", format_claim_handoff(handoff)));
84    }
85    if !report.target.index_paths.is_empty() {
86        lines.push("target index paths:".into());
87        for path in &report.target.index_paths {
88            lines.push(format!("- {}", path));
89        }
90    }
91    if !report.target.record_sources.is_empty() {
92        lines.push("target record sources:".into());
93        for source in &report.target.record_sources {
94            lines.push(format!("- {}", source));
95        }
96    }
97    if let Some(url) = &report.target.canonical_repo_url {
98        lines.push(format!("canonical repo url: {}", url));
99    }
100    if let Some(resolution) = &report.resolution {
101        if let Some(path) = &resolution.canonical_record_path {
102            lines.push(format!("canonical record path: {}", path));
103        }
104        if let Some(path) = &resolution.canonical_mirror_path {
105            lines.push(format!("canonical mirror path: {}", path));
106        }
107        if let Some(path) = &resolution.result_event {
108            lines.push(format!("result event: {}", path));
109        }
110    }
111    if !report.events.is_empty() {
112        lines.push("events:".into());
113        for event in &report.events {
114            let mut line = format!(
115                "- [{}] {:?} at {} by {}",
116                event.sequence, event.kind, event.timestamp, event.actor
117            );
118            if let (Some(from), Some(to)) = (&event.from, &event.to) {
119                line.push_str(&format!(" ({from:?} -> {to:?})"));
120            }
121            lines.push(line);
122            lines.push(format!("  {}", event.summary));
123        }
124    }
125
126    lines.join("\n")
127}
128
129pub fn format_query_value(value: &Value, raw: bool) -> Result<String> {
130    match value {
131        Value::String(text) => Ok(text.clone()),
132        Value::Null => {
133            if raw {
134                Ok(String::new())
135            } else {
136                Ok("null".into())
137            }
138        }
139        Value::Bool(flag) => Ok(flag.to_string()),
140        Value::Number(number) => Ok(number.to_string()),
141        Value::Array(_) | Value::Object(_) => {
142            if raw {
143                bail!("--raw is only supported for scalar query results");
144            }
145            Ok(serde_json::to_string_pretty(value)?)
146        }
147    }
148}
149
150fn append_record_details(
151    lines: &mut Vec<String>,
152    indent: &str,
153    source: Option<&str>,
154    trust: Option<&Trust>,
155    claim: Option<&dotrepo_core::RecordClaimContext>,
156) {
157    lines.push(format!("{}source: {}", indent, source.unwrap_or("none")));
158    if let Some(trust) = trust {
159        lines.push(format!(
160            "{}confidence: {}",
161            indent,
162            trust.confidence.as_deref().unwrap_or("none")
163        ));
164        lines.push(format!(
165            "{}provenance: {}",
166            indent,
167            format_provenance(&trust.provenance)
168        ));
169        lines.push(format!(
170            "{}notes: {}",
171            indent,
172            trust.notes.as_deref().unwrap_or("none")
173        ));
174    } else {
175        lines.push(format!("{}confidence: none", indent));
176        lines.push(format!("{}provenance: none", indent));
177        lines.push(format!("{}notes: none", indent));
178    }
179    if let Some(claim) = claim {
180        lines.push(format!(
181            "{}claim: {:?} ({})",
182            indent,
183            claim.state,
184            format_claim_handoff(claim.handoff)
185        ));
186        lines.push(format!("{}claim path: {}", indent, claim.claim_path));
187    }
188}
189
190pub fn format_selection_reason(reason: SelectionReason) -> &'static str {
191    match reason {
192        SelectionReason::OnlyMatchingRecord => "only matching record",
193        SelectionReason::CanonicalPreferred => {
194            "canonical record preferred over lower-authority competing records"
195        }
196        SelectionReason::HigherStatusOverlay => {
197            "higher-status overlay preferred over lower-status competing overlays"
198        }
199        SelectionReason::EqualAuthorityConflict => {
200            "equal-authority conflict; selected by stable path ordering while preserving competing records"
201        }
202    }
203}
204
205pub fn format_conflict_relationship(relationship: ConflictRelationship) -> &'static str {
206    match relationship {
207        ConflictRelationship::Superseded => "superseded",
208        ConflictRelationship::Parallel => "parallel",
209    }
210}
211
212pub fn format_claim_handoff(handoff: ClaimHandoffOutcome) -> &'static str {
213    match handoff {
214        ClaimHandoffOutcome::PendingCanonical => "pending_canonical",
215        ClaimHandoffOutcome::Superseded => "superseded",
216        ClaimHandoffOutcome::Parallel => "parallel",
217        ClaimHandoffOutcome::Rejected => "rejected",
218        ClaimHandoffOutcome::Withdrawn => "withdrawn",
219        ClaimHandoffOutcome::Disputed => "disputed",
220    }
221}
222
223pub fn format_provenance(provenance: &[String]) -> String {
224    if provenance.is_empty() {
225        "none".into()
226    } else {
227        provenance.join(", ")
228    }
229}
230
231pub fn format_managed_file_state(state: ManagedFileState) -> &'static str {
232    match state {
233        ManagedFileState::Missing => "missing",
234        ManagedFileState::FullyGenerated => "fully_generated",
235        ManagedFileState::PartiallyManaged => "partially_managed",
236        ManagedFileState::Unmanaged => "unmanaged",
237        ManagedFileState::MalformedManaged => "malformed_managed",
238        ManagedFileState::Unsupported => "unsupported",
239    }
240}
241
242pub fn format_doctor_surface(surface: DoctorSurface) -> &'static str {
243    match surface {
244        DoctorSurface::Readme => "readme",
245        DoctorSurface::Security => "security",
246        DoctorSurface::Contributing => "contributing",
247        DoctorSurface::Codeowners => "codeowners",
248        DoctorSurface::PullRequestTemplate => "pull_request_template",
249    }
250}
251
252pub fn format_doctor_recommended_mode(mode: DoctorRecommendedMode) -> &'static str {
253    match mode {
254        DoctorRecommendedMode::Generate => "generate",
255        DoctorRecommendedMode::PartiallyManaged => "partially_managed",
256        DoctorRecommendedMode::Skip => "skip",
257    }
258}
259
260pub fn format_doctor_ownership_honesty(honesty: DoctorOwnershipHonesty) -> &'static str {
261    match honesty {
262        DoctorOwnershipHonesty::Honest => "honest",
263        DoctorOwnershipHonesty::LossyFullGeneration => "lossy_full_generation",
264    }
265}
266
267pub fn print_surface_preview_report(report: &SurfacePreviewReport) {
268    println!("dotrepo preview");
269    for preview in &report.previews {
270        println!(
271            "surface: {}",
272            format_doctor_surface(preview.finding.surface)
273        );
274        println!("path: {}", preview.finding.path.display());
275        println!(
276            "state: {}",
277            format_managed_file_state(preview.finding.state)
278        );
279        if let Some(mode) = preview.finding.declared_mode.clone() {
280            println!("declared mode: {}", format_compat_mode(mode));
281        }
282        if let Some(honesty) = preview.finding.ownership_honesty {
283            println!(
284                "ownership honesty: {}",
285                format_doctor_ownership_honesty(honesty)
286            );
287        }
288        if let Some(mode) = preview.finding.recommended_mode {
289            println!("recommended mode: {}", format_doctor_recommended_mode(mode));
290        }
291        if let Some(drop) = preview.finding.would_drop_unmanaged_content {
292            println!("content loss risk: {}", if drop { "yes" } else { "no" });
293        }
294        println!(
295            "preserves unmanaged content: {}",
296            if preview.preserves_unmanaged_content {
297                "yes"
298            } else {
299                "no"
300            }
301        );
302        println!(
303            "replacement mode: {}",
304            if preview.full_replacement {
305                "full_replacement"
306            } else {
307                "in_place_or_create"
308            }
309        );
310        println!("summary: {}", preview.finding.message);
311        if !preview.finding.advice.is_empty() {
312            println!("advice:");
313            for advice in &preview.finding.advice {
314                println!("- {}", advice);
315            }
316        }
317        if let Some(current) = &preview.current {
318            println!("current:");
319            println!("{}", current);
320        }
321        println!("proposed:");
322        println!("{}", preview.proposed);
323    }
324}
325
326pub fn format_compat_mode(mode: dotrepo_schema::CompatMode) -> &'static str {
327    match mode {
328        dotrepo_schema::CompatMode::Generate => "generate",
329        dotrepo_schema::CompatMode::Skip => "skip",
330    }
331}