use crate::core::{assets, docs, error};
use clap::Subcommand;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
#[derive(clap::Args, Debug)]
pub struct DocsCli {
#[clap(subcommand)]
pub command: DocsCommand,
}
#[derive(Debug, Clone, clap::ValueEnum)]
pub enum DocumentSource {
Embedded,
Override,
Merged,
}
#[derive(Subcommand, Debug)]
pub enum DocsCommand {
List,
Show {
#[clap(value_parser)]
path: String,
#[clap(long, short, value_enum, default_value = "merged")]
source: DocumentSource,
},
Ingest,
Search {
#[clap(long)]
query: String,
#[clap(long)]
op: Option<String>,
#[clap(long = "path")]
path: Vec<String>,
#[clap(long = "tag")]
tag: Vec<String>,
#[clap(long, default_value_t = 5)]
limit: usize,
#[clap(long, default_value = "text")]
format: String,
},
Override {
#[clap(long, short)]
force: bool,
},
}
#[derive(Debug, Default)]
pub struct DocsRunResult {
pub ingested_core_constitution: bool,
}
pub fn run_docs_cli(cli: DocsCli) -> Result<DocsRunResult, error::DecapodError> {
match cli.command {
DocsCommand::List => {
let docs = assets::list_docs();
println!("Embedded Decapod Methodology Docs:");
for doc in docs {
println!("- {}", doc);
}
Ok(DocsRunResult::default())
}
DocsCommand::Show { path, source } => {
let (relative_path, anchor) = if let Some(pos) = path.find('#') {
(&path[..pos], Some(&path[pos + 1..]))
} else {
(path.as_str(), None)
};
let relative_path = relative_path
.strip_prefix("embedded/")
.unwrap_or(relative_path);
if let Some(a) = anchor {
let current_dir = std::env::current_dir().map_err(error::DecapodError::IoError)?;
let repo_root = find_repo_root(¤t_dir)?;
if let Some(fragment) = docs::get_fragment(&repo_root, relative_path, Some(a)) {
println!("--- {} ---", fragment.title);
println!("{}", fragment.excerpt); Ok(DocsRunResult::default())
} else {
Err(error::DecapodError::NotFound(format!(
"Section not found: {} in {}",
a, relative_path
)))
}
} else {
let content = match source {
DocumentSource::Embedded => {
assets::get_embedded_doc(relative_path)
}
DocumentSource::Override => {
let current_dir =
std::env::current_dir().map_err(error::DecapodError::IoError)?;
let repo_root = find_repo_root(¤t_dir)?;
assets::get_override_doc(&repo_root, relative_path)
}
DocumentSource::Merged => {
let current_dir =
std::env::current_dir().map_err(error::DecapodError::IoError)?;
let repo_root = find_repo_root(¤t_dir)?;
assets::get_merged_doc(&repo_root, relative_path)
}
};
match content {
Some(content) => {
println!("{}", content);
Ok(DocsRunResult::default())
}
None => Err(error::DecapodError::NotFound(format!(
"Document not found: {} (source: {:?})",
path, source
))),
}
}
}
DocsCommand::Ingest => {
let docs = assets::list_docs();
let current_dir = std::env::current_dir().map_err(error::DecapodError::IoError)?;
let repo_root = find_repo_root(¤t_dir)?;
let mut ingested_core_constitution = false;
for doc_path in docs {
let relative_path = doc_path.strip_prefix("embedded/").unwrap_or(&doc_path);
if relative_path.starts_with("core/") && relative_path.ends_with(".md") {
ingested_core_constitution = true;
}
if let Some(content) = assets::get_merged_doc(&repo_root, relative_path) {
println!("--- BEGIN {} ---", doc_path);
println!("{}", content);
println!("--- END {} ---", doc_path);
}
}
Ok(DocsRunResult {
ingested_core_constitution,
})
}
DocsCommand::Search {
query,
op,
path,
tag,
limit,
format,
} => {
let current_dir = std::env::current_dir().map_err(error::DecapodError::IoError)?;
let repo_root = find_repo_root(¤t_dir)?;
let fragments = docs::resolve_scoped_fragments(
&repo_root,
Some(&query),
op.as_deref(),
&path,
&tag,
limit,
);
if format.eq_ignore_ascii_case("json") {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"query": query,
"op": op,
"paths": path,
"tags": tag,
"fragments": fragments,
}))
.map_err(|e| error::DecapodError::ValidationError(e.to_string()))?
);
} else {
println!("Scoped constitution context:");
for (idx, fragment) in fragments.iter().enumerate() {
println!("\n{}. {} ({})", idx + 1, fragment.title, fragment.r#ref);
println!("{}", fragment.excerpt);
}
}
Ok(DocsRunResult::default())
}
DocsCommand::Override { force } => {
let current_dir = std::env::current_dir().map_err(error::DecapodError::IoError)?;
let repo_root = find_repo_root(¤t_dir)?;
let override_path = repo_root.join(".decapod").join("OVERRIDE.md");
if !override_path.exists() {
println!("ℹ No OVERRIDE.md found at {}", override_path.display());
println!(" Run `decapod init` to create one.");
return Ok(DocsRunResult::default());
}
let current_checksum = calculate_sha256(&override_path)?;
if force {
println!("🔄 Force re-caching OVERRIDE.md checksum...");
cache_checksum(&repo_root, ¤t_checksum)?;
println!("✓ Checksum cached: {}", current_checksum);
return Ok(DocsRunResult::default());
}
let cached = get_cached_checksum(&repo_root);
match cached {
Some(cached_checksum) if cached_checksum == current_checksum => {
println!("✓ OVERRIDE.md unchanged");
println!(" Cached checksum: {}", cached_checksum);
}
Some(cached_checksum) => {
println!("📝 OVERRIDE.md has changed");
println!(" Old checksum: {}", cached_checksum);
println!(" New checksum: {}", current_checksum);
cache_checksum(&repo_root, ¤t_checksum)?;
println!("✓ Checksum updated");
}
None => {
println!("📝 First time caching OVERRIDE.md checksum");
println!(" Checksum: {}", current_checksum);
cache_checksum(&repo_root, ¤t_checksum)?;
println!("✓ Checksum cached");
}
}
Ok(DocsRunResult::default())
}
}
}
fn find_repo_root(start_dir: &Path) -> Result<PathBuf, error::DecapodError> {
let override_root = std::env::var("DECAPOD_DEV_OVERRIDE")
.map(PathBuf::from)
.unwrap_or_else(|_| start_dir.to_path_buf());
let mut current_dir = override_root;
loop {
if current_dir.join(".decapod").exists() {
return Ok(current_dir);
}
if !current_dir.pop() {
return Err(error::DecapodError::NotFound(
"'.decapod' directory not found in current or parent directories.".to_string(),
));
}
}
}
fn calculate_sha256(path: &Path) -> Result<String, error::DecapodError> {
let content = std::fs::read(path).map_err(error::DecapodError::IoError)?;
let hash = Sha256::digest(&content);
Ok(format!("{:x}", hash))
}
fn get_cached_checksum(repo_root: &Path) -> Option<String> {
let checksum_path = repo_root
.join(".decapod")
.join("generated")
.join("override.checksum");
std::fs::read_to_string(checksum_path).ok()
}
fn cache_checksum(repo_root: &Path, checksum: &str) -> Result<(), error::DecapodError> {
let checksum_path = repo_root
.join(".decapod")
.join("generated")
.join("override.checksum");
if let Some(parent) = checksum_path.parent() {
std::fs::create_dir_all(parent).map_err(error::DecapodError::IoError)?;
}
std::fs::write(checksum_path, checksum).map_err(error::DecapodError::IoError)
}
pub fn schema() -> serde_json::Value {
serde_json::json!({
"name": "docs",
"type": "object",
"properties": {
"list": {
"type": "null",
"description": "List all embedded Decapod methodology documents"
},
"show": {
"type": "string",
"description": "Display a specific embedded document"
},
"ingest": {
"type": "null",
"description": "Dump all embedded constitution for agentic ingestion"
},
"search": {
"type": "object",
"description": "Return scoped constitution fragments for a problem query",
"properties": {
"query": { "type": "string" },
"op": { "type": "string" },
"path": { "type": "array", "items": { "type": "string" } },
"tag": { "type": "array", "items": { "type": "string" } },
"limit": { "type": "integer" },
"format": { "type": "string", "enum": ["text", "json"] }
}
},
"override": {
"type": "object",
"description": "Validate and cache OVERRIDE.md checksum",
"properties": {
"force": {
"type": "boolean",
"description": "Force re-cache even if unchanged"
}
}
}
}
})
}