use indicatif::{ProgressBar, ProgressStyle};
use owo_colors::OwoColorize;
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum DurationField {
Number(f64),
Unknown(String),
}
impl DurationField {
fn validate(&self) -> Result<(), String> {
match self {
DurationField::Number(_) => Ok(()),
DurationField::Unknown(s) if s == "unknown" => Ok(()),
DurationField::Unknown(s) => {
Err(format!("duration must be a number or 'unknown', got '{s}'"))
}
}
}
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
enum ArtPurpose {
Inspiration,
CoverArt,
Other,
}
#[derive(Debug, Deserialize, Serialize)]
struct ArtReference {
path: String,
#[serde(default)]
description: String,
purpose: ArtPurpose,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct SidecarMetadata {
file: String,
path: String,
#[serde(default)]
title: String,
#[serde(default)]
description: String,
#[serde(skip_serializing_if = "Option::is_none")]
duration: Option<DurationField>,
#[serde(skip_serializing_if = "Option::is_none")]
sample_rate: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
channels: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
bit_depth: Option<u16>,
file_size: u64,
modified: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
art: Vec<ArtReference>,
}
pub fn handle_lint(project_path: &str) -> Result<(), Box<dyn Error>> {
let project_path = Path::new(project_path);
if !project_path.exists() {
return Err(format!(
"{} Path does not exist: {}",
"Error:".red().bold(),
project_path.display()
)
.into());
}
println!(
"{} {}",
"Linting project:".bright_black(),
project_path.display().to_string().cyan()
);
println!();
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.unwrap()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
);
spinner.set_message("Scanning for sidecar files...");
let mut total_files = 0;
let mut valid_files = 0;
let mut invalid_files = 0;
let mut errors = Vec::new();
scan_directory(
project_path,
&mut total_files,
&mut valid_files,
&mut invalid_files,
&mut errors,
)?;
spinner.finish_and_clear();
if !errors.is_empty() {
println!(
"{} Found {} YAML errors:\n",
"❌".red(),
errors.len().to_string().red().bold()
);
for (path, error) in errors {
let relative_path = path.strip_prefix(project_path).unwrap_or(&path);
println!(" {}", relative_path.display().to_string().yellow());
println!(" {} {}\n", "Error:".red(), error.bright_black());
}
}
println!("{}", "Summary:".yellow().bold());
println!(
" {} {}",
"Total sidecar files:".bright_black(),
total_files.to_string().cyan()
);
println!(
" {} {}: {}",
"✓".green(),
"Valid YAML".bright_black(),
valid_files.to_string().green().bold()
);
if invalid_files > 0 {
println!(
" {} {}: {}",
"✗".red(),
"Invalid YAML".bright_black(),
invalid_files.to_string().red().bold()
);
}
if invalid_files > 0 {
Err(format!(
"{} Lint check failed: invalid YAML found",
"Error:".red().bold()
)
.into())
} else {
println!(
"\n{} {}",
"✓".green().bold(),
"All YAML frontmatter is valid!".green()
);
Ok(())
}
}
fn scan_directory(
dir: &Path,
total: &mut u32,
valid: &mut u32,
invalid: &mut u32,
errors: &mut Vec<(PathBuf, String)>,
) -> Result<(), Box<dyn Error>> {
let entries = fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if let Some(name) = path.file_name() {
if name.to_string_lossy().starts_with('.') {
continue;
}
}
if path.is_dir() {
let dir_name = path.file_name().unwrap().to_string_lossy();
if dir_name == "node_modules" || dir_name == ".git" || dir_name == "temp" {
continue;
}
scan_directory(&path, total, valid, invalid, errors)?;
} else if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("md") {
if is_sidecar_file(&path) {
*total += 1;
match validate_yaml_frontmatter(&path) {
Ok(()) => {
*valid += 1;
}
Err(e) => {
*invalid += 1;
errors.push((path, e.to_string()));
}
}
}
}
}
Ok(())
}
fn is_sidecar_file(md_path: &Path) -> bool {
let file_name = md_path.file_name().unwrap().to_string_lossy();
if let Some(base_name) = file_name.strip_suffix(".md") {
Path::new(base_name).extension().is_some()
} else {
false
}
}
fn validate_yaml_frontmatter(path: &Path) -> Result<(), Box<dyn Error>> {
let content = fs::read_to_string(path)?;
if !content.starts_with("---\n") {
return Err("Missing YAML frontmatter (file should start with ---)".into());
}
let parts: Vec<&str> = content.splitn(3, "---\n").collect();
if parts.len() < 3 {
return Err("Invalid frontmatter format (missing closing ---)".into());
}
let yaml_content = parts[1];
let metadata: SidecarMetadata = serde_yaml::from_str(yaml_content).map_err(|e| {
let error_msg = e.to_string();
if error_msg.contains("missing field") {
let field = error_msg.split('`').nth(1).unwrap_or("unknown");
format!("Missing required field: '{field}'")
} else if error_msg.contains("invalid type") {
if let Some(field_info) = error_msg.split("for key `").nth(1) {
let field = field_info.split('`').next().unwrap_or("unknown");
format!("Invalid type for field '{field}' - {error_msg}")
} else {
format!("Type error: {error_msg}")
}
} else if error_msg.contains("unknown field") {
let field = error_msg.split('`').nth(1).unwrap_or("unknown");
format!("Unknown field: '{field}' - check for typos in field names")
} else if error_msg.contains("data did not match any variant") {
"Invalid value format - check that all values have the correct type".to_string()
} else {
format!("Schema validation error: {error_msg}")
}
})?;
if let Some(duration) = &metadata.duration {
duration
.validate()
.map_err(|e| format!("Invalid duration value: {e}"))?;
}
Ok(())
}