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}