use crate::cli::resolve::resolve_shorthand_or_path;
use crate::engine::certification::compute_blob_hash_for_spec;
use crate::engine::config::Config;
use crate::engine::document::DocMeta;
use crate::engine::refs::{parse_refs, Ref};
use crate::engine::store::{ResolveError, Store};
use anyhow::{Context, Result};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct PinnedRef {
pub target: String,
pub hash: String,
}
#[derive(Debug, Clone)]
pub struct PinError {
pub target: String,
pub message: String,
}
#[derive(Debug)]
pub struct PinResult {
pub pinned: Vec<PinnedRef>,
pub errors: Vec<PinError>,
pub new_body: String,
}
fn ref_target(r: &Ref) -> String {
match &r.symbol {
Some(sym) => format!("{}#{}", r.path, sym),
None => r.path.clone(),
}
}
pub fn pin_document(root: &Path, config: &Config, spec_path: &str, body: &str) -> PinResult {
let refs = parse_refs(body);
let mut pinned = Vec::new();
let mut errors = Vec::new();
let mut replacements: Vec<(usize, usize, String)> = Vec::new();
for r in &refs {
let target = ref_target(r);
match compute_blob_hash_for_spec(root, config, spec_path, &r.path, r.symbol.as_deref()) {
Ok(hash) => {
let new_ref = match &r.symbol {
Some(sym) => format!("@ref {}#{}@{{blob:{}}}", r.path, sym, hash),
None => format!("@ref {}@{{blob:{}}}", r.path, hash),
};
replacements.push((r.span.0, r.span.1, new_ref));
pinned.push(PinnedRef { target, hash });
}
Err(e) => {
errors.push(PinError {
target,
message: format!("{:#}", e),
});
}
}
}
let mut new_body = body.to_string();
for (start, end, replacement) in replacements.into_iter().rev() {
new_body.replace_range(start..end, &replacement);
}
PinResult {
pinned,
errors,
new_body,
}
}
pub fn run(store: &Store, config: &Config, id: &str, json: bool) -> Result<()> {
let doc = match resolve_shorthand_or_path(store, id) {
Ok(doc) => doc,
Err(ResolveError::Ambiguous { id, matches }) => {
if json {
let paths: Vec<String> = matches
.iter()
.map(|m| m.to_string_lossy().to_string())
.collect();
let output = serde_json::json!({
"error": "ambiguous_id",
"id": id,
"ambiguous_matches": paths,
});
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
eprintln!("Ambiguous ID '{}' matches multiple documents:", id);
for m in &matches {
eprintln!(" {}", m.display());
}
}
return Ok(());
}
Err(ResolveError::NotFound(id)) => {
return Err(anyhow::anyhow!("document not found: {}", id));
}
};
let root = store.root();
let full_path = root.join(&doc.path);
let spec_path = doc.path.to_string_lossy();
let content = std::fs::read_to_string(&full_path)
.with_context(|| format!("failed to read {}", full_path.display()))?;
let body = DocMeta::extract_body(&content)
.with_context(|| format!("failed to parse frontmatter in {}", full_path.display()))?;
let result = pin_document(root, config, &spec_path, &body);
if !result.pinned.is_empty() {
let frontmatter_end = find_body_start(&content)?;
let prefix = &content[..frontmatter_end];
let new_content = format!("{}{}", prefix, result.new_body);
std::fs::write(&full_path, new_content)
.with_context(|| format!("failed to write {}", full_path.display()))?;
}
if json {
let output = serde_json::json!({
"pinned": result.pinned.iter().map(|p| serde_json::json!({
"target": p.target,
"hash": p.hash,
})).collect::<Vec<_>>(),
"errors": result.errors.iter().map(|e| serde_json::json!({
"target": e.target,
"message": e.message,
})).collect::<Vec<_>>(),
});
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
let pinned_count = result.pinned.len();
let error_count = result.errors.len();
if pinned_count > 0 || error_count > 0 {
eprintln!(
"Pinned {} ref{}, {} error{}",
pinned_count,
if pinned_count == 1 { "" } else { "s" },
error_count,
if error_count == 1 { "" } else { "s" },
);
} else {
eprintln!("No @ref directives found");
}
for e in &result.errors {
eprintln!(" error: {}: {}", e.target, e.message);
}
}
Ok(())
}
fn find_body_start(content: &str) -> Result<usize> {
let trimmed = content.trim_start();
let leading_ws = content.len() - trimmed.len();
if !trimmed.starts_with("---") {
anyhow::bail!("no frontmatter found");
}
let after_first = &trimmed[3..];
let end = after_first
.find("\n---")
.ok_or_else(|| anyhow::anyhow!("no closing frontmatter delimiter"))?;
let close_pos = leading_ws + 3 + end + 4; let remainder = &content[close_pos..];
let trimmed_start = remainder.len() - remainder.trim_start_matches('\n').len();
Ok(close_pos + trimmed_start)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::config::Config;
use crate::engine::hashing;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn setup_git_repo() -> TempDir {
let dir = TempDir::new().unwrap();
let path = dir.path();
Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(path)
.output()
.unwrap();
dir
}
#[test]
fn test_pin_writes_hash_for_file_ref() {
let dir = setup_git_repo();
let root = dir.path();
let config = Config::default();
let file_content = "hello world\n";
fs::write(root.join("hello.txt"), file_content).unwrap();
let body = "Some text\n\n@ref hello.txt\n\nMore text\n";
let result = pin_document(root, &config, "docs/specs/SPEC-001", body);
assert_eq!(result.pinned.len(), 1);
assert_eq!(result.errors.len(), 0);
assert_eq!(result.pinned[0].target, "hello.txt");
let expected_hash = hashing::hash_file(&root.join("hello.txt")).unwrap();
assert_eq!(result.pinned[0].hash, expected_hash);
let expected_ref = format!("@ref hello.txt@{{blob:{}}}", expected_hash);
assert!(
result.new_body.contains(&expected_ref),
"Expected body to contain '{}', got: {}",
expected_ref,
result.new_body
);
}
#[test]
fn test_pin_writes_hash_for_symbol_ref() {
let dir = setup_git_repo();
let root = dir.path();
let config = Config::default();
let rust_source = "pub struct MyStruct {\n pub field: i32,\n}\n";
fs::write(root.join("foo.rs"), rust_source).unwrap();
let body = "Spec body\n\n@ref foo.rs#MyStruct\n";
let result = pin_document(root, &config, "docs/specs/SPEC-001", body);
assert_eq!(result.pinned.len(), 1);
assert_eq!(result.errors.len(), 0);
assert_eq!(result.pinned[0].target, "foo.rs#MyStruct");
assert_eq!(result.pinned[0].hash.len(), 40);
let expected_ref = format!("@ref foo.rs#MyStruct@{{blob:{}}}", result.pinned[0].hash);
assert!(
result.new_body.contains(&expected_ref),
"Expected body to contain '{}', got: {}",
expected_ref,
result.new_body
);
}
#[test]
fn test_pin_updates_existing_hash() {
let dir = setup_git_repo();
let root = dir.path();
let config = Config::default();
let file_content = "some content\n";
fs::write(root.join("data.txt"), file_content).unwrap();
let body = "Text before\n\n@ref data.txt@{blob:aabb0011}\n\nText after\n";
let result = pin_document(root, &config, "docs/specs/SPEC-001", body);
assert_eq!(result.pinned.len(), 1);
assert_eq!(result.errors.len(), 0);
let fresh_hash = hashing::hash_file(&root.join("data.txt")).unwrap();
assert_eq!(result.pinned[0].hash, fresh_hash);
assert_ne!(fresh_hash, "aabb0011");
assert!(!result.new_body.contains("aabb0011"));
let expected_ref = format!("@ref data.txt@{{blob:{}}}", fresh_hash);
assert!(result.new_body.contains(&expected_ref));
}
#[test]
fn test_pin_errors_on_nonexistent_file() {
let dir = setup_git_repo();
let root = dir.path();
let config = Config::default();
let body = "See @ref nonexistent.rs for details\n";
let result = pin_document(root, &config, "docs/specs/SPEC-001", body);
assert_eq!(result.pinned.len(), 0);
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].target, "nonexistent.rs");
assert!(result.new_body.contains("@ref nonexistent.rs"));
}
#[test]
fn test_pin_errors_on_nonexistent_symbol() {
let dir = setup_git_repo();
let root = dir.path();
let config = Config::default();
let rust_source = "pub fn real_fn() {}\n";
fs::write(root.join("real_file.rs"), rust_source).unwrap();
let body = "See @ref real_file.rs#NoSuchSymbol\n";
let result = pin_document(root, &config, "docs/specs/SPEC-001", body);
assert_eq!(result.pinned.len(), 0);
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].target, "real_file.rs#NoSuchSymbol");
assert!(result.new_body.contains("@ref real_file.rs#NoSuchSymbol"));
}
#[test]
fn test_pin_mixed_valid_and_invalid() {
let dir = setup_git_repo();
let root = dir.path();
let config = Config::default();
let file_content = "valid content\n";
fs::write(root.join("valid.txt"), file_content).unwrap();
let body = "First: @ref valid.txt\nSecond: @ref missing.txt\n";
let result = pin_document(root, &config, "docs/specs/SPEC-001", body);
assert_eq!(result.pinned.len(), 1);
assert_eq!(result.errors.len(), 1);
assert_eq!(result.pinned[0].target, "valid.txt");
assert_eq!(result.errors[0].target, "missing.txt");
let expected_hash = hashing::hash_file(&root.join("valid.txt")).unwrap();
let expected_ref = format!("@ref valid.txt@{{blob:{}}}", expected_hash);
assert!(result.new_body.contains(&expected_ref));
assert!(result.new_body.contains("@ref missing.txt"));
}
}