speccoder_runtime/
context.rs1use serde::Serialize;
8use speccoder_core::snapshot::ProjectSnapshot;
9
10pub const PART_LIMIT: usize = 32 * 1024;
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
15pub struct Bundle {
16 pub feature: String,
18 pub constitution: String,
20 pub plan: String,
22 pub story: String,
25 pub task: TaskContext,
27 pub dependencies: Vec<DependencyContext>,
29 #[serde(skip_serializing_if = "Vec::is_empty")]
32 pub story_tasks: Vec<DependencyContext>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
37pub struct TaskContext {
38 pub id: String,
40 pub title: String,
42 pub phase: String,
44 pub subsection: String,
46 pub story: String,
48 pub parallel: bool,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
54pub struct DependencyContext {
55 pub id: String,
57 pub title: String,
59 pub done: bool,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum BundleError {
66 NoFeature,
68 NoTasks,
70 NotFound(String),
72}
73
74pub fn bundle(snapshot: &ProjectSnapshot, task_id: &str) -> Result<Bundle, BundleError> {
77 crate::format::reference().bundle(snapshot, task_id)
78}
79
80pub(crate) fn cap(text: &str) -> String {
82 if text.len() <= PART_LIMIT {
83 return text.to_owned();
84 }
85 let mut end = PART_LIMIT;
86 while !text.is_char_boundary(end) {
87 end -= 1;
88 }
89 format!(
90 "{}\n[… cut at {PART_LIMIT} bytes of {} …]",
91 &text[..end],
92 text.len()
93 )
94}
95
96#[derive(Debug, Default, Clone)]
99pub struct Manifest {
100 entries: Vec<speccoder_core::context::ContextEntry>,
101}
102
103impl Manifest {
104 #[must_use]
106 pub fn new() -> Self {
107 Self::default()
108 }
109
110 pub fn push(
113 &mut self,
114 kind: speccoder_core::context::ContextKind,
115 source: &str,
116 reference: &str,
117 bytes: &[u8],
118 ) -> &mut Self {
119 if bytes.is_empty() {
120 return self;
121 }
122 self.entries.push(speccoder_core::context::ContextEntry {
123 kind,
124 source: source.to_owned(),
125 reference: reference.to_owned(),
126 sha256: crate::integrity::sha256_hex(bytes),
127 bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX),
128 cut: bytes.len() > PART_LIMIT,
129 });
130 self
131 }
132
133 #[must_use]
135 pub fn finish(&self) -> speccoder_core::context::ContextManifest {
136 speccoder_core::context::ContextManifest::of(
137 self.entries.clone(),
138 crate::integrity::sha256_hex,
139 )
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn a_manifest_recomputes_from_what_it_lists() {
149 use speccoder_core::context::ContextKind;
150 let mut m = Manifest::new();
151 m.push(ContextKind::Issue, "github:o/r#42", "#42", b"title\nbody")
152 .push(ContextKind::Story, "person", "/triad", b"C\tx\n")
153 .push(ContextKind::Plan, "worktree", "/bundle/plan", b"");
154 let done = m.finish();
155 assert_eq!(done.entries.len(), 2, "an empty part is not listed");
156 let again = speccoder_core::context::ContextManifest::of(
157 done.entries.clone(),
158 crate::integrity::sha256_hex,
159 );
160 assert_eq!(again.digest, done.digest);
161 assert_eq!(
162 done.entries[0].sha256,
163 crate::integrity::sha256_hex(b"C\tx\n")
164 );
165 }
166
167 #[test]
168 fn cap_marks_the_cut() {
169 let long = "é".repeat(PART_LIMIT);
170 let capped = cap(&long);
171 assert!(capped.len() < long.len());
172 assert!(
173 capped.contains("[… cut at 32768 bytes of 65536 …]"),
174 "{}",
175 &capped[capped.len() - 40..]
176 );
177 }
178}