use std::path::Path;
use serde::Deserialize;
use serde_json::{Value, json};
use speccoder_core::CommandData;
use speccoder_core::ask::Chip;
pub const MAX: usize = 8;
const CAP: usize = 24_000;
#[derive(Debug, Clone, Deserialize, serde::Serialize, PartialEq, Eq)]
pub struct ChipRef {
pub kind: String,
#[serde(default)]
pub reference: Value,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub digest: Option<String>,
#[serde(default)]
pub from_view: bool,
}
pub fn resolve(root: &Path, id: &str, r: &ChipRef) -> Result<(Chip, String), String> {
let (label, content) = match r.kind.as_str() {
"feature" => feature(root, r.reference["feature"].as_str())?,
"task" => task(
root,
r.reference["feature"].as_str(),
r.reference["task"]
.as_str()
.ok_or("a task chip names its task")?,
)?,
"file_range" => file(root, &r.reference)?,
_ => {
let text = r
.text
.clone()
.filter(|t| !t.trim().is_empty())
.ok_or("this chip carries no words")?;
(
r.label.clone().unwrap_or_else(|| r.kind.replace('_', " ")),
text,
)
}
};
let content: String = content.chars().take(CAP).collect();
let digest = crate::integrity::sha256_hex(content.as_bytes());
let chip = Chip {
id: id.to_owned(),
kind: r.kind.clone(),
label,
reference: r.reference.clone(),
changed: r.digest.as_deref().is_some_and(|d| d != digest),
digest,
from_view: r.from_view,
};
Ok((chip, content))
}
#[must_use]
pub fn message(text: &str, chips: &[(Chip, String)]) -> String {
let mut out = text.trim().to_owned();
for (chip, content) in chips {
let changed = if chip.changed {
" (changed since it was added)"
} else {
""
};
out.push_str(&format!(
"\n\n<context id=\"{}\" kind=\"{}\" label=\"{}\"{changed}>\n{content}\n</context>",
chip.id,
chip.kind,
chip.label.replace('"', "'"),
));
}
out
}
fn report(root: &Path, feature: Option<&str>) -> Result<Box<speccoder_core::StatusReport>, String> {
let report = crate::status::report(root, feature);
match report.data() {
Some(CommandData::Report(r)) => Ok(r.clone()),
_ => Err(report.message().to_owned()),
}
}
fn feature(root: &Path, feature: Option<&str>) -> Result<(String, String), String> {
let r = report(root, feature)?;
let tasks: Vec<String> = r
.tasks
.iter()
.map(|t| format!("- {} [{}] {}", t.id, t.state, t.title))
.collect();
Ok((
r.feature.clone(),
format!("Feature {}\n\nTasks:\n{}", r.feature, tasks.join("\n")),
))
}
fn task(root: &Path, feature: Option<&str>, id: &str) -> Result<(String, String), String> {
let r = report(root, feature)?;
let t = r
.tasks
.iter()
.find(|t| t.id == id)
.ok_or_else(|| format!("no task {id} in {}", r.feature))?;
let statements = t
.commit
.as_deref()
.map(|c| crate::evidence::statements(root, c))
.unwrap_or_default();
let rows = crate::evidence::rows(t, &statements, None);
Ok((
format!("{} {}", t.id, t.title),
json!({ "feature": r.feature, "task": t.id, "title": t.title, "phase": t.phase, "state": t.state, "evidence": rows })
.to_string(),
))
}
fn file(root: &Path, reference: &Value) -> Result<(String, String), String> {
let path = reference["path"]
.as_str()
.ok_or("a file chip names its path")?;
let rel = crate::tools::paths::normalise(path).map_err(|e| e.0)?;
let root = std::fs::canonicalize(root).map_err(|e| e.to_string())?;
let full = crate::tools::paths::resolve_existing(&root, &rel).map_err(|e| e.0)?;
let target = full
.strip_prefix(&root)
.map_or_else(|_| rel.clone(), |p| p.to_string_lossy().into_owned());
let policy = crate::tools::policy::Policy::new(&[], &[]);
if policy.protected().is_secret(&rel) || policy.protected().is_secret(&target) {
return Err(format!("`{rel}` looks like a credential"));
}
let text = std::fs::read_to_string(&full).map_err(|e| format!("`{rel}`: {e}"))?;
let lines: Vec<&str> = text.lines().collect();
let line = |k: &str| reference[k].as_u64().and_then(|n| usize::try_from(n).ok());
let start = line("start").unwrap_or(1).max(1);
let end = line("end").unwrap_or(lines.len()).min(lines.len());
if start > end {
return Err(format!("`{rel}` has no lines {start}–{end}"));
}
let numbered: Vec<String> = (start..=end)
.map(|n| format!("{n}: {}", lines[n - 1]))
.collect();
let label = if start == 1 && end == lines.len() {
rel.clone()
} else {
format!("{rel} {start}–{end}")
};
Ok((label, crate::secrets::mask(&numbered.join("\n"))))
}