use serde::Serialize;
use crate::utils::upward_walk::WalkStop;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SearchStage {
Explicit,
Project,
Markdownlint,
User,
}
impl SearchStage {
fn heading(self) -> &'static str {
match self {
Self::Explicit => "Explicit config",
Self::Project => "Project config search",
Self::Markdownlint => "Markdownlint config search",
Self::User => "User-level config search",
}
}
fn label(self) -> &'static str {
match self {
Self::Explicit => "explicit config",
Self::Project => "project config",
Self::Markdownlint => "markdownlint config",
Self::User => "user config",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ProbeOutcome {
Found,
Missing,
IneligiblePyproject,
AccessError {
error: String,
},
}
impl ProbeOutcome {
fn text(&self) -> String {
match self {
Self::Found => "found".to_string(),
Self::Missing => "missing".to_string(),
Self::IneligiblePyproject => "ignored (pyproject.toml without a rumdl section)".to_string(),
Self::AccessError { error } => format!("not usable ({error})"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum HomeSource {
HomeEnv,
UserProfile,
PlatformDefault,
Caller,
}
impl HomeSource {
fn text(self) -> &'static str {
match self {
Self::HomeEnv => "HOME",
Self::UserProfile => "USERPROFILE or the user profile API",
Self::PlatformDefault => "HOME or the user database",
Self::Caller => "supplied by the caller",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IgnoredHomeReason {
Empty,
NotUnicode,
MsysStyle,
NotFullyQualified,
}
impl IgnoredHomeReason {
fn text(self) -> &'static str {
match self {
Self::Empty => "it is empty",
Self::NotUnicode => "it is not valid unicode",
Self::MsysStyle => "it is an MSYS-style path (/c/Users/...), not a native Windows path",
Self::NotFullyQualified => "it is not a fully qualified Windows path",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "reason", rename_all = "snake_case")]
pub enum WalkEnd {
HomeBoundary {
path: String,
},
ProfileBoundary {
path: String,
},
GitRoot {
path: String,
},
StopRoot {
path: String,
},
DepthCap,
FilesystemRoot,
}
impl WalkEnd {
fn text(&self) -> String {
match self {
Self::HomeBoundary { path } => {
format!("reached the home directory boundary ({path}), which is not searched")
}
Self::ProfileBoundary { path } => {
format!("reached the profile directory (fallback home) boundary ({path}), which is not searched")
}
Self::GitRoot { path } => format!("reached the git repository root ({path})"),
Self::StopRoot { path } => format!("reached the search root ({path})"),
Self::DepthCap => "reached the maximum search depth".to_string(),
Self::FilesystemRoot => "reached the filesystem root".to_string(),
}
}
}
impl From<&WalkStop> for WalkEnd {
fn from(stop: &WalkStop) -> Self {
let path = |p: &std::path::Path| p.to_string_lossy().into_owned();
match stop {
WalkStop::HomeBoundary(p) => Self::HomeBoundary { path: path(p) },
WalkStop::ProfileBoundary(p) => Self::ProfileBoundary { path: path(p) },
WalkStop::GitRoot(p) => Self::GitRoot { path: path(p) },
WalkStop::StopRoot(p) => Self::StopRoot { path: path(p) },
WalkStop::DepthCap => Self::DepthCap,
WalkStop::FilesystemRoot => Self::FilesystemRoot,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SkipReason {
ProjectConfigFound,
ExplicitConfig,
Isolated,
}
impl SkipReason {
fn text(self) -> &'static str {
match self {
Self::ProjectConfigFound => "a project config was found",
Self::ExplicitConfig => "--config was given",
Self::Isolated => "--no-config/--isolated was given",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum TraceEvent {
StartDirectory {
path: String,
},
Home {
path: Option<String>,
source: Option<HomeSource>,
},
IgnoredHome {
value: String,
reason: IgnoredHomeReason,
},
Probe {
stage: SearchStage,
path: String,
#[serde(flatten)]
outcome: ProbeOutcome,
#[serde(skip_serializing_if = "std::ops::Not::not")]
profile_fallback: bool,
},
WalkEnded {
stage: SearchStage,
#[serde(flatten)]
end: WalkEnd,
},
Skipped {
stage: SearchStage,
reason: SkipReason,
},
Selected {
stage: SearchStage,
path: String,
shadows: Vec<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
profile_fallback: bool,
},
}
const PROFILE_FALLBACK_LABEL: &str = "profile fallback: HOME is set to a different directory";
impl TraceEvent {
pub fn describe(&self) -> String {
match self {
Self::StartDirectory { path } => format!("start directory: {path}"),
Self::Home {
path: Some(path),
source: Some(source),
} => format!("home directory: {path} (from {})", source.text()),
Self::Home { .. } => "home directory: not resolved".to_string(),
Self::IgnoredHome { value, reason } => format!("ignored HOME={value:?}: {}", reason.text()),
Self::Probe {
path,
outcome,
profile_fallback,
..
} => {
let label = if *profile_fallback {
format!(" [{PROFILE_FALLBACK_LABEL}]")
} else {
String::new()
};
format!("{path}: {}{label}", outcome.text())
}
Self::WalkEnded { end, .. } => format!("search ended: {}", end.text()),
Self::Skipped { stage, reason } => {
let what = match stage {
SearchStage::User => "user-level search",
SearchStage::Markdownlint => "markdownlint search",
SearchStage::Project => "project search",
SearchStage::Explicit => "explicit config",
};
format!("{what} skipped: {}", reason.text())
}
Self::Selected {
stage,
path,
shadows,
profile_fallback,
} => {
let mut line = format!("selected {}: {path}", stage.label());
if *profile_fallback {
line.push_str(&format!(" [{PROFILE_FALLBACK_LABEL}]"));
}
if !shadows.is_empty() {
line.push_str(&format!(" (shadows: {})", shadows.join(", ")));
}
line
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct DiscoveryTrace {
events: Vec<TraceEvent>,
}
impl DiscoveryTrace {
pub fn record(&mut self, event: TraceEvent) {
log::debug!("[rumdl-config] discovery: {}", event.describe());
self.events.push(event);
}
pub fn events(&self) -> &[TraceEvent] {
&self.events
}
pub fn reject_selected(&mut self, stage: SearchStage, path: &str, reason: &str) {
let profile_fallback = self.events.iter().any(|e| {
matches!(e, TraceEvent::Selected { stage: s, path: p, profile_fallback: true, .. } if *s == stage && p == path)
});
self.events
.retain(|e| !matches!(e, TraceEvent::Selected { stage: s, path: p, .. } if *s == stage && p == path));
self.record(TraceEvent::Probe {
stage,
path: path.to_string(),
outcome: ProbeOutcome::AccessError {
error: reason.to_string(),
},
profile_fallback,
});
}
pub fn has_home(&self) -> bool {
self.events.iter().any(|e| matches!(e, TraceEvent::Home { .. }))
}
pub fn has_start_directory(&self) -> bool {
self.events
.iter()
.any(|e| matches!(e, TraceEvent::StartDirectory { .. }))
}
pub fn selected(&self) -> impl Iterator<Item = (&SearchStage, &str)> {
self.events.iter().filter_map(|e| match e {
TraceEvent::Selected { stage, path, .. } => Some((stage, path.as_str())),
_ => None,
})
}
pub fn render_text(&self) -> String {
let mut out = String::new();
let mut stage: Option<SearchStage> = None;
let mut markdownlint_selected = false;
let mut any_selected = false;
for event in &self.events {
let event_stage = match event {
TraceEvent::Probe { stage, .. } | TraceEvent::WalkEnded { stage, .. } => Some(*stage),
_ => None,
};
if let Some(s) = event_stage
&& stage != Some(s)
{
out.push_str(&format!("{}:\n", s.heading()));
stage = Some(s);
}
match event {
TraceEvent::Probe { .. } | TraceEvent::WalkEnded { .. } => {
out.push_str(&format!(" {}\n", event.describe()));
}
TraceEvent::Selected {
stage: s,
path,
shadows,
profile_fallback,
} => {
stage = None;
any_selected = true;
let note = if *s == SearchStage::User && markdownlint_selected {
" (applied as the base under the markdownlint config)"
} else {
""
};
if *s == SearchStage::Markdownlint {
markdownlint_selected = true;
}
let label = if *profile_fallback {
format!(" [{PROFILE_FALLBACK_LABEL}]")
} else {
String::new()
};
out.push_str(&format!("selected {}: {path}{label}{note}\n", s.label()));
for shadow in shadows {
out.push_str(&format!(" shadows (lower priority): {shadow}\n"));
}
}
_ => {
stage = None;
out.push_str(&format!("{}\n", event.describe()));
}
}
}
if !any_selected {
out.push_str("selected: none (using defaults)\n");
}
out
}
pub fn to_json(&self) -> String {
#[derive(Serialize)]
struct Selected<'a> {
stage: SearchStage,
path: &'a str,
}
#[derive(Serialize)]
struct Document<'a> {
events: &'a [TraceEvent],
selected: Vec<Selected<'a>>,
}
let selected = self
.selected()
.map(|(stage, path)| Selected { stage: *stage, path })
.collect();
serde_json::to_string_pretty(&Document {
events: &self.events,
selected,
})
.unwrap_or_else(|_| "{}".to_string())
}
}