mod document;
mod format;
mod parser;
mod path_util;
mod scoring;
mod validator;
use crate::{document::DOCUMENT_EXTENSION, format::CodeStr, path_util::relative_path};
use clap::{ArgAction, Parser, Subcommand as ClapSubcommand};
use colored::Colorize;
use similar::TextDiff;
use std::{env, fs, path::PathBuf, process::exit};
#[derive(Parser)]
#[command(
about = concat!(
env!("CARGO_PKG_DESCRIPTION"),
"\n\n",
"More information can be found at: ",
env!("CARGO_PKG_HOMEPAGE"),
),
version,
disable_version_flag = true
)]
struct Cli {
#[arg(short, long, help = "Print version", action = ArgAction::Version)]
_version: Option<bool>,
#[arg(long, value_name = "PATH", help = "Specify the path to the document")]
path: Option<PathBuf>,
#[command(subcommand)]
command: Option<Subcommand>,
}
#[derive(ClapSubcommand)]
enum Subcommand {
#[command(about = "Check a document")]
Check,
#[command(about = "Fix a document (default)")]
Fix,
}
fn find_document() -> Result<PathBuf, String> {
let current_directory = env::current_dir()
.map_err(|error| format!("Failed to determine the current directory: {error}"))?;
for directory in current_directory.ancestors() {
let entries = fs::read_dir(directory).map_err(|error| {
format!(
"Failed to read {}: {error}",
directory.to_string_lossy().code_str(),
)
})?;
let mut documents = Vec::<PathBuf>::new();
for entry in entries {
let entry = entry.map_err(|error| {
format!(
"Failed to read an entry in {}: {error}",
directory.to_string_lossy().code_str(),
)
})?;
let path = entry.path();
let has_document_extension = path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case(DOCUMENT_EXTENSION));
if has_document_extension {
let metadata = fs::metadata(&path).map_err(|error| {
format!(
"Failed to inspect {}: {error}",
path.to_string_lossy().code_str(),
)
})?;
if metadata.is_file() {
documents.push(path);
}
}
}
documents.sort();
if documents.len() > 1 {
let file_names = documents
.iter()
.filter_map(|path| path.file_name())
.map(|file_name| file_name.to_string_lossy().code_str().to_string())
.collect::<Vec<String>>()
.join(", ");
return Err(format!(
"Found multiple documents in {}: {file_names}",
directory.to_string_lossy().code_str(),
));
}
if let Some(document) = documents.into_iter().next() {
return Ok(document);
}
}
Err(format!(
"No document found in {} or its ancestors.",
current_directory.to_string_lossy().code_str(),
))
}
fn entry() -> Result<(), String> {
let cli = Cli::parse();
let document_path = cli.path.map_or_else(find_document, Ok)?;
let current_directory = env::current_dir()
.map_err(|error| format!("Failed to determine the current directory: {error}"))?;
let display_path = relative_path(¤t_directory, &document_path).to_owned();
let document_bytes = fs::read(&document_path).map_err(|error| {
format!(
"Failed to read {}: {error}",
display_path.to_string_lossy().code_str(),
)
})?;
let document_contents = String::from_utf8(document_bytes).map_err(|error| {
format!(
"Document {} is not valid UTF-8: {error}",
display_path.to_string_lossy().code_str(),
)
})?;
let document = parser::parse(&document_contents).map_err(|error| {
format!(
"Failed to parse {}: {error}",
display_path.to_string_lossy().code_str(),
)
})?;
validator::validate(&document, &document_path).map_err(|error| {
format!(
"Failed to validate {}:\n{error}",
display_path.to_string_lossy().code_str(),
)
})?;
let rendered_document = document.to_string();
match cli.command.unwrap_or(Subcommand::Fix) {
Subcommand::Check => {
if document_contents != rendered_document {
let diff = TextDiff::from_lines(&document_contents, &rendered_document)
.unified_diff()
.header("document", "rendered")
.to_string();
return Err(format!(
"Document {} is not formatted correctly:\n\n{diff}\n{} can fix it.",
display_path.to_string_lossy().code_str(),
"mull fix".code_str(),
));
}
println!(
"Document {} looks good.",
display_path.to_string_lossy().code_str(),
);
}
Subcommand::Fix => {
if document_contents == rendered_document {
println!(
"Document {} looks good.",
display_path.to_string_lossy().code_str(),
);
} else {
fs::write(&document_path, rendered_document).map_err(|error| {
format!(
"Failed to write {}: {error}",
display_path.to_string_lossy().code_str(),
)
})?;
println!("Fixed {}.", display_path.to_string_lossy().code_str());
}
}
}
Ok(())
}
fn main() {
if let Err(e) = entry() {
eprintln!("{} {}", "[Error]".red().bold(), e);
exit(1);
}
}
#[cfg(test)]
mod tests {
use super::{Cli, Subcommand};
use clap::{CommandFactory, Parser};
use std::path::PathBuf;
#[test]
fn verify_cli() {
Cli::command().debug_assert();
}
#[test]
fn parse_subcommands() {
assert!(matches!(
Cli::try_parse_from(["mull", "check"]).unwrap().command,
Some(Subcommand::Check),
));
assert!(matches!(
Cli::try_parse_from(["mull", "fix"]).unwrap().command,
Some(Subcommand::Fix),
));
}
#[test]
fn parse_path() {
let cli = Cli::try_parse_from(["mull", "--path", "notes.mull", "check"]).unwrap();
assert_eq!(cli.path, Some(PathBuf::from("notes.mull")));
}
}