Skip to main content

kcode_k1_chat_thread_web_code_open_targets/
lib.rs

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}