use crate::types::RegistryEntry;
use std::fs;
use std::path::Path;
use std::time::Duration;
use walkdir::WalkDir;
const REGISTRY_FILENAME: &str = "specsync-registry.toml";
#[derive(Debug, Clone)]
pub struct RemoteRegistry {
#[allow(dead_code)]
pub name: String,
pub specs: Vec<(String, String)>,
}
impl RemoteRegistry {
pub fn has_spec(&self, module: &str) -> bool {
self.specs.iter().any(|(m, _)| m == module)
}
pub fn spec_path(&self, module: &str) -> Option<&str> {
self.specs
.iter()
.find(|(m, _)| m == module)
.map(|(_, p)| p.as_str())
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct RemoteSpec {
pub module: String,
pub status: Option<String>,
pub depends_on: Vec<String>,
pub exports: Vec<String>,
pub body: String,
}
pub fn fetch_remote_spec(repo: &str, spec_path: &str) -> Result<String, String> {
let url = format!("https://raw.githubusercontent.com/{repo}/HEAD/{spec_path}");
let agent = ureq::Agent::new_with_config(
ureq::config::Config::builder()
.timeout_global(Some(Duration::from_secs(10)))
.build(),
);
let mut response = agent
.get(&url)
.call()
.map_err(|e| format!("HTTP request failed: {e}"))?;
if response.status() != 200 {
return Err(format!(
"HTTP {} — could not fetch {spec_path} from {repo}",
response.status()
));
}
response
.body_mut()
.read_to_string()
.map_err(|e| format!("Failed to read response body: {e}"))
}
pub fn parse_remote_spec(module: &str, content: &str) -> Option<RemoteSpec> {
use crate::parser;
let parsed = parser::parse_frontmatter(content)?;
let exports = parser::get_spec_symbols(&parsed.body);
Some(RemoteSpec {
module: parsed
.frontmatter
.module
.unwrap_or_else(|| module.to_string()),
status: parsed.frontmatter.status,
depends_on: parsed.frontmatter.depends_on,
exports,
body: parsed.body,
})
}
pub fn fetch_remote_registry(repo: &str) -> Result<RemoteRegistry, String> {
let url = format!("https://raw.githubusercontent.com/{repo}/HEAD/{REGISTRY_FILENAME}");
let agent = ureq::Agent::new_with_config(
ureq::config::Config::builder()
.timeout_global(Some(Duration::from_secs(10)))
.build(),
);
let mut response = agent
.get(&url)
.call()
.map_err(|e| format!("HTTP request failed: {e}"))?;
if response.status() != 200 {
return Err(format!(
"HTTP {} — {repo} may not have a {REGISTRY_FILENAME}",
response.status()
));
}
let body = response
.body_mut()
.read_to_string()
.map_err(|e| format!("Failed to read response body: {e}"))?;
let entry =
parse_registry(&body).ok_or_else(|| format!("Failed to parse registry from {repo}"))?;
Ok(RemoteRegistry {
name: entry.name,
specs: entry.specs,
})
}
#[allow(dead_code)]
pub fn load_registry(root: &Path) -> Option<RegistryEntry> {
let path = root.join(REGISTRY_FILENAME);
let content = fs::read_to_string(&path).ok()?;
parse_registry(&content)
}
fn parse_registry(content: &str) -> Option<RegistryEntry> {
let mut name = String::new();
let mut specs = Vec::new();
let mut in_specs = false;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line == "[registry]" {
in_specs = false;
continue;
}
if line == "[specs]" {
in_specs = true;
continue;
}
if let Some(eq_pos) = line.find('=') {
let key = line[..eq_pos].trim();
let value = line[eq_pos + 1..].trim();
let value = value.trim_matches('"');
if in_specs {
specs.push((key.to_string(), value.to_string()));
} else if key == "name" {
name = value.to_string();
}
}
}
if name.is_empty() {
return None;
}
Some(RegistryEntry { name, specs })
}
pub fn generate_registry(root: &Path, project_name: &str, specs_dir: &str) -> String {
let specs_path = root.join(specs_dir);
let mut specs = Vec::new();
if specs_path.exists() {
for entry in WalkDir::new(&specs_path).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_file()
&& path
.to_str()
.map(|s| s.ends_with(".spec.md"))
.unwrap_or(false)
{
if let Some(name) = path.file_name().and_then(|n| n.to_str())
&& name.starts_with('_')
{
continue;
}
if let Ok(content) = fs::read_to_string(path)
&& let Some(module) = extract_module_name(&content)
{
let rel_path = path
.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
specs.push((module, rel_path));
}
}
}
}
specs.sort_by(|a, b| a.0.cmp(&b.0));
let mut output = String::new();
output.push_str("[registry]\n");
output.push_str(&format!("name = \"{project_name}\"\n"));
output.push_str("\n[specs]\n");
for (module, path) in &specs {
output.push_str(&format!("{module} = \"{path}\"\n"));
}
output
}
pub fn register_module(root: &Path, module_name: &str, spec_rel_path: &str) -> bool {
let path = root.join(REGISTRY_FILENAME);
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return false,
};
if let Some(entry) = parse_registry(&content) {
if entry.specs.iter().any(|(m, _)| m == module_name) {
return false;
}
}
let new_line = format!("{module_name} = \"{spec_rel_path}\"\n");
if content.contains("[specs]") {
let updated = format!("{}\n{new_line}", content.trim_end());
if fs::write(&path, updated).is_ok() {
return true;
}
}
false
}
fn extract_module_name(content: &str) -> Option<String> {
for line in content.lines() {
if line == "---" {
continue;
}
if let Some(rest) = line.strip_prefix("module:") {
let name = rest.trim();
if !name.is_empty() {
return Some(name.to_string());
}
}
if line.starts_with("---") && content.starts_with("---") {
break;
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_registry() {
let content = r#"
[registry]
name = "algochat"
[specs]
auth = "specs/auth/auth.spec.md"
messaging = "specs/messaging/messaging.spec.md"
"#;
let entry = parse_registry(content).unwrap();
assert_eq!(entry.name, "algochat");
assert_eq!(entry.specs.len(), 2);
assert_eq!(entry.specs[0].0, "auth");
assert_eq!(entry.specs[0].1, "specs/auth/auth.spec.md");
}
#[test]
fn test_parse_registry_empty() {
assert!(parse_registry("").is_none());
assert!(parse_registry("[registry]").is_none());
}
#[test]
fn test_extract_module_name() {
let content = "---\nmodule: auth\nversion: 1\n---\n# Auth\n";
assert_eq!(extract_module_name(content), Some("auth".to_string()));
}
#[test]
fn test_remote_registry_has_spec() {
let reg = RemoteRegistry {
name: "test".to_string(),
specs: vec![
("auth".to_string(), "specs/auth/auth.spec.md".to_string()),
(
"messaging".to_string(),
"specs/messaging/messaging.spec.md".to_string(),
),
],
};
assert!(reg.has_spec("auth"));
assert!(reg.has_spec("messaging"));
assert!(!reg.has_spec("nonexistent"));
}
}