1use std::path::Path;
6
7use serde::Deserialize;
8use serde_json::{Value, json};
9use speccoder_core::CommandData;
10use speccoder_core::ask::Chip;
11
12pub const MAX: usize = 8;
14
15const CAP: usize = 24_000;
17
18#[derive(Debug, Clone, Deserialize, serde::Serialize, PartialEq, Eq)]
20pub struct ChipRef {
21 pub kind: String,
23 #[serde(default)]
25 pub reference: Value,
26 #[serde(default)]
28 pub label: Option<String>,
29 #[serde(default)]
31 pub text: Option<String>,
32 #[serde(default)]
34 pub digest: Option<String>,
35 #[serde(default)]
37 pub from_view: bool,
38}
39
40pub fn resolve(root: &Path, id: &str, r: &ChipRef) -> Result<(Chip, String), String> {
46 let (label, content) = match r.kind.as_str() {
47 "feature" => feature(root, r.reference["feature"].as_str())?,
48 "task" => task(
49 root,
50 r.reference["feature"].as_str(),
51 r.reference["task"]
52 .as_str()
53 .ok_or("a task chip names its task")?,
54 )?,
55 "file_range" => file(root, &r.reference)?,
56 _ => {
57 let text = r
58 .text
59 .clone()
60 .filter(|t| !t.trim().is_empty())
61 .ok_or("this chip carries no words")?;
62 (
63 r.label.clone().unwrap_or_else(|| r.kind.replace('_', " ")),
64 text,
65 )
66 }
67 };
68 let content: String = content.chars().take(CAP).collect();
69 let digest = crate::integrity::sha256_hex(content.as_bytes());
70 let chip = Chip {
71 id: id.to_owned(),
72 kind: r.kind.clone(),
73 label,
74 reference: r.reference.clone(),
75 changed: r.digest.as_deref().is_some_and(|d| d != digest),
76 digest,
77 from_view: r.from_view,
78 };
79 Ok((chip, content))
80}
81
82#[must_use]
84pub fn message(text: &str, chips: &[(Chip, String)]) -> String {
85 let mut out = text.trim().to_owned();
86 for (chip, content) in chips {
87 let changed = if chip.changed {
88 " (changed since it was added)"
89 } else {
90 ""
91 };
92 out.push_str(&format!(
93 "\n\n<context id=\"{}\" kind=\"{}\" label=\"{}\"{changed}>\n{content}\n</context>",
94 chip.id,
95 chip.kind,
96 chip.label.replace('"', "'"),
97 ));
98 }
99 out
100}
101
102fn report(root: &Path, feature: Option<&str>) -> Result<Box<speccoder_core::StatusReport>, String> {
103 let report = crate::status::report(root, feature);
104 match report.data() {
105 Some(CommandData::Report(r)) => Ok(r.clone()),
106 _ => Err(report.message().to_owned()),
107 }
108}
109
110fn feature(root: &Path, feature: Option<&str>) -> Result<(String, String), String> {
111 let r = report(root, feature)?;
112 let tasks: Vec<String> = r
113 .tasks
114 .iter()
115 .map(|t| format!("- {} [{}] {}", t.id, t.state, t.title))
116 .collect();
117 Ok((
118 r.feature.clone(),
119 format!("Feature {}\n\nTasks:\n{}", r.feature, tasks.join("\n")),
120 ))
121}
122
123fn task(root: &Path, feature: Option<&str>, id: &str) -> Result<(String, String), String> {
124 let r = report(root, feature)?;
125 let t = r
126 .tasks
127 .iter()
128 .find(|t| t.id == id)
129 .ok_or_else(|| format!("no task {id} in {}", r.feature))?;
130 let statements = t
131 .commit
132 .as_deref()
133 .map(|c| crate::evidence::statements(root, c))
134 .unwrap_or_default();
135 let rows = crate::evidence::rows(t, &statements, None);
136 Ok((
137 format!("{} {}", t.id, t.title),
138 json!({ "feature": r.feature, "task": t.id, "title": t.title, "phase": t.phase, "state": t.state, "evidence": rows })
139 .to_string(),
140 ))
141}
142
143fn file(root: &Path, reference: &Value) -> Result<(String, String), String> {
144 let path = reference["path"]
145 .as_str()
146 .ok_or("a file chip names its path")?;
147 let rel = crate::tools::paths::normalise(path).map_err(|e| e.0)?;
148 let root = std::fs::canonicalize(root).map_err(|e| e.to_string())?;
151 let full = crate::tools::paths::resolve_existing(&root, &rel).map_err(|e| e.0)?;
152 let target = full
153 .strip_prefix(&root)
154 .map_or_else(|_| rel.clone(), |p| p.to_string_lossy().into_owned());
155 let policy = crate::tools::policy::Policy::new(&[], &[]);
156 if policy.protected().is_secret(&rel) || policy.protected().is_secret(&target) {
157 return Err(format!("`{rel}` looks like a credential"));
158 }
159 let text = std::fs::read_to_string(&full).map_err(|e| format!("`{rel}`: {e}"))?;
160 let lines: Vec<&str> = text.lines().collect();
161 let line = |k: &str| reference[k].as_u64().and_then(|n| usize::try_from(n).ok());
162 let start = line("start").unwrap_or(1).max(1);
163 let end = line("end").unwrap_or(lines.len()).min(lines.len());
164 if start > end {
165 return Err(format!("`{rel}` has no lines {start}–{end}"));
166 }
167 let numbered: Vec<String> = (start..=end)
168 .map(|n| format!("{n}: {}", lines[n - 1]))
169 .collect();
170 let label = if start == 1 && end == lines.len() {
171 rel.clone()
172 } else {
173 format!("{rel} {start}–{end}")
174 };
175 Ok((label, crate::secrets::mask(&numbered.join("\n"))))
176}