Skip to main content

speccoder_runtime/ask/
chips.rs

1//! Chips: what is on screen, given to Ask as context. The engine resolves
2//! a feature, a task or a file range itself; anything else carries the
3//! words the view shows. Every chip gets a digest, so a changed one says so.
4
5use std::path::Path;
6
7use serde::Deserialize;
8use serde_json::{Value, json};
9use speccoder_core::CommandData;
10use speccoder_core::ask::Chip;
11
12/// At most this many per turn.
13pub const MAX: usize = 8;
14
15/// What a chip's content may be at most.
16const CAP: usize = 24_000;
17
18/// A chip as an interface names it.
19#[derive(Debug, Clone, Deserialize, serde::Serialize, PartialEq, Eq)]
20pub struct ChipRef {
21    /// `feature`, `task`, `file_range`, or another kind with `text`.
22    pub kind: String,
23    /// `{feature}`, `{task, feature?}`, `{path, start?, end?}`.
24    #[serde(default)]
25    pub reference: Value,
26    /// What the chip reads as; the engine's own label wins where it has one.
27    #[serde(default)]
28    pub label: Option<String>,
29    /// The words the view shows, for a kind the engine does not resolve.
30    #[serde(default)]
31    pub text: Option<String>,
32    /// The digest when it was added: a different one now marks it changed.
33    #[serde(default)]
34    pub digest: Option<String>,
35    /// The current view's subject.
36    #[serde(default)]
37    pub from_view: bool,
38}
39
40/// `r`'s content and its chip (numbered `id`).
41///
42/// # Errors
43///
44/// It points at nothing, outside the workspace, or at a credential.
45pub 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/// The message the model reads: the person's words, then each chip.
83#[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    // Resolved as the workspace's own reads are: a link out of the project
149    // is refused, and the credential check is on what it points at.
150    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}