use anyhow::{bail, Context, Result};
use console::style;
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use super::{
classify_companions, find_spec_files, load_config, parse, to_title_case, validate_module_name,
validation, SPEC_CHECK_SCHEMA, SPEC_LIST_SCHEMA, SPEC_SHOW_SCHEMA,
};
#[derive(Debug, Serialize)]
pub(crate) struct SpecSummary {
name: String,
version: u32,
status: String,
path: String,
files: Vec<String>,
section_count: usize,
required_sections: usize,
companions: Vec<String>,
missing_companions: Vec<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct SpecDetail {
name: String,
version: u32,
status: String,
path: String,
files: Vec<String>,
sections: Vec<String>,
companions: Vec<String>,
missing_companions: Vec<String>,
}
pub(crate) fn check(root: &Path, strict: bool, json: bool) -> Result<()> {
let config = load_config(root)?;
let specs_dir = root.join(config.specs_dir.as_deref().unwrap_or("specs"));
if !specs_dir.exists() {
if json {
let payload = serde_json::json!({
"schema_version": SPEC_CHECK_SCHEMA,
"action": "spec_check",
"specs": [],
"totals": { "checked": 0, "errors": 0, "warnings": 0 },
"strict": strict,
});
println!("{}", serde_json::to_string_pretty(&payload)?);
} else {
println!(
"{} No specs directory found at {}",
style("*").cyan().bold(),
style(specs_dir.display()).dim()
);
}
return Ok(());
}
let required_sections = if config.required_sections.is_empty() {
vec![
"Purpose".to_string(),
"Public API".to_string(),
"Invariants".to_string(),
"Behavioral Examples".to_string(),
"Error Cases".to_string(),
"Dependencies".to_string(),
"Change Log".to_string(),
]
} else {
config.required_sections.clone()
};
let mut results: Vec<validation::SpecResult> = 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") {
results.push(validation::validate_spec(path, root, &required_sections));
}
}
}
if results.is_empty() {
if json {
let payload = serde_json::json!({
"schema_version": SPEC_CHECK_SCHEMA,
"action": "spec_check",
"specs": [],
"totals": { "checked": 0, "errors": 0, "warnings": 0 },
"strict": strict,
});
println!("{}", serde_json::to_string_pretty(&payload)?);
} else {
println!(
"{} No spec files found in {}",
style("*").cyan().bold(),
style(specs_dir.display()).dim()
);
}
return Ok(());
}
results.sort_by(|a, b| a.name.cmp(&b.name));
let mut total_errors = 0;
let mut total_warnings = 0;
for result in &results {
total_errors += result.error_count();
total_warnings += result.warning_count();
}
if json {
let specs_payload: Vec<serde_json::Value> = results
.iter()
.map(|r| {
let errors: Vec<&str> = r
.issues
.iter()
.filter(|i| i.is_error)
.map(|i| i.message.as_str())
.collect();
let warnings: Vec<&str> = r
.issues
.iter()
.filter(|i| !i.is_error)
.map(|i| i.message.as_str())
.collect();
serde_json::json!({
"name": r.name,
"version": r.version,
"status": r.status,
"file_count": r.file_count,
"section_count": r.section_count,
"required_count": r.required_count,
"errors": errors,
"warnings": warnings,
})
})
.collect();
let payload = serde_json::json!({
"schema_version": SPEC_CHECK_SCHEMA,
"action": "spec_check",
"specs": specs_payload,
"totals": {
"checked": results.len(),
"errors": total_errors,
"warnings": total_warnings,
},
"strict": strict,
});
println!("{}", serde_json::to_string_pretty(&payload)?);
if total_errors > 0 || (strict && total_warnings > 0) {
bail!(
"spec check failed: {} error(s), {} warning(s)",
total_errors,
total_warnings
);
}
return Ok(());
}
for result in &results {
if result.has_errors() || (strict && result.has_warnings()) {
print!(
"{} {} (v{}, {})",
style("❌").red().bold(),
style(&result.name).red(),
result.version,
result.status,
);
} else if result.has_warnings() {
print!(
"{} {} (v{}, {})",
style("⚠️").yellow().bold(),
style(&result.name).yellow(),
result.version,
result.status,
);
} else {
print!(
"{} {} (v{}, {})",
style("✅").green().bold(),
style(&result.name).green(),
result.version,
result.status,
);
}
println!(
" — {} {}, {}/{} sections",
result.file_count,
if result.file_count == 1 {
"file"
} else {
"files"
},
result.section_count,
result.required_count,
);
for issue in &result.issues {
if issue.is_error {
println!(" {} {}", style("error:").red(), issue.message);
} else {
println!(" {} {}", style("warn:").yellow(), issue.message);
}
}
}
println!();
println!(
" {} specs checked, {} {}, {} {}",
results.len(),
total_errors,
if total_errors == 1 { "error" } else { "errors" },
total_warnings,
if total_warnings == 1 {
"warning"
} else {
"warnings"
},
);
if total_errors > 0 || (strict && total_warnings > 0) {
if strict && total_warnings > 0 && total_errors == 0 {
println!(
" {}",
style("(warnings treated as errors in strict mode)").dim()
);
}
bail!(
"spec check failed: {} error(s), {} warning(s)",
total_errors,
total_warnings
);
}
Ok(())
}
pub(crate) fn build_summary(
spec_path: &Path,
root: &Path,
required_count: usize,
) -> Result<SpecSummary> {
let content = fs::read_to_string(spec_path)
.with_context(|| format!("reading {}", spec_path.display()))?;
let (fm, body) = parse::parse_frontmatter(&content)
.with_context(|| format!("parsing frontmatter in {}", spec_path.display()))?;
let sections = parse::extract_sections(&body);
let (companions, missing_companions) = classify_companions(spec_path.parent().unwrap_or(root));
let rel_path = spec_path
.strip_prefix(root)
.unwrap_or(spec_path)
.display()
.to_string();
Ok(SpecSummary {
name: fm.module,
version: fm.version,
status: fm.status,
path: rel_path,
files: fm.files,
section_count: sections.len(),
required_sections: required_count,
companions,
missing_companions,
})
}
pub(crate) fn list_specs(root: &Path, json: bool) -> Result<()> {
let config = load_config(root)?;
let specs_dir = root.join(config.specs_dir.as_deref().unwrap_or("specs"));
let required_count = if config.required_sections.is_empty() {
7
} else {
config.required_sections.len()
};
if !specs_dir.exists() {
if json {
println!("[]");
} else {
println!(
"{} No specs directory found at {}",
style("*").cyan().bold(),
style(specs_dir.display()).dim()
);
}
return Ok(());
}
let mut summaries: Vec<SpecSummary> = Vec::new();
let mut parse_errors: Vec<(PathBuf, String)> = Vec::new();
for path in find_spec_files(&specs_dir) {
match build_summary(&path, root, required_count) {
Ok(summary) => summaries.push(summary),
Err(e) => parse_errors.push((path, e.to_string())),
}
}
summaries.sort_by(|a, b| a.name.cmp(&b.name));
if json {
let envelope = serde_json::json!({
"schema_version": SPEC_LIST_SCHEMA,
"action": "spec_list",
"specs": summaries,
});
println!("{}", serde_json::to_string_pretty(&envelope)?);
return Ok(());
}
if summaries.is_empty() && parse_errors.is_empty() {
println!(
"{} No spec files found in {}",
style("*").cyan().bold(),
style(specs_dir.display()).dim()
);
return Ok(());
}
for summary in &summaries {
let status_marker = match summary.status.as_str() {
"active" | "stable" => style("●").green(),
"draft" | "review" => style("●").yellow(),
"deprecated" | "archived" => style("●").red(),
_ => style("●").dim(),
};
println!(
"{} {} {} ({})",
status_marker,
style(&summary.name).bold(),
style(format!("v{}", summary.version)).dim(),
summary.status,
);
println!(
" {} — {} source {}, {}/{} sections, {} companion {}",
style(&summary.path).dim(),
summary.files.len(),
if summary.files.len() == 1 {
"file"
} else {
"files"
},
summary.section_count,
summary.required_sections,
summary.companions.len(),
if summary.companions.len() == 1 {
"file"
} else {
"files"
},
);
if !summary.missing_companions.is_empty() {
println!(
" {} {}",
style("missing:").yellow(),
summary.missing_companions.join(", ")
);
}
}
for (path, err) in &parse_errors {
println!(
"{} {} — {}",
style("❌").red().bold(),
path.display(),
style(err).red()
);
}
println!();
println!(" {} spec(s) found", summaries.len());
Ok(())
}
pub(crate) fn show_spec(root: &Path, name: &str, json: bool) -> Result<()> {
validate_module_name(name)?;
let config = load_config(root)?;
let specs_dir = root.join(config.specs_dir.as_deref().unwrap_or("specs"));
let spec_path = specs_dir.join(name).join(format!("{name}.spec.md"));
if !spec_path.exists() {
bail!(
"No spec found for '{}'. Looked at {}. Run {} to see available specs.",
name,
spec_path.display(),
style("fledge spec list").cyan()
);
}
let content = fs::read_to_string(&spec_path)
.with_context(|| format!("reading {}", spec_path.display()))?;
let (fm, body) = parse::parse_frontmatter(&content)
.with_context(|| format!("parsing frontmatter in {}", spec_path.display()))?;
let sections = parse::extract_sections(&body);
let spec_dir = spec_path.parent().unwrap_or(root);
let (companions, missing_companions) = classify_companions(spec_dir);
let rel_path = spec_path
.strip_prefix(root)
.unwrap_or(&spec_path)
.display()
.to_string();
let detail = SpecDetail {
name: fm.module,
version: fm.version,
status: fm.status,
path: rel_path,
files: fm.files,
sections,
companions,
missing_companions,
};
if json {
let envelope = serde_json::json!({
"schema_version": SPEC_SHOW_SCHEMA,
"action": "spec_show",
"spec": detail,
});
println!("{}", serde_json::to_string_pretty(&envelope)?);
return Ok(());
}
println!(
"{} {} ({})",
style(&detail.name).bold().cyan(),
style(format!("v{}", detail.version)).dim(),
detail.status,
);
println!(" path: {}", style(&detail.path).dim());
if detail.files.is_empty() {
println!(" source files: {}", style("(none)").dim());
} else {
println!(" source files:");
for file in &detail.files {
println!(" - {file}");
}
}
if detail.sections.is_empty() {
println!(" sections: {}", style("(none)").dim());
} else {
println!(" sections ({}):", detail.sections.len());
for section in &detail.sections {
println!(" - {section}");
}
}
if detail.companions.is_empty() {
println!(" companions: {}", style("(none present)").yellow());
} else {
println!(" companions:");
for companion in &detail.companions {
println!(" {} {}", style("✓").green(), companion);
}
}
for companion in &detail.missing_companions {
println!(" {} {}", style("✗").yellow(), companion);
}
Ok(())
}
pub(crate) fn init(root: &Path) -> Result<()> {
let specsync_dir = root.join(".specsync");
if specsync_dir.exists() {
bail!(".specsync/ already exists. Remove it first to re-initialize.");
}
let specs_dir = root.join("specs");
fs::create_dir_all(&specsync_dir)?;
let project_name = root
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let config_content = r#"# spec-sync v4 configuration
# Docs: https://github.com/CorvidLabs/spec-sync
specs_dir = "specs"
source_dirs = ["src"]
exclude_dirs = []
exclude_patterns = []
required_sections = ["Purpose", "Public API", "Invariants", "Behavioral Examples", "Error Cases", "Dependencies", "Change Log"]
enforcement = "strict"
[lifecycle]
track_history = false
"#;
let registry_content = format!(
r#"[registry]
name = "{project_name}"
[specs]
"#
);
let gitignore_content = r#"backup-3x/
config.local.toml
hashes.json
"#;
fs::write(specsync_dir.join("config.toml"), config_content)?;
println!(
"{} Created .specsync/config.toml",
style("✅").green().bold()
);
fs::write(specsync_dir.join("registry.toml"), registry_content)?;
println!(
"{} Created .specsync/registry.toml",
style("✅").green().bold()
);
fs::write(specsync_dir.join(".gitignore"), gitignore_content)?;
println!(
"{} Created .specsync/.gitignore",
style("✅").green().bold()
);
fs::write(specsync_dir.join("version"), "4.3.1\n")?;
println!("{} Created .specsync/version", style("✅").green().bold());
if !specs_dir.exists() {
fs::create_dir_all(&specs_dir)?;
println!("{} Created specs/", style("✅").green().bold());
}
println!();
println!(
" Spec-sync initialized. Run {} to create your first spec.",
style("fledge spec new <name>").cyan()
);
Ok(())
}
pub(crate) fn new_spec(root: &Path, name: &str) -> Result<()> {
validate_module_name(name)?;
let config = load_config(root)?;
let specs_dir = root.join(config.specs_dir.as_deref().unwrap_or("specs"));
let spec_dir = specs_dir.join(name);
if spec_dir.exists() {
bail!("Spec directory already exists: {}", spec_dir.display());
}
fs::create_dir_all(&spec_dir)?;
let spec_content = format!(
r#"---
module: {name}
version: 1
status: draft
files:
- src/{name}.rs
db_tables: []
depends_on: []
---
# {title}
## Purpose
<!-- Describe what this module does and why it exists. -->
## Public API
### Exported Functions
| Export | Description |
|--------|-------------|
| | |
### Structs & Enums
| Type | Description |
|------|-------------|
| | |
### Traits
| Trait | Description |
|-------|-------------|
| | |
### Functions
| Function | Signature | Description |
|----------|-----------|-------------|
| | | |
## Invariants
1. <!-- List invariants that must always hold. -->
## Behavioral Examples
```
Given ...
When ...
Then ...
```
## Error Cases
| Error | When | Behavior |
|-------|------|----------|
| | | |
## Dependencies
- None
## Change Log
| Version | Date | Changes |
|---------|------|---------|
| 1 | {date} | Initial spec |
"#,
name = name,
title = to_title_case(name),
date = chrono::Local::now().format("%Y-%m-%d"),
);
let requirements_content = format!(
r#"---
spec: {name}.spec.md
---
## User Stories
- As a developer, I want to <!-- describe the goal -->
## Acceptance Criteria
- <!-- List measurable acceptance criteria. -->
## Constraints
- <!-- List any constraints or limitations. -->
## Out of Scope
- <!-- List anything explicitly excluded. -->
"#
);
let tasks_content = format!(
r#"---
spec: {name}.spec.md
---
## Tasks
- [ ] Write spec
- [ ] Implement module
- [ ] Write tests
"#
);
let context_content = format!(
r#"---
spec: {name}.spec.md
---
## Context
<!-- Describe the context and motivation for this module. -->
## Related Modules
- <!-- List related modules or specs. -->
## Design Decisions
- <!-- Document key design decisions and their rationale. -->
"#
);
let testing_content = format!(
r#"---
spec: {name}.spec.md
---
## Test Plan
### Unit Tests
- <!-- List unit test scenarios. -->
### Integration Tests
- <!-- List integration test scenarios. -->
"#
);
fs::write(spec_dir.join(format!("{name}.spec.md")), &spec_content)?;
println!(
"{} Created specs/{name}/{name}.spec.md",
style("✅").green().bold()
);
fs::write(spec_dir.join("requirements.md"), &requirements_content)?;
println!(
"{} Created specs/{name}/requirements.md",
style("✅").green().bold()
);
fs::write(spec_dir.join("tasks.md"), &tasks_content)?;
println!(
"{} Created specs/{name}/tasks.md",
style("✅").green().bold()
);
fs::write(spec_dir.join("context.md"), &context_content)?;
println!(
"{} Created specs/{name}/context.md",
style("✅").green().bold()
);
fs::write(spec_dir.join("testing.md"), &testing_content)?;
println!(
"{} Created specs/{name}/testing.md",
style("✅").green().bold()
);
let registry_path = root.join(".specsync/registry.toml");
if registry_path.exists() {
let mut registry = fs::read_to_string(®istry_path)?;
let entry = format!("{name} = \"specs/{name}/{name}.spec.md\"\n");
if !registry.contains(&format!("{name} =")) {
registry.push_str(&entry);
fs::write(®istry_path, ®istry)?;
}
}
println!();
println!(
" Spec module '{}' created. Edit {} to get started.",
style(name).green(),
style(format!("specs/{name}/{name}.spec.md")).cyan()
);
Ok(())
}