libtenx/dialect/
tags.rs

1//! Defines an interaction style where files are sent to the model in XML-like tags, and model
2//! responses are parsed from similar tags.
3
4use std::path::PathBuf;
5
6use super::{xmlish, DialectProvider};
7use crate::{
8    config::Config,
9    context::ContextProvider,
10    patch::{Change, Patch, Replace, Smart, UDiff, WriteFile},
11    session::{ModelResponse, Operation, Session},
12    Result, TenxError,
13};
14use fs_err as fs;
15
16const SYSTEM: &str = include_str!("./tags-system.txt");
17const SMART: &str = include_str!("./tags-smart.txt");
18const REPLACE: &str = include_str!("./tags-replace.txt");
19const UDIFF: &str = include_str!("./tags-udiff.txt");
20const EDIT: &str = include_str!("./tags-edit.txt");
21
22/// Tenx's primary code generation dialect, which uses XML-ish tags as the basic communication format with models.
23#[derive(Debug, Default, PartialEq, Eq, Clone)]
24pub struct Tags {
25    pub smart: bool,
26    pub replace: bool,
27    pub udiff: bool,
28    pub edit: bool,
29}
30
31impl Tags {
32    pub fn new(smart: bool, replace: bool, udiff: bool, edit: bool) -> Self {
33        Self {
34            smart,
35            replace,
36            udiff,
37            edit,
38        }
39    }
40}
41
42impl DialectProvider for Tags {
43    fn name(&self) -> &'static str {
44        "tags"
45    }
46
47    fn system(&self) -> String {
48        let mut out = SYSTEM.to_string();
49        if self.smart {
50            out.push_str(SMART);
51        }
52        if self.replace {
53            out.push_str(REPLACE);
54        }
55        if self.udiff {
56            out.push_str(UDIFF);
57        }
58        if self.edit {
59            out.push_str(EDIT);
60        }
61        out
62    }
63
64    fn render_context(&self, config: &Config, s: &Session) -> Result<String> {
65        if self.system().is_empty() {
66            return Ok("There is no non-editable context.".into());
67        }
68
69        let mut rendered = String::new();
70        rendered.push_str("<context>\n");
71        for cspec in s.contexts() {
72            for ctx in cspec.context_items(config, s)? {
73                let txt = format!(
74                    "<item name=\"{}\" type=\"{:?}\">\n{}\n</item>\n",
75                    ctx.source, ctx.ty, ctx.body
76                );
77                rendered.push_str(&txt)
78            }
79        }
80        rendered.push_str("</context>");
81        Ok(rendered)
82    }
83
84    fn render_editables(
85        &self,
86        config: &Config,
87        _session: &Session,
88        paths: Vec<PathBuf>,
89    ) -> Result<String> {
90        let mut rendered = String::new();
91        for path in paths {
92            let contents = fs::read_to_string(config.abspath(&path)?)?;
93            rendered.push_str(&format!(
94                "<editable path=\"{}\">\n{}</editable>\n\n",
95                path.display(),
96                contents
97            ));
98        }
99        Ok(rendered)
100    }
101
102    fn render_step_request(
103        &self,
104        _config: &Config,
105        session: &Session,
106        offset: usize,
107    ) -> Result<String> {
108        let prompt = session
109            .steps()
110            .get(offset)
111            .ok_or_else(|| TenxError::Internal("Invalid prompt offset".into()))?;
112        let mut rendered = String::new();
113        rendered.push_str(&format!("\n<prompt>\n{}\n</prompt>\n\n", &prompt.prompt));
114        Ok(rendered)
115    }
116
117    /// Parses a response string containing XML-like tags and returns a `Patch` struct.
118    ///
119    /// The input string should contain one or more of the following tags:
120    ///
121    /// `<write_file>` tag for file content:
122    /// ```xml
123    /// <write_file path="/path/to/file.txt">
124    ///     File content goes here
125    /// </write_file>
126    /// ```
127    ///
128    /// `<replace>` tag for file replace:
129    /// ```xml
130    /// <replace path="/path/to/file.txt">
131    ///     <old>Old content goes here</old>
132    ///     <new>New content goes here</new>
133    /// </replace>
134    /// ```
135    ///
136    /// The function parses these tags and populates an `Patch` struct with
137    /// `WriteFile` entries for `<write_file>` tags and `Replace` entries for `<replace>` tags.
138    /// Whitespace is trimmed from the content of all tags. Any text outside of recognized tags is
139    /// ignored.
140    fn parse(&self, response: &str) -> Result<ModelResponse> {
141        let mut patch = Patch::default();
142        let mut operations = vec![];
143        let mut lines = response.lines().map(String::from).peekable();
144        let mut comment = None;
145
146        while let Some(line) = lines.peek() {
147            if let Some(tag) = xmlish::parse_open(line) {
148                match tag.name.as_str() {
149                    "smart" => {
150                        let path = tag
151                            .attributes
152                            .get("path")
153                            .ok_or_else(|| TenxError::ResponseParse {
154                                user: "Failed to parse model response".into(),
155                                model: format!(
156                                    "Missing path attribute in smart tag. Line: '{}'",
157                                    line
158                                ),
159                            })?
160                            .clone();
161                        let (_, content) = xmlish::parse_block("smart", &mut lines)?;
162                        patch.changes.push(Change::Smart(Smart {
163                            path: path.into(),
164                            text: content.join("\n"),
165                        }));
166                    }
167                    "write_file" => {
168                        let path = tag
169                            .attributes
170                            .get("path")
171                            .ok_or_else(|| TenxError::ResponseParse {
172                                user: "Failed to parse model response".into(),
173                                model: format!(
174                                    "Missing path attribute in write_file tag. Line: '{}'",
175                                    line
176                                ),
177                            })?
178                            .clone();
179                        let (_, content) = xmlish::parse_block("write_file", &mut lines)?;
180                        patch.changes.push(Change::Write(WriteFile {
181                            path: path.into(),
182                            content: content.join("\n"),
183                        }));
184                    }
185                    "replace" => {
186                        let path = tag
187                            .attributes
188                            .get("path")
189                            .ok_or_else(|| TenxError::ResponseParse {
190                                user: "Failed to parse model response".into(),
191                                model: format!(
192                                    "Missing path attribute in replace tag. Line: '{}'",
193                                    line
194                                ),
195                            })?
196                            .clone();
197                        let (_, replace_content) = xmlish::parse_block("replace", &mut lines)?;
198                        let mut replace_lines = replace_content.into_iter().peekable();
199                        let (_, old) = xmlish::parse_block("old", &mut replace_lines)?;
200                        let (_, new) = xmlish::parse_block("new", &mut replace_lines)?;
201                        patch.changes.push(Change::Replace(Replace {
202                            path: path.into(),
203                            old: old.join("\n"),
204                            new: new.join("\n"),
205                        }));
206                    }
207                    "udiff" => {
208                        let (_, content) = xmlish::parse_block("udiff", &mut lines)?;
209                        patch
210                            .changes
211                            .push(Change::UDiff(UDiff::new(content.join("\n"))?));
212                    }
213                    "comment" => {
214                        let (_, content) = xmlish::parse_block("comment", &mut lines)?;
215                        comment = Some(content.join("\n"));
216                    }
217                    "edit" => {
218                        let (_, content) = xmlish::parse_block("edit", &mut lines)?;
219                        for line in content {
220                            let path = line.trim().to_string();
221                            if !path.is_empty() {
222                                operations.push(Operation::Edit(PathBuf::from(path)));
223                            }
224                        }
225                    }
226                    _ => {
227                        lines.next();
228                    }
229                }
230            } else {
231                lines.next();
232            }
233        }
234        Ok(ModelResponse {
235            patch: Some(patch),
236            operations,
237            usage: None,
238            comment,
239            response_text: Some(response.to_string()),
240        })
241    }
242
243    fn render_step_response(
244        &self,
245        _config: &Config,
246        session: &Session,
247        offset: usize,
248    ) -> Result<String> {
249        let step = session
250            .steps()
251            .get(offset)
252            .ok_or_else(|| TenxError::Internal("Invalid step offset".into()))?;
253        if let Some(resp) = &step.model_response {
254            let mut rendered = String::new();
255            if let Some(comment) = &resp.comment {
256                rendered.push_str(&format!("<comment>\n{}\n</comment>\n\n", comment));
257            }
258            for op in &resp.operations {
259                let Operation::Edit(path) = op;
260                rendered.push_str(&format!("<edit>\n{}\n</edit>\n\n", path.display()));
261            }
262            if let Some(patch) = &resp.patch {
263                for change in &patch.changes {
264                    match change {
265                        Change::Write(write_file) => {
266                            rendered.push_str(&format!(
267                                "<write_file path=\"{}\">\n{}\n</write_file>\n\n",
268                                write_file.path.display(),
269                                write_file.content
270                            ));
271                        }
272                        Change::Replace(replace) => {
273                            rendered.push_str(&format!(
274                            "<replace path=\"{}\">\n<old>\n{}\n</old>\n<new>\n{}\n</new>\n</replace>\n\n",
275                            replace.path.display(),
276                            replace.old,
277                            replace.new
278                        ));
279                        }
280                        Change::Smart(smart) => {
281                            rendered.push_str(&format!(
282                                "<smart path=\"{}\">\n{}\n</smart>\n\n",
283                                smart.path.display(),
284                                smart.text
285                            ));
286                        }
287                        Change::UDiff(udiff) => {
288                            rendered.push_str(&format!("<udiff>\n{}\n</udiff>\n\n", udiff.patch));
289                        }
290                    }
291                }
292            }
293            Ok(rendered)
294        } else {
295            Ok(String::new())
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::session::{Step, StepType};
304
305    use indoc::indoc;
306    use pretty_assertions::assert_eq;
307
308    #[test]
309    fn test_parse_response_basic() {
310        let d = Tags {
311            smart: true,
312            replace: true,
313            udiff: false,
314            edit: false,
315        };
316
317        let input = indoc! {r#"
318            <comment>
319            This is a comment.
320            </comment>
321            <write_file path="/path/to/file2.txt">
322            This is the content of the file.
323            </write_file>
324            <replace path="/path/to/file.txt">
325            <old>
326            Old content
327            </old>
328            <new>
329            New content
330            </new>
331            </replace>
332        "#};
333
334        let expected = ModelResponse {
335            patch: Some(Patch {
336                changes: vec![
337                    Change::Write(WriteFile {
338                        path: PathBuf::from("/path/to/file2.txt"),
339                        content: "This is the content of the file.".to_string(),
340                    }),
341                    Change::Replace(Replace {
342                        path: PathBuf::from("/path/to/file.txt"),
343                        old: "Old content".to_string(),
344                        new: "New content".to_string(),
345                    }),
346                ],
347            }),
348            operations: vec![],
349            usage: None,
350            comment: Some("This is a comment.".to_string()),
351            response_text: Some(input.to_string()),
352        };
353
354        let result = d.parse(input).unwrap();
355        assert_eq!(result, expected);
356    }
357
358    #[test]
359    fn test_parse_edit() {
360        let d = Tags::default();
361
362        let input = indoc! {r#"
363            <comment>
364            Testing edit tag
365            </comment>
366            <edit>
367            src/main.rs
368            </edit>
369            <edit>
370                with/leading/spaces.rs
371            </edit>
372        "#};
373
374        let result = d.parse(input).unwrap();
375        assert_eq!(
376            result.operations,
377            vec![
378                Operation::Edit(PathBuf::from("src/main.rs")),
379                Operation::Edit(PathBuf::from("with/leading/spaces.rs")),
380            ]
381        );
382    }
383
384    #[test]
385    fn test_render_edit() {
386        let d = Tags::default();
387        let mut session = Session::default();
388
389        let response = ModelResponse {
390            comment: Some("A comment".into()),
391            patch: None,
392            operations: vec![
393                Operation::Edit(PathBuf::from("src/main.rs")),
394                Operation::Edit(PathBuf::from("src/lib.rs")),
395            ],
396            usage: None,
397            response_text: Some("Test response".into()),
398        };
399
400        session.steps_mut().push(Step::new(
401            "test_model".into(),
402            "test".into(),
403            StepType::Code,
404        ));
405        if let Some(step) = session.steps_mut().last_mut() {
406            step.model_response = Some(response);
407        }
408
409        let result = d
410            .render_step_response(&Config::default(), &session, 0)
411            .unwrap();
412        assert_eq!(
413            result,
414            indoc! {r#"
415                <comment>
416                A comment
417                </comment>
418
419                <edit>
420                src/main.rs
421                </edit>
422
423                <edit>
424                src/lib.rs
425                </edit>
426
427            "#}
428        );
429    }
430
431    #[test]
432    fn test_parse_edit_multiline() {
433        let d = Tags::default();
434
435        let input = indoc! {r#"
436            <edit>
437            /path/to/first
438            /path/to/second
439            </edit>
440        "#};
441
442        let result = d.parse(input).unwrap();
443        assert_eq!(
444            result.operations,
445            vec![
446                Operation::Edit(PathBuf::from("/path/to/first")),
447                Operation::Edit(PathBuf::from("/path/to/second")),
448            ]
449        );
450    }
451
452    #[test]
453    fn test_render_system() {
454        let tags_with_smart = Tags {
455            smart: true,
456            replace: true,
457            udiff: false,
458            edit: false,
459        };
460        let tags_without_smart = Tags {
461            smart: false,
462            replace: true,
463            udiff: false,
464            edit: false,
465        };
466
467        // Test with smart enabled
468        let _system_with_smart = tags_with_smart.system();
469
470        // Test without smart
471        let _system_without_smart = tags_without_smart.system();
472    }
473}