use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
pub struct M3lFile {
pub path: String,
pub content: String,
}
#[derive(Debug, Deserialize)]
pub struct M3lConfig {
pub name: Option<String>,
pub version: Option<String>,
pub sources: Option<Vec<String>>,
#[serde(flatten)]
unknown: std::collections::BTreeMap<String, yaml_serde::Value>,
}
const CONFIG_FILE: &str = "m3l.config.yaml";
const CONFIG_KEYS: [&str; 3] = ["name", "version", "sources"];
pub struct ProjectInput {
pub files: Vec<M3lFile>,
pub config: Option<M3lConfig>,
pub warnings: Vec<String>,
}
fn source_path(path: &Path) -> String {
let text = path.to_string_lossy();
if std::path::MAIN_SEPARATOR == '\\' {
text.replace('\\', "/")
} else {
text.into_owned()
}
}
pub fn read_project(input_path: &Path) -> Result<ProjectInput, String> {
if !input_path.exists() {
return Err(format!("Path does not exist: {}", input_path.display()));
}
if input_path.is_file() {
let content = read_m3l_text(input_path, Selected::Named)?;
return Ok(ProjectInput {
files: vec![M3lFile {
path: source_path(input_path),
content,
}],
config: None,
warnings: Vec::new(),
});
}
if input_path.is_dir() {
let config_path = input_path.join(CONFIG_FILE);
if config_path.exists() {
let config = read_config(&config_path)?;
let warnings = unknown_key_warnings(&config_path, &config);
let files = read_from_config(&config, input_path)?;
return Ok(ProjectInput {
files,
config: Some(config),
warnings,
});
}
return Ok(ProjectInput {
files: scan_directory(input_path)?,
config: None,
warnings: Vec::new(),
});
}
Err(format!(
"Path is neither a file nor a directory: {}",
input_path.display()
))
}
fn read_config(config_path: &Path) -> Result<M3lConfig, String> {
let yaml = fs::read_to_string(config_path)
.map_err(|e| format!("Failed to read {}: {}", config_path.display(), e))?;
yaml_serde::from_str(&yaml).map_err(|e| {
format!(
"Invalid project configuration {}: {}",
config_path.display(),
e
)
})
}
fn unknown_key_warnings(config_path: &Path, config: &M3lConfig) -> Vec<String> {
config
.unknown
.keys()
.map(|key| {
let hint = CONFIG_KEYS
.iter()
.find(|known| is_near(key, known))
.map(|known| format!(" (did you mean '{known}'?)"))
.unwrap_or_default();
format!(
"{}: unknown key '{key}' is ignored{hint}; known keys are {}",
config_path.display(),
CONFIG_KEYS.join(", ")
)
})
.collect()
}
fn is_near(key: &str, known: &str) -> bool {
let (a, b) = (key.to_ascii_lowercase(), known.to_ascii_lowercase());
if a == b || a.starts_with(&b) || b.starts_with(&a) {
return true;
}
let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
let mut row: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.iter().enumerate() {
let mut prev = row[0];
row[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cur = row[j + 1];
row[j + 1] = if ca == cb {
prev
} else {
1 + prev.min(row[j]).min(row[j + 1])
};
prev = cur;
}
}
row[b.len()] <= 1
}
#[derive(Clone, Copy)]
enum Selected {
Named,
ByScan,
}
fn read_m3l_text(path: &Path, selected: Selected) -> Result<String, String> {
fs::read_to_string(path).map_err(|e| match e.kind() {
std::io::ErrorKind::InvalidData => match selected {
Selected::Named => format!(
"{} is not UTF-8 text — M3L files are read as UTF-8; save it as UTF-8",
path.display()
),
Selected::ByScan => format!(
"{} is not UTF-8 text — M3L files are read as UTF-8. The directory scan reads every .md \
file; if this one is not part of the model, list the model files under `sources` in {}",
path.display(),
CONFIG_FILE
),
},
std::io::ErrorKind::PermissionDenied => {
format!("Failed to read {}: permission denied", path.display())
}
_ => format!("Failed to read {}: {}", path.display(), e),
})
}
fn scan_directory(dir_path: &Path) -> Result<Vec<M3lFile>, String> {
let patterns = [
dir_path.join("**/*.m3l.md"),
dir_path.join("**/*.m3l"),
dir_path.join("**/*.md"),
];
let mut paths: Vec<PathBuf> = Vec::new();
for pattern in &patterns {
let pattern_str = pattern.to_string_lossy().replace('\\', "/");
let entries =
glob::glob(&pattern_str).map_err(|e| format!("Invalid glob pattern: {}", e))?;
for entry in entries {
match entry {
Ok(path) => {
if !paths.contains(&path) {
paths.push(path);
}
}
Err(e) => {
return Err(format!("Glob error: {}", e));
}
}
}
}
paths.sort();
let mut files = Vec::new();
for path in paths {
let content = read_m3l_text(&path, Selected::ByScan)?;
files.push(M3lFile {
path: source_path(&path),
content,
});
}
Ok(files)
}
fn read_from_config(config: &M3lConfig, base_dir: &Path) -> Result<Vec<M3lFile>, String> {
let source_patterns = match config.sources {
Some(ref s) if !s.is_empty() => s.clone(),
_ => return scan_directory(base_dir),
};
let mut files: Vec<M3lFile> = Vec::new();
let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
for pattern in &source_patterns {
let full_pattern = base_dir.join(pattern);
let pattern_str = full_pattern.to_string_lossy().replace('\\', "/");
let entries = glob::glob(&pattern_str)
.map_err(|e| format!("Invalid glob pattern '{}': {}", pattern, e))?;
let mut matched: Vec<PathBuf> = Vec::new();
for entry in entries {
match entry {
Ok(path) => {
if !seen.contains(&path) {
seen.insert(path.clone());
matched.push(path);
}
}
Err(e) => return Err(format!("Glob error: {}", e)),
}
}
matched.sort();
for path in matched {
let content = read_m3l_text(&path, Selected::Named)?;
files.push(M3lFile {
path: source_path(&path),
content,
});
}
}
Ok(files)
}