use std::fs;
use std::path::Path;
use std::path::PathBuf;
use crate::core::error;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const LOCAL_PROJECT_SPECS_DIR: &str = ".decapod/generated/specs";
pub const LOCAL_PROJECT_SPECS_README: &str = ".decapod/generated/specs/README.md";
pub const LOCAL_PROJECT_SPECS_INTENT: &str = ".decapod/generated/specs/INTENT.md";
pub const LOCAL_PROJECT_SPECS_ARCHITECTURE: &str = ".decapod/generated/specs/ARCHITECTURE.md";
pub const LOCAL_PROJECT_SPECS_INTERFACES: &str = ".decapod/generated/specs/INTERFACES.md";
pub const LOCAL_PROJECT_SPECS_VALIDATION: &str = ".decapod/generated/specs/VALIDATION.md";
pub const LOCAL_PROJECT_SPECS_SEMANTICS: &str = ".decapod/generated/specs/SEMANTICS.md";
pub const LOCAL_PROJECT_SPECS_OPERATIONS: &str = ".decapod/generated/specs/OPERATIONS.md";
pub const LOCAL_PROJECT_SPECS_SECURITY: &str = ".decapod/generated/specs/SECURITY.md";
pub const LOCAL_PROJECT_SPECS_MANIFEST: &str = ".decapod/generated/specs/.manifest.json";
pub const LOCAL_PROJECT_SPECS_MANIFEST_SCHEMA: &str = "1.0.0";
#[derive(Clone, Copy, Debug)]
pub struct LocalProjectSpec {
pub path: &'static str,
pub role: &'static str,
pub constitution_ref: &'static str,
}
pub const LOCAL_PROJECT_SPECS: &[LocalProjectSpec] = &[
LocalProjectSpec {
path: LOCAL_PROJECT_SPECS_README,
role: "specs_index",
constitution_ref: "interfaces/PROJECT_SPECS#Canonical Local Project Specs Set",
},
LocalProjectSpec {
path: LOCAL_PROJECT_SPECS_INTENT,
role: "intent_purpose",
constitution_ref: "specs/INTENT",
},
LocalProjectSpec {
path: LOCAL_PROJECT_SPECS_ARCHITECTURE,
role: "implementation_architecture",
constitution_ref: "interfaces/ARCHITECTURE_FOUNDATIONS",
},
LocalProjectSpec {
path: LOCAL_PROJECT_SPECS_INTERFACES,
role: "service_contracts",
constitution_ref: "interfaces/CONTROL_PLANE",
},
LocalProjectSpec {
path: LOCAL_PROJECT_SPECS_VALIDATION,
role: "proof_and_gate_plan",
constitution_ref: "interfaces/TESTING",
},
LocalProjectSpec {
path: LOCAL_PROJECT_SPECS_SEMANTICS,
role: "state_machines_and_invariants",
constitution_ref: "interfaces/PROJECT_SPECS",
},
LocalProjectSpec {
path: LOCAL_PROJECT_SPECS_OPERATIONS,
role: "operational_readiness",
constitution_ref: "interfaces/PROJECT_SPECS",
},
LocalProjectSpec {
path: LOCAL_PROJECT_SPECS_SECURITY,
role: "security_posture",
constitution_ref: "interfaces/PROJECT_SPECS",
},
];
#[derive(Debug, Clone, Default)]
pub struct LocalProjectSpecsContext {
pub intent: Option<String>,
pub architecture: Option<String>,
pub interfaces: Option<String>,
pub validation: Option<String>,
pub semantics: Option<String>,
pub operations: Option<String>,
pub security: Option<String>,
pub canonical_paths: Vec<String>,
pub constitution_refs: Vec<String>,
pub update_guidance: String,
}
fn read_if_exists(project_root: &Path, rel_path: &str) -> Option<String> {
let path = project_root.join(rel_path);
if !path.exists() {
return None;
}
fs::read_to_string(path).ok()
}
pub fn first_markdown_content_line(markdown: &str) -> Option<String> {
let mut in_fence = false;
for line in markdown.lines() {
let mut trimmed = line.trim();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fence = !in_fence;
continue;
}
if in_fence
|| trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with('<')
|| trimmed.starts_with("![")
|| trimmed.starts_with('|')
|| trimmed == "---"
{
continue;
}
if let Some(rest) = trimmed.strip_prefix("- ") {
trimmed = rest.trim();
} else if let Some(rest) = trimmed.strip_prefix("* ") {
trimmed = rest.trim();
}
if trimmed.is_empty() || trimmed.starts_with("[ ]") || trimmed.starts_with("[x]") {
continue;
}
return Some(trimmed.to_string());
}
None
}
pub fn local_project_specs_context(project_root: &Path) -> LocalProjectSpecsContext {
let mut ctx = LocalProjectSpecsContext::default();
for spec in LOCAL_PROJECT_SPECS {
ctx.canonical_paths.push(spec.path.to_string());
ctx.constitution_refs
.push(spec.constitution_ref.to_string());
}
ctx.constitution_refs.sort();
ctx.constitution_refs.dedup();
ctx.intent = read_if_exists(project_root, LOCAL_PROJECT_SPECS_INTENT)
.and_then(|s| first_markdown_content_line(&s));
ctx.architecture = read_if_exists(project_root, LOCAL_PROJECT_SPECS_ARCHITECTURE)
.and_then(|s| first_markdown_content_line(&s));
ctx.interfaces = read_if_exists(project_root, LOCAL_PROJECT_SPECS_INTERFACES)
.and_then(|s| first_markdown_content_line(&s));
ctx.validation = read_if_exists(project_root, LOCAL_PROJECT_SPECS_VALIDATION)
.and_then(|s| first_markdown_content_line(&s));
ctx.semantics = read_if_exists(project_root, LOCAL_PROJECT_SPECS_SEMANTICS)
.and_then(|s| first_markdown_content_line(&s));
ctx.operations = read_if_exists(project_root, LOCAL_PROJECT_SPECS_OPERATIONS)
.and_then(|s| first_markdown_content_line(&s));
ctx.security = read_if_exists(project_root, LOCAL_PROJECT_SPECS_SECURITY)
.and_then(|s| first_markdown_content_line(&s));
ctx.update_guidance = "Treat .decapod/generated/specs/*.md as living project contracts: when user intent, interfaces, architecture, or proof gates change, update these specs before implementation proceeds.".to_string();
ctx
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectSpecManifestEntry {
pub path: String,
pub template_hash: String,
pub content_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectSpecsManifest {
pub schema_version: String,
pub template_version: String,
pub generated_at: String,
pub repo_signal_fingerprint: String,
pub files: Vec<ProjectSpecManifestEntry>,
}
pub fn hash_text(text: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
format!("{:x}", hasher.finalize())
}
fn repo_signal_requires_content_hash(rel_path: &str) -> bool {
rel_path == "AGENTS.md"
|| rel_path == "CLAUDE.md"
|| rel_path == "CODEX.md"
|| rel_path == "GEMINI.md"
|| rel_path == "Cargo.toml"
|| rel_path == "Cargo.lock"
|| rel_path == "package.json"
|| rel_path == "package-lock.json"
|| rel_path == "pyproject.toml"
|| rel_path == "requirements.txt"
|| rel_path == "go.mod"
|| rel_path == "go.sum"
|| rel_path == "Dockerfile"
|| rel_path == "docker-compose.yml"
|| rel_path == "docker-compose.yaml"
|| rel_path == "compose.yml"
|| rel_path == "compose.yaml"
|| rel_path == "README.md"
|| rel_path == "Makefile"
|| rel_path.starts_with("infra/")
|| rel_path.starts_with("deploy/")
|| rel_path.starts_with("k8s/")
|| rel_path.ends_with(".sql")
}
fn collect_significant_repo_paths(
root: &Path,
dir: &Path,
out: &mut Vec<PathBuf>,
) -> Result<(), error::DecapodError> {
if !dir.is_dir() {
return Ok(());
}
let name = dir.file_name().and_then(|s| s.to_str()).unwrap_or("");
if matches!(
name,
".git" | ".decapod" | "target" | "node_modules" | ".venv"
) {
return Ok(());
}
for entry in fs::read_dir(dir).map_err(error::DecapodError::IoError)? {
let entry = entry.map_err(error::DecapodError::IoError)?;
let path = entry.path();
if path.is_dir() {
collect_significant_repo_paths(root, &path, out)?;
continue;
}
if !path.is_file() {
continue;
}
let rel = match path.strip_prefix(root) {
Ok(p) => p,
Err(_) => continue,
};
let rel_str = rel.to_string_lossy();
let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
let top_level_signal = matches!(
file_name,
"Cargo.toml"
| "Cargo.lock"
| "package.json"
| "package-lock.json"
| "pyproject.toml"
| "requirements.txt"
| "go.mod"
| "go.sum"
| "Dockerfile"
| "docker-compose.yml"
| "docker-compose.yaml"
| "compose.yml"
| "compose.yaml"
| "README.md"
| "Makefile"
);
let path_signal = rel_str.starts_with(".github/workflows/")
|| rel_str.starts_with("src/")
|| rel_str.starts_with("app/")
|| rel_str.starts_with("api/")
|| rel_str.starts_with("backend/")
|| rel_str.starts_with("frontend/")
|| rel_str.starts_with("web/")
|| rel_str.starts_with("services/")
|| rel_str.starts_with("infra/")
|| rel_str.starts_with("deploy/")
|| rel_str.starts_with("k8s/")
|| rel_str.ends_with(".sql");
if top_level_signal || path_signal {
out.push(path);
}
}
Ok(())
}
pub fn repo_signal_fingerprint(project_root: &Path) -> Result<String, error::DecapodError> {
let mut files = Vec::new();
collect_significant_repo_paths(project_root, project_root, &mut files)?;
files.sort();
let mut hasher = Sha256::new();
for path in files {
let rel = path
.strip_prefix(project_root)
.unwrap_or(path.as_path())
.to_string_lossy()
.to_string();
hasher.update(rel.as_bytes());
hasher.update(b"\0");
if repo_signal_requires_content_hash(&rel) {
let content = fs::read(&path).map_err(error::DecapodError::IoError)?;
let content_hash = hash_text(&String::from_utf8_lossy(&content));
hasher.update(content_hash.as_bytes());
} else {
hasher.update(b"path-only");
}
hasher.update(b"\n");
}
Ok(format!("{:x}", hasher.finalize()))
}
pub fn read_specs_manifest(
project_root: &Path,
) -> Result<Option<ProjectSpecsManifest>, error::DecapodError> {
let path = project_root.join(LOCAL_PROJECT_SPECS_MANIFEST);
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(path).map_err(error::DecapodError::IoError)?;
let manifest: ProjectSpecsManifest = serde_json::from_str(&raw).map_err(|e| {
error::DecapodError::ValidationError(format!("Invalid project specs manifest: {e}"))
})?;
Ok(Some(manifest))
}
pub fn refresh_specs_manifest(
project_root: &Path,
) -> Result<ProjectSpecsManifest, error::DecapodError> {
let existing = read_specs_manifest(project_root)?.ok_or_else(|| {
error::DecapodError::NotFound(
"Project specs manifest not found. Run `decapod init` first.".to_string(),
)
})?;
let mut manifest_entries = Vec::new();
for spec in LOCAL_PROJECT_SPECS {
let path = project_root.join(spec.path);
if !path.exists() {
continue;
}
let body = fs::read_to_string(&path).map_err(error::DecapodError::IoError)?;
let content_hash = hash_text(&body);
let template_hash = existing
.files
.iter()
.find(|f| f.path == spec.path)
.map(|f| f.template_hash.clone())
.unwrap_or_else(|| content_hash.clone());
manifest_entries.push(ProjectSpecManifestEntry {
path: spec.path.to_string(),
template_hash,
content_hash,
});
}
let manifest = ProjectSpecsManifest {
schema_version: LOCAL_PROJECT_SPECS_MANIFEST_SCHEMA.to_string(),
template_version: existing.template_version,
generated_at: crate::core::time::now_epoch_z(),
repo_signal_fingerprint: repo_signal_fingerprint(project_root)?,
files: manifest_entries,
};
let manifest_path = project_root.join(LOCAL_PROJECT_SPECS_MANIFEST);
let manifest_body = serde_json::to_string_pretty(&manifest).map_err(|e| {
error::DecapodError::ValidationError(format!("Failed to serialize specs manifest: {e}"))
})?;
fs::write(manifest_path, manifest_body).map_err(error::DecapodError::IoError)?;
Ok(manifest)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_markdown_content_line_uses_bullet_text_before_code_fence() {
let markdown = r#"# Intent
## Product Outcome
- Decapod governs agent work.
## Product View
```mermaid
flowchart LR
```
"#;
assert_eq!(
first_markdown_content_line(markdown).as_deref(),
Some("Decapod governs agent work.")
);
}
#[test]
fn first_markdown_content_line_ignores_html_and_fenced_blocks() {
let markdown = r#"<p align="center">ignored</p>
```bash
cargo install decapod
```
Real product summary.
"#;
assert_eq!(
first_markdown_content_line(markdown).as_deref(),
Some("Real product summary.")
);
}
}