use std::collections::BTreeMap;
use serde::Serialize;
use crate::init::evidence::{Evidence, SCAN_CAP};
use crate::init::fit::Churn;
use crate::init::probe::Probes;
use crate::init::settings::{Confidence, SettingId, Settings, Value, ALL_SETTINGS};
#[derive(Debug, Serialize)]
pub struct Report {
pub written: bool,
pub config_path: String,
pub settings: BTreeMap<String, Value>,
pub confidence: BTreeMap<String, Confidence>,
pub evidence: EvidenceSummary,
pub normalize_churn: Churn,
pub default_churn: Churn,
pub warnings: Vec<String>,
pub notes: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct EvidenceSummary {
pub files: usize,
pub scanned_files: usize,
pub capped: bool,
pub library_path: String,
pub markdown_files: usize,
pub djot_files: usize,
pub wiki_links: usize,
pub markdown_links: usize,
pub unresolved_links: usize,
pub refs_with_extension: usize,
pub refs_bare: usize,
pub refs_relative_votes: usize,
pub refs_absolute_votes: usize,
pub frontmatter_keys: BTreeMap<String, usize>,
}
pub fn summarize(evidence: &Evidence) -> EvidenceSummary {
EvidenceSummary {
files: evidence.files,
scanned_files: evidence.scanned_files,
capped: evidence.capped,
library_path: evidence.library_path.clone(),
markdown_files: evidence.markdown_files,
djot_files: evidence.djot_files,
wiki_links: evidence.wiki_links,
markdown_links: evidence.markdown_links,
unresolved_links: evidence.unresolved_links,
refs_with_extension: evidence.refs_with_extension,
refs_bare: evidence.refs_bare,
refs_relative_votes: evidence.refs_relative_votes,
refs_absolute_votes: evidence.refs_absolute_votes,
frontmatter_keys: evidence.frontmatter_keys.entries().into_iter().collect(),
}
}
fn counted(count: usize, singular: &str, plural: &str) -> String {
if count == 1 {
format!("{} {}", count, singular)
} else {
format!("{} {}", count, plural)
}
}
pub fn summary_line(evidence: &Evidence) -> String {
let mut parts = Vec::new();
let location = if evidence.library_path.is_empty() {
"here".to_string()
} else {
format!("in {}/", evidence.library_path)
};
parts.push(format!(
"{} {}",
counted(evidence.scanned_files, "file", "files"),
location
));
if evidence.wiki_links > 0 {
parts.push(format!("{} wiki links", evidence.wiki_links));
}
if evidence.markdown_links > 0 {
parts.push(format!("{} markdown links", evidence.markdown_links));
}
if evidence.unresolved_links > 0 {
parts.push(format!("{} unresolved", evidence.unresolved_links));
}
parts.join(" · ")
}
pub fn warnings(evidence: &Evidence, settings: &Settings, probes: &Probes) -> Vec<String> {
let mut warnings = Vec::new();
for id in ALL_SETTINGS {
if settings.is_mixed(id) {
warnings.push(format!(
"{} is mixed across the corpus — {}",
id.label(),
settings.note(id)
));
}
}
if evidence.root_relative_links > 0 {
warnings.push(format!(
"{} from the library root but carry no leading slash — normalize rewrites to /path form",
counted(evidence.root_relative_links, "link resolves", "links resolve")
));
}
if evidence.crlf_files > 0 {
warnings.push(format!(
"{} CRLF line endings — normalize writes LF",
counted(evidence.crlf_files, "file uses", "files use")
));
}
if evidence.bom_files > 0 {
warnings.push(format!(
"{} with a byte order mark — normalize strips it",
counted(evidence.bom_files, "file starts", "files start")
));
}
if evidence.setext_headers > 0 {
warnings.push(format!(
"{} will be rewritten as ATX headers",
counted(evidence.setext_headers, "setext header", "setext headers")
));
}
if !evidence.case_collisions.is_empty() {
warnings.push(format!(
"keys differing only by case will collide on case-insensitive filesystems: {}",
evidence.case_collisions.join("; ")
));
}
if !evidence.unreadable_files.is_empty() {
warnings.push(format!(
"{} could not be read as UTF-8 — skipped",
counted(evidence.unreadable_files.len(), "file", "files")
));
}
if evidence.capped {
warnings.push(format!(
"corpus is larger than {} files — detection used the first {} found",
SCAN_CAP, SCAN_CAP
));
}
if let Some(parent) = &probes.nested_library {
warnings.push(format!("nested library — {} already contains .iwe", parent));
}
warnings
}
pub fn notes(evidence: &Evidence) -> Vec<String> {
let mut notes = Vec::new();
if evidence.unresolved_links > 0 {
let mut note = format!(
"{} to nothing",
counted(evidence.unresolved_links, "link resolves", "links resolve")
);
if !evidence.broken_link_samples.is_empty() {
note.push_str(&format!(" ({})", evidence.broken_link_samples.join(", ")));
}
notes.push(note);
}
if evidence.embeds > 0 {
notes.push(format!(
"{} (![[…]]) — read as links, not transclusions",
counted(evidence.embeds, "embed", "embeds")
));
}
if evidence.callouts > 0 {
notes.push(format!(
"{} (> [!note]) — kept as block quotes",
counted(evidence.callouts, "callout", "callouts")
));
}
if evidence.comment_lines > 0 {
notes.push(format!(
"{} with %%comments%% — kept as plain text",
counted(evidence.comment_lines, "line", "lines")
));
}
if evidence.inline_tags > 0 || evidence.frontmatter_tag_files > 0 {
notes.push(format!(
"tags: {} inline #hashtags, {} files with frontmatter tags",
evidence.inline_tags, evidence.frontmatter_tag_files
));
}
let frontmatter = evidence.frontmatter_keys.entries();
if !frontmatter.is_empty() && evidence.scanned_files > 0 {
let mut fields: Vec<(String, usize)> = frontmatter;
fields.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
fields.truncate(6);
let rendered: Vec<String> = fields
.iter()
.map(|(field, count)| format!("{} {}%", field, count * 100 / evidence.scanned_files))
.collect();
notes.push(format!("frontmatter fields: {}", rendered.join(", ")));
}
if evidence.keys_with_spaces > 0 {
notes.push(format!(
"{} spaces",
counted(
evidence.keys_with_spaces,
"filename contains",
"filenames contain"
)
));
}
if !evidence.duplicate_titles.is_empty() {
notes.push(format!(
"duplicate titles: {}",
evidence.duplicate_titles.join("; ")
));
}
notes
}
pub fn epilogue(probes: &Probes) -> Vec<String> {
let mut lines = vec!["try: iwe normalize --dry-run · iwe find --fuzzy <query>".to_string()];
for editor in &probes.editors {
lines.push(format!("editor: {}", editor.hint()));
}
if probes.git_repository {
if probes.git_clean {
lines.push(
"your notes are under version control — iwe normalize is safe to try".to_string(),
);
} else {
lines.push("working tree is dirty — commit before running iwe normalize".to_string());
}
}
lines
}
pub fn render_settings(settings: &Settings) -> String {
let mut output = String::new();
for id in ALL_SETTINGS {
if id == SettingId::Agents {
continue;
}
output.push_str(&format!(
"{:<52} {:<16} {}\n",
id.key(),
settings.get(id).to_string(),
settings.confidence(id)
));
}
output
}