Skip to main content

rumdl_lib/config/
discovery_trace.rs

1//! A record of how configuration discovery reached its answer.
2//!
3//! Discovery decides in several places: which directory the walk started in,
4//! which home directory bounds it, which candidate files exist, why the walk
5//! stopped and which file won. Each of those places records its decision here
6//! as it makes it, so the trace is the discovery that ran rather than a second
7//! description of it that could drift. `rumdl config file --explain` renders the
8//! trace, and every event is also written to the debug log as it is recorded.
9//!
10//! The trace names paths and the reasons discovery acted on them. It never holds
11//! file contents, and it does not follow `extends`: the files a configuration
12//! pulled in are answered by `SourcedConfig::loaded_files`.
13
14use serde::Serialize;
15
16use crate::utils::upward_walk::WalkStop;
17
18/// Which part of discovery an event belongs to.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum SearchStage {
22    /// A config named with `--config`.
23    Explicit,
24    /// The upward walk for a rumdl project config.
25    Project,
26    /// The upward walk for a markdownlint config.
27    Markdownlint,
28    /// The user-level config locations.
29    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/// What was found at one candidate path.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
54#[serde(tag = "status", rename_all = "snake_case")]
55pub enum ProbeOutcome {
56    Found,
57    Missing,
58    /// A `pyproject.toml` that exists but has no rumdl section.
59    IneligiblePyproject,
60    /// The path could not be inspected or read for a reason other than being
61    /// absent (permissions, a directory where a file belongs, ...).
62    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/// Where the home directory came from.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
80#[serde(rename_all = "snake_case")]
81pub enum HomeSource {
82    /// The `HOME` environment variable (honored on Windows when it is a native path).
83    HomeEnv,
84    /// `USERPROFILE`, then the user profile API (Windows).
85    UserProfile,
86    /// `HOME`, then the user database (everywhere but Windows).
87    PlatformDefault,
88    /// A directory the caller supplied (a language server or a test).
89    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/// Why a `HOME` value was not used as the home directory.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
105#[serde(rename_all = "snake_case")]
106pub enum IgnoredHomeReason {
107    Empty,
108    NotUnicode,
109    /// `/c/Users/x`, the form MSYS and Cygwin shells use for a Windows path.
110    MsysStyle,
111    /// Anything else that is not a fully qualified Windows path: relative,
112    /// drive-relative (`C:foo`), rooted without a drive (`/home/x`).
113    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/// Why an upward walk ended without finding anything more.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
129#[serde(tag = "reason", rename_all = "snake_case")]
130pub enum WalkEnd {
131    /// The walk reached the home directory, which is user-level and not searched as a project.
132    HomeBoundary {
133        path: String,
134    },
135    /// The walk reached the user profile directory, a home in its own right next to
136    /// the resolved one, which is user-level and not searched as a project.
137    ProfileBoundary {
138        path: String,
139    },
140    /// The walk reached the root of a git repository.
141    GitRoot {
142        path: String,
143    },
144    /// The walk reached the directory it was told to stop at.
145    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/// Why a stage of discovery did not run.
184#[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/// One decision discovery made.
203#[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        /// The location belongs to the profile fallback tier (Windows, when `HOME`
223        /// names a different directory than the user profile).
224        #[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        /// Lower-priority candidates that exist and lost to `path`.
240        shadows: Vec<String>,
241        /// `path` came from the profile fallback tier.
242        #[serde(skip_serializing_if = "std::ops::Not::not")]
243        profile_fallback: bool,
244    },
245}
246
247/// What the text rendering appends to a path that belongs to the profile fallback tier.
248const PROFILE_FALLBACK_LABEL: &str = "profile fallback: HOME is set to a different directory";
249
250impl TraceEvent {
251    /// One line for the debug log and the text rendering.
252    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/// The decisions discovery made, in the order it made them.
304#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
305pub struct DiscoveryTrace {
306    events: Vec<TraceEvent>,
307}
308
309impl DiscoveryTrace {
310    /// Append an event and write it to the debug log.
311    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    /// Withdraw a selection whose file turned out not to be loadable and record the
321    /// file as unusable instead, so the trace names only configuration that applies.
322    /// The reason is fixed text: a parser error can quote the file.
323    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    /// Whether the home directory decision is already on the record. A load
340    /// resolves the home once and the loaders it calls would otherwise repeat it.
341    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    /// Every file discovery selected, in the order it selected them.
352    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    /// The human-readable trace printed by `rumdl config file --explain`.
360    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    /// The machine-readable trace for `rumdl config --output json file --explain`.
419    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}