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}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33pub struct TaskContext {
34 pub id: String,
36 pub title: String,
38 pub phase: String,
40 pub subsection: String,
42 pub story: String,
44 pub parallel: bool,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
50pub struct DependencyContext {
51 pub id: String,
53 pub title: String,
55 pub done: bool,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum BundleError {
62 NoFeature,
64 NoTasks,
66 NotFound(String),
68}
69
70pub fn bundle(snapshot: &ProjectSnapshot, task_id: &str) -> Result<Bundle, BundleError> {
73 crate::format::reference().bundle(snapshot, task_id)
74}
75
76pub(crate) fn cap(text: &str) -> String {
78 if text.len() <= PART_LIMIT {
79 return text.to_owned();
80 }
81 let mut end = PART_LIMIT;
82 while !text.is_char_boundary(end) {
83 end -= 1;
84 }
85 format!(
86 "{}\n[… cut at {PART_LIMIT} bytes of {} …]",
87 &text[..end],
88 text.len()
89 )
90}
91
92#[derive(Debug, Default, Clone)]
95pub struct Manifest {
96 entries: Vec<speccoder_core::context::ContextEntry>,
97}
98
99impl Manifest {
100 #[must_use]
102 pub fn new() -> Self {
103 Self::default()
104 }
105
106 pub fn push(
109 &mut self,
110 kind: speccoder_core::context::ContextKind,
111 source: &str,
112 reference: &str,
113 bytes: &[u8],
114 ) -> &mut Self {
115 if bytes.is_empty() {
116 return self;
117 }
118 self.entries.push(speccoder_core::context::ContextEntry {
119 kind,
120 source: source.to_owned(),
121 reference: reference.to_owned(),
122 sha256: crate::integrity::sha256_hex(bytes),
123 bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX),
124 cut: bytes.len() > PART_LIMIT,
125 });
126 self
127 }
128
129 #[must_use]
131 pub fn finish(&self) -> speccoder_core::context::ContextManifest {
132 speccoder_core::context::ContextManifest::of(
133 self.entries.clone(),
134 crate::integrity::sha256_hex,
135 )
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn a_manifest_recomputes_from_what_it_lists() {
145 use speccoder_core::context::ContextKind;
146 let mut m = Manifest::new();
147 m.push(ContextKind::Issue, "github:o/r#42", "#42", b"title\nbody")
148 .push(ContextKind::Story, "person", "/triad", b"C\tx\n")
149 .push(ContextKind::Plan, "worktree", "/bundle/plan", b"");
150 let done = m.finish();
151 assert_eq!(done.entries.len(), 2, "an empty part is not listed");
152 let again = speccoder_core::context::ContextManifest::of(
153 done.entries.clone(),
154 crate::integrity::sha256_hex,
155 );
156 assert_eq!(again.digest, done.digest);
157 assert_eq!(
158 done.entries[0].sha256,
159 crate::integrity::sha256_hex(b"C\tx\n")
160 );
161 }
162
163 #[test]
164 fn cap_marks_the_cut() {
165 let long = "é".repeat(PART_LIMIT);
166 let capped = cap(&long);
167 assert!(capped.len() < long.len());
168 assert!(
169 capped.contains("[… cut at 32768 bytes of 65536 …]"),
170 "{}",
171 &capped[capped.len() - 40..]
172 );
173 }
174}