1use serde::Serialize;
15
16use crate::utils::upward_walk::WalkStop;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum SearchStage {
22 Explicit,
24 Project,
26 Markdownlint,
28 User,
30}
31
32impl SearchStage {
33 fn heading(self) -> &'static str {
34 match self {
35 Self::Explicit => "Explicit config",
36 Self::Project => "Project config search",
37 Self::Markdownlint => "Markdownlint config search",
38 Self::User => "User-level config search",
39 }
40 }
41
42 fn label(self) -> &'static str {
43 match self {
44 Self::Explicit => "explicit config",
45 Self::Project => "project config",
46 Self::Markdownlint => "markdownlint config",
47 Self::User => "user config",
48 }
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
54#[serde(tag = "status", rename_all = "snake_case")]
55pub enum ProbeOutcome {
56 Found,
57 Missing,
58 IneligiblePyproject,
60 AccessError {
63 error: String,
64 },
65}
66
67impl ProbeOutcome {
68 fn text(&self) -> String {
69 match self {
70 Self::Found => "found".to_string(),
71 Self::Missing => "missing".to_string(),
72 Self::IneligiblePyproject => "ignored (pyproject.toml without a rumdl section)".to_string(),
73 Self::AccessError { error } => format!("not usable ({error})"),
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
80#[serde(rename_all = "snake_case")]
81pub enum HomeSource {
82 HomeEnv,
84 UserProfile,
86 PlatformDefault,
88 Caller,
90}
91
92impl HomeSource {
93 fn text(self) -> &'static str {
94 match self {
95 Self::HomeEnv => "HOME",
96 Self::UserProfile => "USERPROFILE or the user profile API",
97 Self::PlatformDefault => "HOME or the user database",
98 Self::Caller => "supplied by the caller",
99 }
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
105#[serde(rename_all = "snake_case")]
106pub enum IgnoredHomeReason {
107 Empty,
108 NotUnicode,
109 MsysStyle,
111 NotFullyQualified,
114}
115
116impl IgnoredHomeReason {
117 fn text(self) -> &'static str {
118 match self {
119 Self::Empty => "it is empty",
120 Self::NotUnicode => "it is not valid unicode",
121 Self::MsysStyle => "it is an MSYS-style path (/c/Users/...), not a native Windows path",
122 Self::NotFullyQualified => "it is not a fully qualified Windows path",
123 }
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
129#[serde(tag = "reason", rename_all = "snake_case")]
130pub enum WalkEnd {
131 HomeBoundary {
133 path: String,
134 },
135 ProfileBoundary {
138 path: String,
139 },
140 GitRoot {
142 path: String,
143 },
144 StopRoot {
146 path: String,
147 },
148 DepthCap,
149 FilesystemRoot,
150}
151
152impl WalkEnd {
153 fn text(&self) -> String {
154 match self {
155 Self::HomeBoundary { path } => {
156 format!("reached the home directory boundary ({path}), which is not searched")
157 }
158 Self::ProfileBoundary { path } => {
159 format!("reached the profile directory (fallback home) boundary ({path}), which is not searched")
160 }
161 Self::GitRoot { path } => format!("reached the git repository root ({path})"),
162 Self::StopRoot { path } => format!("reached the search root ({path})"),
163 Self::DepthCap => "reached the maximum search depth".to_string(),
164 Self::FilesystemRoot => "reached the filesystem root".to_string(),
165 }
166 }
167}
168
169impl From<&WalkStop> for WalkEnd {
170 fn from(stop: &WalkStop) -> Self {
171 let path = |p: &std::path::Path| p.to_string_lossy().into_owned();
172 match stop {
173 WalkStop::HomeBoundary(p) => Self::HomeBoundary { path: path(p) },
174 WalkStop::ProfileBoundary(p) => Self::ProfileBoundary { path: path(p) },
175 WalkStop::GitRoot(p) => Self::GitRoot { path: path(p) },
176 WalkStop::StopRoot(p) => Self::StopRoot { path: path(p) },
177 WalkStop::DepthCap => Self::DepthCap,
178 WalkStop::FilesystemRoot => Self::FilesystemRoot,
179 }
180 }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
185#[serde(rename_all = "snake_case")]
186pub enum SkipReason {
187 ProjectConfigFound,
188 ExplicitConfig,
189 Isolated,
190}
191
192impl SkipReason {
193 fn text(self) -> &'static str {
194 match self {
195 Self::ProjectConfigFound => "a project config was found",
196 Self::ExplicitConfig => "--config was given",
197 Self::Isolated => "--no-config/--isolated was given",
198 }
199 }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
204#[serde(tag = "event", rename_all = "snake_case")]
205pub enum TraceEvent {
206 StartDirectory {
207 path: String,
208 },
209 Home {
210 path: Option<String>,
211 source: Option<HomeSource>,
212 },
213 IgnoredHome {
214 value: String,
215 reason: IgnoredHomeReason,
216 },
217 Probe {
218 stage: SearchStage,
219 path: String,
220 #[serde(flatten)]
221 outcome: ProbeOutcome,
222 #[serde(skip_serializing_if = "std::ops::Not::not")]
225 profile_fallback: bool,
226 },
227 WalkEnded {
228 stage: SearchStage,
229 #[serde(flatten)]
230 end: WalkEnd,
231 },
232 Skipped {
233 stage: SearchStage,
234 reason: SkipReason,
235 },
236 Selected {
237 stage: SearchStage,
238 path: String,
239 shadows: Vec<String>,
241 #[serde(skip_serializing_if = "std::ops::Not::not")]
243 profile_fallback: bool,
244 },
245}
246
247const PROFILE_FALLBACK_LABEL: &str = "profile fallback: HOME is set to a different directory";
249
250impl TraceEvent {
251 pub fn describe(&self) -> String {
253 match self {
254 Self::StartDirectory { path } => format!("start directory: {path}"),
255 Self::Home {
256 path: Some(path),
257 source: Some(source),
258 } => format!("home directory: {path} (from {})", source.text()),
259 Self::Home { .. } => "home directory: not resolved".to_string(),
260 Self::IgnoredHome { value, reason } => format!("ignored HOME={value:?}: {}", reason.text()),
261 Self::Probe {
262 path,
263 outcome,
264 profile_fallback,
265 ..
266 } => {
267 let label = if *profile_fallback {
268 format!(" [{PROFILE_FALLBACK_LABEL}]")
269 } else {
270 String::new()
271 };
272 format!("{path}: {}{label}", outcome.text())
273 }
274 Self::WalkEnded { end, .. } => format!("search ended: {}", end.text()),
275 Self::Skipped { stage, reason } => {
276 let what = match stage {
277 SearchStage::User => "user-level search",
278 SearchStage::Markdownlint => "markdownlint search",
279 SearchStage::Project => "project search",
280 SearchStage::Explicit => "explicit config",
281 };
282 format!("{what} skipped: {}", reason.text())
283 }
284 Self::Selected {
285 stage,
286 path,
287 shadows,
288 profile_fallback,
289 } => {
290 let mut line = format!("selected {}: {path}", stage.label());
291 if *profile_fallback {
292 line.push_str(&format!(" [{PROFILE_FALLBACK_LABEL}]"));
293 }
294 if !shadows.is_empty() {
295 line.push_str(&format!(" (shadows: {})", shadows.join(", ")));
296 }
297 line
298 }
299 }
300 }
301}
302
303#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
305pub struct DiscoveryTrace {
306 events: Vec<TraceEvent>,
307}
308
309impl DiscoveryTrace {
310 pub fn record(&mut self, event: TraceEvent) {
312 log::debug!("[rumdl-config] discovery: {}", event.describe());
313 self.events.push(event);
314 }
315
316 pub fn events(&self) -> &[TraceEvent] {
317 &self.events
318 }
319
320 pub fn reject_selected(&mut self, stage: SearchStage, path: &str, reason: &str) {
324 let profile_fallback = self.events.iter().any(|e| {
325 matches!(e, TraceEvent::Selected { stage: s, path: p, profile_fallback: true, .. } if *s == stage && p == path)
326 });
327 self.events
328 .retain(|e| !matches!(e, TraceEvent::Selected { stage: s, path: p, .. } if *s == stage && p == path));
329 self.record(TraceEvent::Probe {
330 stage,
331 path: path.to_string(),
332 outcome: ProbeOutcome::AccessError {
333 error: reason.to_string(),
334 },
335 profile_fallback,
336 });
337 }
338
339 pub fn has_home(&self) -> bool {
342 self.events.iter().any(|e| matches!(e, TraceEvent::Home { .. }))
343 }
344
345 pub fn has_start_directory(&self) -> bool {
346 self.events
347 .iter()
348 .any(|e| matches!(e, TraceEvent::StartDirectory { .. }))
349 }
350
351 pub fn selected(&self) -> impl Iterator<Item = (&SearchStage, &str)> {
353 self.events.iter().filter_map(|e| match e {
354 TraceEvent::Selected { stage, path, .. } => Some((stage, path.as_str())),
355 _ => None,
356 })
357 }
358
359 pub fn render_text(&self) -> String {
361 let mut out = String::new();
362 let mut stage: Option<SearchStage> = None;
363 let mut markdownlint_selected = false;
364 let mut any_selected = false;
365 for event in &self.events {
366 let event_stage = match event {
367 TraceEvent::Probe { stage, .. } | TraceEvent::WalkEnded { stage, .. } => Some(*stage),
368 _ => None,
369 };
370 if let Some(s) = event_stage
371 && stage != Some(s)
372 {
373 out.push_str(&format!("{}:\n", s.heading()));
374 stage = Some(s);
375 }
376 match event {
377 TraceEvent::Probe { .. } | TraceEvent::WalkEnded { .. } => {
378 out.push_str(&format!(" {}\n", event.describe()));
379 }
380 TraceEvent::Selected {
381 stage: s,
382 path,
383 shadows,
384 profile_fallback,
385 } => {
386 stage = None;
387 any_selected = true;
388 let note = if *s == SearchStage::User && markdownlint_selected {
389 " (applied as the base under the markdownlint config)"
390 } else {
391 ""
392 };
393 if *s == SearchStage::Markdownlint {
394 markdownlint_selected = true;
395 }
396 let label = if *profile_fallback {
397 format!(" [{PROFILE_FALLBACK_LABEL}]")
398 } else {
399 String::new()
400 };
401 out.push_str(&format!("selected {}: {path}{label}{note}\n", s.label()));
402 for shadow in shadows {
403 out.push_str(&format!(" shadows (lower priority): {shadow}\n"));
404 }
405 }
406 _ => {
407 stage = None;
408 out.push_str(&format!("{}\n", event.describe()));
409 }
410 }
411 }
412 if !any_selected {
413 out.push_str("selected: none (using defaults)\n");
414 }
415 out
416 }
417
418 pub fn to_json(&self) -> String {
420 #[derive(Serialize)]
421 struct Selected<'a> {
422 stage: SearchStage,
423 path: &'a str,
424 }
425 #[derive(Serialize)]
426 struct Document<'a> {
427 events: &'a [TraceEvent],
428 selected: Vec<Selected<'a>>,
429 }
430 let selected = self
431 .selected()
432 .map(|(stage, path)| Selected { stage: *stage, path })
433 .collect();
434 serde_json::to_string_pretty(&Document {
435 events: &self.events,
436 selected,
437 })
438 .unwrap_or_else(|_| "{}".to_string())
439 }
440}