use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("Error: {error}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<(), String> {
print!("Enter folder path (or exit): ");
io::stdout().flush().map_err(|e| e.to_string())?;
let mut input = String::new();
io::stdin().read_line(&mut input).map_err(|e| e.to_string())?;
let input = input.trim();
if input.is_empty() || input.eq_ignore_ascii_case("exit") {
return Ok(());
}
let folder = Path::new(input.trim_matches('"'));
let mut files: Vec<PathBuf> = Vec::new();
let entries = fs::read_dir(folder)
.map_err(|e| format!("Cannot open folder '{}': {e}", folder.display()))?;
// Collect regular XML files only. Ignore subdirectories and symbolic links.
for entry in entries {
let entry = entry.map_err(|e| format!("Cannot read folder entry: {e}"))?;
let path = entry.path();
let kind = entry.file_type()
.map_err(|e| format!("Cannot inspect '{}': {e}", path.display()))?;
let is_xml = path.extension().and_then(|s| s.to_str())
.is_some_and(|s| s.eq_ignore_ascii_case("xml"));
if kind.is_file() && is_xml {
files.push(path);
}
}
// Sort alphabetically, ignoring case. Exact spelling breaks ties.
files.sort_by_cached_key(|path| {
(path.file_name().unwrap_or_default().to_string_lossy().to_lowercase(), path.clone())
});
if files.is_empty() {
return Err("No XML files found in the selected folder.".into());
}
for path in &files {
let text = fs::read_to_string(path)
.map_err(|e| format!("Cannot read '{}': {e}", path.display()))?;
let filename = path.file_name().unwrap_or_default().to_string_lossy();
print_anchors(&text, &filename);
}
println!("\nDone. {} XML file(s) analyzed. No files changed.", files.len());
Ok(())
}
/// Inspect literal anchor="..." attributes using text searches only.
/// Associate each anchor with the number immediately after its opening tag.
/// This expects the sample format and does not validate or parse XML.
fn print_anchors(text: &str, filename: &str) {
const MARKER: &str = "anchor=\"";
let mut remaining = text;
let mut found = false;
while let Some(position) = remaining.find(MARKER) {
// Require an attribute boundary to avoid matching names such as data-anchor.
if position > 0 && !remaining.as_bytes()[position - 1].is_ascii_whitespace() {
remaining = &remaining[position + MARKER.len()..];
continue;
}
found = true;
let value = &remaining[position + MARKER.len()..];
let Some(quote) = value.find('"') else {
println!("{filename}: [missing closing quote] -> [not found]");
return;
};
let anchor = &value[..quote];
let after_anchor = &value[quote + 1..];
// Stay inside the current opening tag when looking for its end.
let tag_end = after_anchor.find(['>', '<']);
let number = tag_end.and_then(|end| {
if after_anchor.as_bytes()[end] != b'>' {
return None;
}
let content = &after_anchor[end + 1..];
let digit_count = content.bytes().take_while(|b| b.is_ascii_digit()).count();
if digit_count > 0 && content.as_bytes().get(digit_count) == Some(&b'<') {
Some(&content[..digit_count])
} else {
None
}
});
println!("{filename}: {anchor} -> {}", number.unwrap_or("[not found]"));
remaining = after_anchor;
}
if !found {
println!("{filename}: [no anchor found] -> [not found]");
}
}