use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
pub(crate) mod commands;
pub(crate) mod parse;
#[cfg(test)]
mod tests;
pub(crate) mod validation;
pub(crate) const COMPANION_FILES: &[&str] =
&["requirements.md", "tasks.md", "context.md", "testing.md"];
pub(crate) const SPEC_CHECK_SCHEMA: u32 = 1;
pub(crate) const SPEC_LIST_SCHEMA: u32 = 1;
pub(crate) const SPEC_SHOW_SCHEMA: u32 = 1;
#[derive(Debug, Deserialize)]
pub(crate) struct SpecSyncConfig {
pub(crate) specs_dir: Option<String>,
#[serde(default)]
pub(crate) required_sections: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct SpecFrontmatter {
pub module: String,
pub version: u32,
pub status: String,
#[serde(default)]
pub files: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct IndexEntry {
pub name: String,
pub version: u32,
pub status: String,
pub purpose: Option<String>,
pub files: Vec<String>,
pub path: PathBuf,
}
#[derive(Debug)]
pub enum SpecAction {
Check { strict: bool, json: bool },
Init,
New { name: String },
List { json: bool },
Show { name: String, json: bool },
}
pub(crate) fn load_config(project_root: &Path) -> Result<SpecSyncConfig> {
let config_path = project_root.join(".specsync/config.toml");
if !config_path.exists() {
bail!(
"No .specsync/config.toml found. Run {} to initialize.",
console::style("fledge spec init").cyan()
);
}
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read {}", config_path.display()))?;
let config: SpecSyncConfig =
toml::from_str(&content).with_context(|| "Failed to parse .specsync/config.toml")?;
Ok(config)
}
pub(crate) fn find_project_root() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
pub(crate) fn specs_dir_from_config(root: &Path) -> Result<PathBuf> {
let config = load_config(root)?;
Ok(root.join(config.specs_dir.as_deref().unwrap_or("specs")))
}
pub(crate) fn find_spec_files(specs_dir: &Path) -> Vec<PathBuf> {
let mut spec_paths = Vec::new();
for entry in WalkDir::new(specs_dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "md") {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name.ends_with(".spec.md") {
spec_paths.push(path.to_path_buf());
}
}
}
spec_paths
}
pub(crate) fn classify_companions(spec_dir: &Path) -> (Vec<String>, Vec<String>) {
let mut present = Vec::new();
let mut missing = Vec::new();
for companion in COMPANION_FILES {
if spec_dir.join(companion).exists() {
present.push((*companion).to_string());
} else {
missing.push((*companion).to_string());
}
}
(present, missing)
}
pub(crate) fn validate_module_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("Module name cannot be empty");
}
if name == "." || name == ".." {
bail!("Invalid module name '{name}'");
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
bail!("Invalid module name '{name}': may not contain path separators or '..'");
}
Ok(())
}
pub(crate) fn to_title_case(s: &str) -> String {
s.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn run(action: SpecAction) -> Result<()> {
let root = find_project_root();
match action {
SpecAction::Check { strict, json } => commands::check(&root, strict, json),
SpecAction::Init => commands::init(&root),
SpecAction::New { name } => commands::new_spec(&root, &name),
SpecAction::List { json } => commands::list_specs(&root, json),
SpecAction::Show { name, json } => commands::show_spec(&root, &name, json),
}
}
pub fn collect_index(root: &Path) -> Result<Vec<IndexEntry>> {
let specs_dir = specs_dir_from_config(root)?;
if !specs_dir.exists() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
for path in find_spec_files(&specs_dir) {
let Ok(content) = fs::read_to_string(&path) else {
continue;
};
let Ok((fm, body)) = parse::parse_frontmatter(&content) else {
continue;
};
entries.push(IndexEntry {
name: fm.module,
version: fm.version,
status: fm.status,
purpose: parse::extract_purpose(&body),
files: fm.files,
path,
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}
pub fn render_index_markdown(entries: &[IndexEntry]) -> String {
let mut out = String::from("## Available specs\n\n");
out.push_str(
"This project documents each module in `specs/<name>/`. \
Run `fledge spec show <name>` for the full detail.\n\n",
);
for entry in entries {
let purpose = entry
.purpose
.as_deref()
.unwrap_or("(no purpose documented)");
let files = if entry.files.is_empty() {
String::new()
} else {
format!(" — {}", entry.files.join(", "))
};
out.push_str(&format!(
"- **{}** v{} ({}){} — {}\n",
entry.name, entry.version, entry.status, files, purpose,
));
}
out
}
pub fn all_module_names(root: &Path) -> Result<Vec<String>> {
Ok(collect_index(root)?.into_iter().map(|e| e.name).collect())
}
pub fn specs_for_changed_files(root: &Path, changed_files: &[String]) -> Result<Vec<String>> {
let index = collect_index(root)?;
let mut matched = Vec::new();
for entry in &index {
let files_match = entry
.files
.iter()
.any(|f| changed_files.iter().any(|c| c == f));
let dir_match = entry
.path
.parent()
.and_then(|parent| parent.strip_prefix(root).ok())
.map(|rel_parent| {
let mut prefix = rel_parent.to_string_lossy().replace('\\', "/");
if !prefix.ends_with('/') {
prefix.push('/');
}
changed_files.iter().any(|c| c.starts_with(&prefix))
})
.unwrap_or(false);
if files_match || dir_match {
matched.push(entry.name.clone());
}
}
matched.sort();
matched.dedup();
Ok(matched)
}
pub fn load_module_bundle(root: &Path, name: &str) -> Result<String> {
validate_module_name(name)?;
let entry = collect_index(root)
.ok()
.and_then(|index| index.into_iter().find(|e| e.name == name));
let spec_path = match entry {
Some(e) => e.path,
None => {
let specs_dir = specs_dir_from_config(root)?;
let fallback = specs_dir.join(name).join(format!("{name}.spec.md"));
bail!(
"No spec found for '{}' (looked at {})",
name,
fallback.display()
);
}
};
let module_dir = spec_path.parent().unwrap_or(&spec_path).to_path_buf();
let spec_filename = spec_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| format!("{name}.spec.md"));
let mut bundle = String::new();
bundle.push_str(&format!("## Spec bundle: {name}\n\n"));
let spec_content = fs::read_to_string(&spec_path)
.with_context(|| format!("reading {}", spec_path.display()))?;
bundle.push_str(&format!("### `{spec_filename}`\n\n"));
bundle.push_str(spec_content.trim_end());
bundle.push_str("\n\n");
for companion in COMPANION_FILES {
let companion_path = module_dir.join(companion);
if !companion_path.exists() {
continue;
}
let companion_content = fs::read_to_string(&companion_path)
.with_context(|| format!("reading {}", companion_path.display()))?;
bundle.push_str(&format!("### `{companion}`\n\n"));
bundle.push_str(companion_content.trim_end());
bundle.push_str("\n\n");
}
Ok(bundle)
}