use crate::candidate_object::CandidateMode;
use crate::candidate_record::CandidateChange;
use crate::candidate_version::{Candidate, CandidateVersion};
use crate::config_schema::{MarkdownAutofixOptions, PolicyConfig, markdown_rule_name};
use crate::diagnostics::EngineFailureCode;
use crate::event_path::EventPath;
use crate::git_metadata::path_from_git_bytes;
use crate::markdown_exclude::{ExcludeMatcher, compile_exclude};
use crate::markdown_linter_output::{LintRun, RemainingFinding};
use crate::policy_content::{ContentState, Correction, LifecycleContent};
use crate::policy_engine::{PolicyFinding, PolicyOutcome};
use crate::policy_trigger::Trigger;
use crate::repository_facts::RepositoryFacts;
use crate::repository_location::RepositoryLocation;
use crate::worktree_identity::worktree_root;
use std::path::{Path, PathBuf};
use std::rc::Rc;
pub const MARKDOWN_AUTOFIX_CODE: &str = "markdown-autofix";
pub const MARKDOWN_VIOLATION_CODE: &str = "markdown-violation";
pub const UNCHECKED_REMEDY: &str = "The Markdown candidate was not checked and nothing was changed; fix the cause, or pass --no-enforce-markdown/autofix to skip this policy for one command.";
#[derive(Clone, Copy, Debug)]
pub struct LintRequest<'a> {
pub top_level: &'a Path,
pub options: &'a MarkdownAutofixOptions,
pub path: &'a [u8],
pub source: &'a [u8],
}
pub trait MarkdownLinter {
fn lint(&mut self, request: &LintRequest<'_>) -> Result<LintRun, String>;
}
pub struct MarkdownState {
pub options: MarkdownAutofixOptions,
pub linter: Box<dyn MarkdownLinter>,
exclude: Option<Result<ExcludeMatcher, String>>,
}
pub fn markdown_state(linter: Box<dyn MarkdownLinter>) -> MarkdownState {
return MarkdownState {
options: PolicyConfig::defaults().markdown_autofix,
linter,
exclude: None,
};
}
impl MarkdownState {
fn excludes(&mut self, path: &[u8]) -> Result<bool, String> {
if self.exclude.is_none() {
self.exclude = Some(compile_exclude(self.options.exclude.as_slice()));
}
match &self.exclude {
Some(Ok(matcher)) => return Ok(matcher.excludes(path)),
Some(Err(reason)) => return Err(reason.clone()),
None => {
return Err(String::from(
"cli-git did not compile the markdown/autofix exclude patterns; this is a defect in cli-git.",
));
}
}
}
}
pub fn is_markdown_path(path: &[u8]) -> bool {
let Some(spelled) = path_from_git_bytes(path) else {
return false;
};
match spelled.extension().and_then(std::ffi::OsStr::to_str) {
Some("md") | Some("mdx") => return true,
_ => return false,
}
}
fn is_eligible(candidate: &Candidate) -> bool {
if candidate.change == CandidateChange::Deleted {
return false;
}
if candidate.mode != CandidateMode::Regular && candidate.mode != CandidateMode::Executable {
return false;
}
return is_markdown_path(candidate.path.as_slice());
}
fn unchecked(path: Option<&[u8]>, reason: &str) -> PolicyOutcome {
return PolicyOutcome::Failed {
code: EngineFailureCode::PolicyIncomplete,
message: format!("{reason}. {UNCHECKED_REMEDY}"),
path: path.map(EventPath::from_git_bytes),
};
}
fn top_level<F: RepositoryFacts>(facts: &mut F) -> Result<PathBuf, PolicyOutcome> {
let location: RepositoryLocation = match facts.location() {
Ok(found) => found,
Err(message) => {
return Err(PolicyOutcome::Failed {
code: EngineFailureCode::ContentUnavailable,
message,
path: None,
});
}
};
match worktree_root(&location.identity) {
Some(root) => return Ok(root.to_path_buf()),
None => {
return Err(PolicyOutcome::Failed {
code: EngineFailureCode::ContentUnavailable,
message: String::from(
"cli-git could not find the repository's top level, where monochromatic-lint must run.",
),
path: None,
});
}
}
}
fn rule_list(options: &MarkdownAutofixOptions) -> String {
let mut names: Vec<String> = Vec::new();
for rule in &options.rules {
names.push(format!("markdown/{}", markdown_rule_name(*rule)));
}
return names.join(", ");
}
fn violation(path: &EventPath, remaining: &RemainingFinding) -> PolicyFinding {
return PolicyFinding {
code: MARKDOWN_VIOLATION_CODE,
message: format!(
"{} at {}:{}:{}: {}",
markdown_rule_name(remaining.rule),
path.text(),
remaining.line,
remaining.column,
remaining.message
),
path: Some(path.clone()),
location: None,
fix_available: false,
};
}
fn candidate_findings(
content: &mut ContentState,
candidate: &Candidate,
bytes: Rc<[u8]>,
run: LintRun,
options: &MarkdownAutofixOptions,
trigger: Trigger,
) -> Vec<PolicyFinding> {
let path: EventPath = EventPath::from_git_bytes(candidate.path.as_slice());
let mut findings: Vec<PolicyFinding> = Vec::new();
if run.fixed.as_slice() != &*bytes {
let corrects: bool = trigger == Trigger::DirectFix;
findings.push(PolicyFinding {
code: MARKDOWN_AUTOFIX_CODE,
message: format!(
"monochromatic-lint --fix ({}) rewrites {}.",
rule_list(options),
path.text()
),
path: Some(path.clone()),
location: None,
fix_available: corrects,
});
if corrects {
content.propose(Correction {
path: candidate.path.clone(),
mode: candidate.mode,
before: bytes,
after: Rc::from(run.fixed),
});
}
}
for remaining in &run.remaining {
findings.push(violation(&path, remaining));
}
return findings;
}
pub fn check_markdown<F: RepositoryFacts>(
content: &mut ContentState,
lifecycle: &LifecycleContent,
facts: &mut F,
state: &mut MarkdownState,
trigger: Trigger,
) -> PolicyOutcome {
let version: Rc<CandidateVersion> = match content.version(lifecycle, facts) {
Ok(Some(found)) => found,
Ok(None) => return PolicyOutcome::Findings(Vec::new()),
Err(outcome) => return outcome,
};
let mut findings: Vec<PolicyFinding> = Vec::new();
if state.options.rules.is_empty() {
return PolicyOutcome::Findings(findings);
}
for candidate in version.candidates() {
if !is_eligible(candidate) {
continue;
}
match state.excludes(candidate.path.as_slice()) {
Ok(true) => continue,
Ok(false) => {}
Err(reason) => return unchecked(None, reason.as_str()),
}
let bytes: Rc<[u8]> = match content.bytes(candidate) {
Ok(read) => read,
Err(outcome) => return outcome,
};
if std::str::from_utf8(&bytes).is_err() {
continue;
}
let top: PathBuf = match top_level(facts) {
Ok(found) => found,
Err(outcome) => return outcome,
};
let request: LintRequest<'_> = LintRequest {
top_level: top.as_path(),
options: &state.options,
path: candidate.path.as_slice(),
source: &bytes,
};
let run: LintRun = match state.linter.lint(&request) {
Ok(usable) => usable,
Err(reason) => return unchecked(Some(candidate.path.as_slice()), reason.as_str()),
};
findings.extend(candidate_findings(
content,
candidate,
bytes,
run,
&state.options,
trigger,
));
}
return PolicyOutcome::Findings(findings);
}
#[cfg(test)]
#[path = "policy_markdown_tests.rs"]
mod tests;