1#![forbid(unsafe_code)]
2#![doc = include_str!("../Documentation.md")]
3
4use kcode_k1_chat_boxes::{BoxId, ChatBox, ToolCallId};
5use kcode_k1_chat_web_code_output::{WebCodeOutput, create, open};
6use kcode_k1_web_code_document::{CodeDocument, Language};
7use std::fmt::{Display, Formatter};
8
9pub const NON_TEXT_SENTINEL: &str = "this file is a non-text object and cannot be modified";
10
11#[derive(Clone, Debug, Eq, PartialEq)]
12struct FileBinding {
13 originating_call: BoxId,
14 tool_call_id: ToolCallId,
15 message_index: u64,
16 path: String,
17 is_text: bool,
18 current: bool,
19}
20
21#[derive(Clone, Debug, Eq, PartialEq)]
22pub struct SourceLayout {
23 bindings: Vec<FileBinding>,
24}
25
26impl SourceLayout {
27 pub fn originating_call(&self) -> Option<BoxId> {
28 self.bindings
29 .first()
30 .map(|binding| binding.originating_call)
31 }
32
33 pub fn tool_call_id(&self) -> Option<ToolCallId> {
34 self.bindings.first().map(|binding| binding.tool_call_id)
35 }
36
37 pub fn file_count(&self) -> usize {
38 self.bindings.len()
39 }
40
41 pub fn record_file(
42 &mut self,
43 originating_call: BoxId,
44 tool_call_id: ToolCallId,
45 path: String,
46 is_text: bool,
47 ) -> Result<(), String> {
48 if self
49 .bindings
50 .iter()
51 .any(|binding| binding.current && binding.path == path)
52 {
53 return Err("source layout already contains the current path".to_owned());
54 }
55 self.bindings.push(FileBinding {
56 originating_call,
57 tool_call_id,
58 message_index: 1,
59 path,
60 is_text,
61 current: true,
62 });
63 Ok(())
64 }
65
66 pub fn retire_file(&mut self, path: &str) -> Result<(), String> {
67 let binding = self
68 .bindings
69 .iter_mut()
70 .rev()
71 .find(|binding| binding.current && binding.path == path)
72 .ok_or_else(|| "source layout does not contain the current path".to_owned())?;
73 binding.current = false;
74 Ok(())
75 }
76}
77
78pub type OpenLayout = SourceLayout;
79
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct SourcePresentation {
82 pub output: WebCodeOutput,
83 pub layout: SourceLayout,
84}
85
86pub type OpenPresentation = SourcePresentation;
87
88pub fn present_create(
89 originating_call: BoxId,
90 tool_call_id: ToolCallId,
91 document: &CodeDocument,
92 version: &str,
93) -> SourcePresentation {
94 present(originating_call, tool_call_id, document, version, true)
95}
96
97pub fn present_open(
98 originating_call: BoxId,
99 tool_call_id: ToolCallId,
100 document: &CodeDocument,
101 version: &str,
102) -> SourcePresentation {
103 present(originating_call, tool_call_id, document, version, false)
104}
105
106fn present(
107 originating_call: BoxId,
108 tool_call_id: ToolCallId,
109 document: &CodeDocument,
110 version: &str,
111 creating: bool,
112) -> SourcePresentation {
113 let mut messages = Vec::with_capacity(document.files().len());
114 let mut paths = Vec::with_capacity(document.files().len());
115 let mut bindings = Vec::with_capacity(document.files().len());
116
117 for (offset, file) in document.files().iter().enumerate() {
118 let message_index = u64::try_from(offset + 1).expect("file message index fits u64");
119 let is_text = std::str::from_utf8(file.bytes()).is_ok();
120 messages.push(file_message(file.bytes()));
121 paths.push(file.path().to_owned());
122 bindings.push(FileBinding {
123 originating_call,
124 tool_call_id,
125 message_index,
126 path: file.path().to_owned(),
127 is_text,
128 current: true,
129 });
130 }
131
132 let name = document.family().logical_name();
133 let language = presentation_language(document);
134 let result = if creating {
135 Ok(format!("Created {version}."))
136 } else {
137 Ok(format!("Opened {version}."))
138 };
139 let output = if creating {
140 create(
141 tool_call_id,
142 messages,
143 result,
144 name,
145 version,
146 language,
147 &paths,
148 )
149 } else {
150 open(
151 tool_call_id,
152 messages,
153 result,
154 name,
155 version,
156 language,
157 &paths,
158 )
159 };
160
161 SourcePresentation {
162 output,
163 layout: SourceLayout { bindings },
164 }
165}
166
167fn presentation_language(document: &CodeDocument) -> Language {
168 let javascript = document.file("index.js").is_some();
169 let html = document.file("index.html").is_some();
170 let css = document.file("index.css").is_some();
171 match (javascript, html, css) {
172 (false, true, false) => Language::Html,
173 (false, false, true) => Language::Css,
174 _ => Language::JavaScript,
175 }
176}
177
178pub fn file_message(bytes: &[u8]) -> String {
179 std::str::from_utf8(bytes)
180 .map(str::to_owned)
181 .unwrap_or_else(|_| NON_TEXT_SENTINEL.to_owned())
182}
183
184#[derive(Clone, Debug, Eq, PartialEq)]
185pub struct FileTarget {
186 path: String,
187 is_text: bool,
188}
189
190impl FileTarget {
191 pub fn path(&self) -> &str {
192 &self.path
193 }
194
195 pub const fn is_text(&self) -> bool {
196 self.is_text
197 }
198}
199
200#[derive(Clone, Copy, Debug, Eq, PartialEq)]
201pub enum TargetRejection {
202 HistoryAbsent,
203 HistoryDuplicate,
204 MalformedPayload,
205 DifferentOpen,
206 MessageOutOfLayout,
207 NotSourceMessage,
208 RetiredFile,
209}
210
211impl TargetRejection {
212 pub const fn code(self) -> &'static str {
213 match self {
214 Self::HistoryAbsent => "history_absent",
215 Self::HistoryDuplicate => "history_duplicate",
216 Self::MalformedPayload => "malformed_payload",
217 Self::DifferentOpen => "different_open",
218 Self::MessageOutOfLayout => "message_out_of_layout",
219 Self::NotSourceMessage => "not_source_message",
220 Self::RetiredFile => "retired_file",
221 }
222 }
223}
224
225impl Display for TargetRejection {
226 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
227 formatter.write_str(match self {
228 Self::HistoryAbsent => "target box is absent from history",
229 Self::HistoryDuplicate => "target box ID occurs more than once in history",
230 Self::MalformedPayload => "target has malformed tool metadata",
231 Self::DifferentOpen => "target belongs to a different source generation",
232 Self::MessageOutOfLayout => "target message index is outside the source layout",
233 Self::NotSourceMessage => "target is not a source file message",
234 Self::RetiredFile => "target no longer represents a current file",
235 })
236 }
237}
238
239impl std::error::Error for TargetRejection {}
240
241pub fn classify_anchor(
242 history: &[ChatBox],
243 target_id: BoxId,
244 layout: &SourceLayout,
245) -> Result<FileTarget, TargetRejection> {
246 classify(history, target_id, layout, false)
247}
248
249pub fn classify_file_target(
250 history: &[ChatBox],
251 target_id: BoxId,
252 layout: &SourceLayout,
253) -> Result<FileTarget, TargetRejection> {
254 classify(history, target_id, layout, true)
255}
256
257fn classify(
258 history: &[ChatBox],
259 target_id: BoxId,
260 layout: &SourceLayout,
261 require_current: bool,
262) -> Result<FileTarget, TargetRejection> {
263 let mut matching = history.iter().filter(|value| value.id() == target_id);
264 let target = matching.next().ok_or(TargetRejection::HistoryAbsent)?;
265 if matching.next().is_some() {
266 return Err(TargetRejection::HistoryDuplicate);
267 }
268 let message = target
269 .tool_message_metadata()
270 .map_err(|_| TargetRejection::MalformedPayload)?
271 .ok_or(TargetRejection::NotSourceMessage)?;
272
273 let same_call = layout.bindings.iter().any(|binding| {
274 binding.tool_call_id == message.tool_call_id
275 && binding.originating_call == message.originating_call
276 });
277 if !same_call {
278 return Err(TargetRejection::DifferentOpen);
279 }
280 let binding = layout
281 .bindings
282 .iter()
283 .find(|binding| {
284 binding.tool_call_id == message.tool_call_id
285 && binding.originating_call == message.originating_call
286 && binding.message_index == message.message_index
287 })
288 .ok_or(TargetRejection::MessageOutOfLayout)?;
289 if require_current && !binding.current {
290 return Err(TargetRejection::RetiredFile);
291 }
292 Ok(FileTarget {
293 path: binding.path.clone(),
294 is_text: binding.is_text,
295 })
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use kcode_k1_chat_boxes::{ToolMessageMetadata, named_tool_message_box};
302 use kcode_k1_transaction_id::TxId;
303 use kcode_k1_web_package::{AuthorityId, SourceFile, WebFamily};
304
305 fn call(byte: u8) -> ToolCallId {
306 ToolCallId::new([byte; 12], 4)
307 }
308
309 fn document() -> CodeDocument {
310 CodeDocument::new(
311 WebFamily::new(AuthorityId::new(TxId::from_bytes([1; 12])), "alpha").unwrap(),
312 vec![
313 SourceFile::new("z/binary.dat", vec![0xff, 0x00]),
314 SourceFile::new("Documentation.md", b"Docs\r\n".to_vec()),
315 SourceFile::new("src/a.odd", b"export const value = 1;\n".to_vec()),
316 ],
317 )
318 .unwrap()
319 }
320
321 fn assign(id: u64, value: ChatBox) -> ChatBox {
322 ChatBox::new(
323 BoxId::new(id),
324 value.box_type().to_owned(),
325 value.contents().to_owned(),
326 value.hidden_type().to_owned(),
327 value.hidden_contents().to_owned(),
328 )
329 }
330
331 fn message(
332 id: u64,
333 originating_call: BoxId,
334 tool_call_id: ToolCallId,
335 index: u64,
336 title: &str,
337 body: &str,
338 ) -> ChatBox {
339 assign(
340 id,
341 named_tool_message_box(
342 &ToolMessageMetadata {
343 tool_call_id,
344 originating_call,
345 message_index: index,
346 message: body.to_owned(),
347 },
348 title,
349 )
350 .unwrap(),
351 )
352 }
353
354 #[test]
355 fn complete_files_are_sorted_named_and_binary_safe() {
356 let opened = present_open(BoxId::new(7), call(2), &document(), "1.2.3");
357 assert_eq!(
358 opened.output.message_titles,
359 [
360 Some("File: Documentation.md".to_owned()),
361 Some("File: src/a.odd".to_owned()),
362 Some("File: z/binary.dat".to_owned()),
363 ]
364 );
365 assert_eq!(opened.output.messages[0], "Docs\r\n");
366 assert_eq!(opened.output.messages[1], "export const value = 1;\n");
367 assert_eq!(opened.output.messages[2], NON_TEXT_SENTINEL);
368 assert_eq!(opened.layout.file_count(), 3);
369 }
370
371 #[test]
372 fn retired_and_incremental_messages_keep_anchor_semantics() {
373 let mut layout = present_create(BoxId::new(7), call(2), &document(), "draft").layout;
374 let docs = message(
375 10,
376 BoxId::new(7),
377 call(2),
378 1,
379 "File: Documentation.md",
380 "Docs\r\n",
381 );
382 let binary = message(
383 11,
384 BoxId::new(7),
385 call(2),
386 3,
387 "File: z/binary.dat",
388 NON_TEXT_SENTINEL,
389 );
390 layout.retire_file("z/binary.dat").unwrap();
391 assert_eq!(
392 classify_anchor(std::slice::from_ref(&binary), BoxId::new(11), &layout,)
393 .unwrap()
394 .path(),
395 "z/binary.dat"
396 );
397 assert_eq!(
398 classify_file_target(std::slice::from_ref(&binary), BoxId::new(11), &layout,),
399 Err(TargetRejection::RetiredFile)
400 );
401 assert!(
402 classify_file_target(std::slice::from_ref(&docs), BoxId::new(10), &layout,)
403 .unwrap()
404 .is_text()
405 );
406
407 layout
408 .record_file(BoxId::new(20), call(3), "new/file.txt".to_owned(), true)
409 .unwrap();
410 let added = message(21, BoxId::new(20), call(3), 1, "File: new/file.txt", "new");
411 assert_eq!(
412 classify_file_target(std::slice::from_ref(&added), BoxId::new(21), &layout,)
413 .unwrap()
414 .path(),
415 "new/file.txt"
416 );
417 }
418}