use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use fs_err as fs;
use serde::{Deserialize, Serialize};
use crate::{config, context, model::Usage, patch::Patch, Result, TenxError};
#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq)]
pub struct ModelResponse {
pub comment: Option<String>,
pub patch: Option<Patch>,
pub operations: Vec<Operation>,
pub usage: Option<Usage>,
pub response_text: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub enum Operation {
Edit(PathBuf),
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub enum StepType {
Code,
Fix,
Auto,
Error,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Step {
pub model: String,
pub step_type: StepType,
pub prompt: String,
pub model_response: Option<ModelResponse>,
pub response_time: Option<f64>,
pub err: Option<TenxError>,
pub rollback_cache: HashMap<PathBuf, String>,
}
impl Step {
pub fn new(model: String, prompt: String, step_type: StepType) -> Self {
Step {
model,
step_type,
prompt,
model_response: None,
response_time: None,
err: None,
rollback_cache: HashMap::new(),
}
}
fn apply(&mut self, config: &config::Config) -> Result<()> {
if let Some(resp) = &self.model_response {
if let Some(patch) = &resp.patch {
self.rollback_cache = patch.snapshot(config)?;
patch.apply(config)?;
}
}
Ok(())
}
pub fn rollback(&mut self, config: &config::Config) -> Result<()> {
for (path, content) in &self.rollback_cache {
fs::write(config.abspath(path)?, content)?;
}
self.model_response = None;
self.err = None;
Ok(())
}
}
fn is_glob(s: &str) -> bool {
s.contains('*') || s.contains('?')
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct Session {
editable: Vec<PathBuf>,
pub steps: Vec<Step>,
pub contexts: Vec<context::Context>,
}
impl Session {
pub fn clear_ctx(&mut self) {
self.contexts.clear();
}
pub fn update_prompt_at(&mut self, offset: usize, prompt: String) -> Result<()> {
if offset >= self.steps.len() {
return Err(TenxError::Internal("Invalid step offset".into()));
}
self.steps[offset].prompt = prompt;
Ok(())
}
pub fn clear(&mut self) {
self.steps.clear();
}
}
impl Session {
pub fn steps(&self) -> &Vec<Step> {
&self.steps
}
pub fn steps_mut(&mut self) -> &mut Vec<Step> {
&mut self.steps
}
pub fn last_step(&self) -> Option<&Step> {
self.steps.last()
}
pub fn last_step_mut(&mut self) -> Option<&mut Step> {
self.steps.last_mut()
}
pub fn contexts(&self) -> &Vec<context::Context> {
&self.contexts
}
pub fn editables(&self) -> Vec<PathBuf> {
let mut paths = self.editable.clone();
paths.sort();
paths
}
pub fn abs_editables(&self, config: &config::Config) -> Result<Vec<PathBuf>> {
let mut paths: Vec<PathBuf> = self
.editable
.clone()
.iter()
.map(|p| config.abspath(p))
.collect::<Result<Vec<PathBuf>>>()?;
paths.sort();
Ok(paths)
}
pub fn should_continue(&self) -> bool {
if let Some(step) = self.steps.last() {
step.model_response.is_none() && step.err.is_none()
} else {
false
}
}
pub fn last_step_error(&self) -> Option<&TenxError> {
self.last_step().and_then(|step| step.err.as_ref())
}
pub fn add_prompt(&mut self, model: String, prompt: String, step_type: StepType) -> Result<()> {
if let Some(last_step) = self.steps.last() {
if last_step.model_response.is_none() && last_step.err.is_none() {
return Err(TenxError::Internal(
"Cannot add a new prompt while the previous step has no response".into(),
));
}
}
self.steps.push(Step::new(model, prompt, step_type));
Ok(())
}
pub fn set_last_prompt(
&mut self,
model: String,
prompt: String,
step_type: StepType,
) -> Result<()> {
if self.steps.is_empty() {
self.steps.push(Step::new(model, prompt, step_type));
Ok(())
} else if let Some(last_step) = self.steps.last_mut() {
last_step.prompt = prompt;
last_step.model = model;
last_step.step_type = step_type;
last_step.model_response = None;
Ok(())
} else {
Err(TenxError::Internal("Failed to set prompt".into()))
}
}
pub fn add_context(&mut self, new_context: context::Context) {
if let Some(pos) = self.contexts.iter().position(|x| x.is_dupe(&new_context)) {
self.contexts[pos] = new_context;
} else {
self.contexts.push(new_context);
}
}
pub fn add_editable_path<P: AsRef<Path>>(
&mut self,
config: &config::Config,
path: P,
) -> Result<usize> {
let normalized_path = config.normalize_path(path)?;
if !config.project_files()?.contains(&normalized_path) {
return Err(TenxError::NotFound {
msg: "Path not included in project".to_string(),
path: normalized_path.display().to_string(),
});
}
if !self.editable.contains(&normalized_path) {
self.editable.push(normalized_path);
Ok(1)
} else {
Ok(0)
}
}
pub fn add_editable_glob(&mut self, config: &config::Config, pattern: &str) -> Result<usize> {
let matched_files = config.match_files_with_glob(pattern)?;
let mut added = 0;
for file in matched_files {
added += self.add_editable_path(config, file)?;
}
Ok(added)
}
pub fn add_editable(&mut self, config: &config::Config, path: &str) -> Result<usize> {
if is_glob(path) {
self.add_editable_glob(config, path)
} else {
self.add_editable_path(config, path)
}
}
fn reset_steps(&mut self, config: &config::Config, keep: Option<usize>) -> Result<()> {
let total = self.steps.len();
match keep {
Some(offset) if offset >= total => {
return Err(TenxError::Internal("Invalid rollback offset".into()));
}
Some(offset) => {
let n = total - offset - 1;
for step in self.steps.iter_mut().rev().take(n) {
step.rollback(config)?;
}
self.steps.truncate(offset + 1);
}
None => {
for step in self.steps.iter_mut().rev() {
step.rollback(config)?;
}
self.steps.clear();
}
}
Ok(())
}
pub fn reset(&mut self, config: &config::Config, offset: usize) -> Result<()> {
self.reset_steps(config, Some(offset))
}
pub fn reset_all(&mut self, config: &config::Config) -> Result<()> {
self.reset_steps(config, None)
}
pub fn apply_last_step(&mut self, config: &config::Config) -> Result<()> {
let step = self
.last_step_mut()
.ok_or_else(|| TenxError::Internal("No steps in session".into()))?;
let resp = step
.model_response
.clone()
.ok_or_else(|| TenxError::Internal("No response in the last step".into()))?;
step.apply(config)?;
let mut had_edit = false;
for operation in &resp.operations {
match operation {
Operation::Edit(path) => {
self.add_editable_path(config, path)?;
had_edit = true;
}
}
}
if had_edit {
let current_model = self
.steps
.last()
.map(|s| s.model.clone())
.unwrap_or_default();
self.add_prompt(current_model, "OK".into(), StepType::Auto)?;
}
Ok(())
}
pub fn editables_for_step(&self, step_offset: usize) -> Result<Vec<PathBuf>> {
if step_offset > self.steps.len() {
return Err(TenxError::Internal("Invalid step offset".into()));
}
let mut most_recent_modified: HashMap<PathBuf, i32> = self
.editable
.iter()
.map(|path| (path.clone(), -1))
.collect();
for (idx, step) in self.steps.iter().enumerate() {
if let Some(resp) = &step.model_response {
if let Some(patch) = &resp.patch {
for path in patch.changed_files() {
most_recent_modified.insert(path.clone(), idx as i32);
}
}
for op in &resp.operations {
let Operation::Edit(path) = op;
most_recent_modified.insert(path.clone(), idx as i32);
}
}
}
let target_step = (step_offset as i32) - 1;
Ok(self
.editable
.iter()
.filter(|path| most_recent_modified.get(*path) == Some(&target_step))
.cloned()
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::patch::{Change, Patch, WriteFile};
#[test]
fn test_add_editable() -> Result<()> {
let test_project = crate::testutils::test_project();
test_project.create_file_tree(&["foo.txt", "dir_a/bar.txt", "dir_b/baz.txt"]);
struct TestCase {
name: &'static str,
cwd: &'static str,
editable: &'static str,
expected: Vec<PathBuf>,
}
let mut tests = vec![
TestCase {
name: "simple file path",
cwd: "",
editable: "./foo.txt",
expected: vec![PathBuf::from("foo.txt")],
},
TestCase {
name: "relative path to subdirectory",
cwd: "dir_a",
editable: "./bar.txt",
expected: vec![PathBuf::from("dir_a/bar.txt")],
},
TestCase {
name: "relative path between directories",
cwd: "dir_a",
editable: "../dir_b/baz.txt",
expected: vec![PathBuf::from("dir_b/baz.txt")],
},
TestCase {
name: "relative path to parent directory",
cwd: "dir_a",
editable: "../foo.txt",
expected: vec![PathBuf::from("foo.txt")],
},
TestCase {
name: "simple file glob",
cwd: "",
editable: "foo.*",
expected: vec![PathBuf::from("foo.txt")],
},
];
tests.reverse();
for t in tests.iter() {
let mut sess = test_project.session.clone();
let cwd = test_project.tempdir.path().join(t.cwd);
let config = test_project.config.clone().with_cwd(cwd);
sess.add_editable(&config, t.editable)?;
assert_eq!(sess.editables(), t.expected, "test case: {}", t.name);
}
Ok(())
}
#[test]
fn test_add_context_ignores_duplicates() -> Result<()> {
let mut test_project = crate::testutils::test_project();
test_project.create_file_tree(&["test.txt"]);
test_project.write("test.txt", "content");
let path1 =
context::Context::Path(context::Path::new(&test_project.config, "test.txt".into())?);
let path2 =
context::Context::Path(context::Path::new(&test_project.config, "test.txt".into())?);
test_project.session.add_context(path1);
test_project.session.add_context(path2);
assert_eq!(test_project.session.contexts.len(), 1);
let url1 = context::Context::Url(context::Url::new("http://example.com".into()));
let url2 = context::Context::Url(context::Url::new("http://example.com".into()));
test_project.session.add_context(url1);
test_project.session.add_context(url2);
assert_eq!(test_project.session.contexts.len(), 2);
Ok(())
}
#[test]
fn test_reset() -> Result<()> {
let mut test_project = crate::testutils::test_project();
test_project.create_file_tree(&["test.txt"]);
test_project.write("test.txt", "Initial content");
for i in 1..=3 {
let content = format!("Content {}", i);
let patch = Patch {
changes: vec![Change::Write(WriteFile {
path: PathBuf::from("test.txt"),
content: content.clone(),
})],
};
test_project.session.add_prompt(
"test_model".into(),
format!("Prompt {}", i),
StepType::Code,
)?;
let rollback_cache = [(PathBuf::from("test.txt"), test_project.read("test.txt"))]
.into_iter()
.collect();
if let Some(step) = test_project.session.last_step_mut() {
step.model_response = Some(ModelResponse {
patch: Some(patch.clone()),
operations: vec![],
usage: None,
comment: Some(format!("Step {}", i)),
response_text: Some(format!("Step {}", i)),
});
step.rollback_cache = rollback_cache;
step.apply(&test_project.config)?;
}
}
assert_eq!(test_project.session.steps.len(), 3);
assert_eq!(test_project.read("test.txt"), "Content 3");
test_project.session.reset(&test_project.config, 0)?;
assert_eq!(test_project.session.steps.len(), 1);
assert_eq!(test_project.read("test.txt"), "Content 1");
test_project.session.reset_all(&test_project.config)?;
assert_eq!(test_project.session.steps.len(), 0);
assert_eq!(test_project.read("test.txt"), "Initial content");
Ok(())
}
#[test]
fn test_editables_for_step() -> Result<()> {
let mut test_project = crate::testutils::test_project();
test_project.create_file_tree(&["file1.txt", "file2.txt", "file3.txt"]);
test_project.write("file1.txt", "content1");
test_project.write("file2.txt", "content2");
test_project.write("file3.txt", "content3");
test_project
.session
.add_editable_path(&test_project.config, "file1.txt")?;
test_project
.session
.add_editable_path(&test_project.config, "file2.txt")?;
test_project
.session
.add_editable_path(&test_project.config, "file3.txt")?;
let editables = test_project.session.editables_for_step(0)?;
assert_eq!(editables.len(), 3,);
test_project
.session
.add_prompt("test_model".into(), "step0".into(), StepType::Code)?;
let step = test_project.session.steps.last_mut().unwrap();
step.model_response = Some(ModelResponse {
patch: Some(Patch {
changes: vec![Change::Write(WriteFile {
path: PathBuf::from("file1.txt"),
content: "modified1".into(),
})],
}),
operations: vec![],
usage: None,
comment: None,
response_text: None,
});
test_project
.session
.add_prompt("test_model".into(), "step1".into(), StepType::Code)?;
let step = test_project.session.steps.last_mut().unwrap();
step.model_response = Some(ModelResponse {
patch: Some(Patch {
changes: vec![Change::Write(WriteFile {
path: PathBuf::from("file3.txt"),
content: "modified3".into(),
})],
}),
operations: vec![Operation::Edit(PathBuf::from("file2.txt"))],
usage: None,
comment: None,
response_text: None,
});
test_project
.session
.add_prompt("test_model".into(), "step2".into(), StepType::Code)?;
let step = test_project.session.steps.last_mut().unwrap();
step.model_response = Some(ModelResponse {
patch: None,
operations: vec![],
usage: None,
comment: None,
response_text: None,
});
let editables = test_project.session.editables_for_step(0)?;
assert!(
editables.is_empty(),
"No files should be editable at step 0"
);
let editables = test_project.session.editables_for_step(1)?;
assert_eq!(editables.len(), 1, "One file should be editable");
assert_eq!(editables[0], PathBuf::from("file1.txt"));
let editables = test_project.session.editables_for_step(2)?;
assert_eq!(editables.len(), 2, "Two files should be editable");
assert!(editables.contains(&PathBuf::from("file2.txt")));
assert!(editables.contains(&PathBuf::from("file3.txt")));
let editables = test_project.session.editables_for_step(3)?;
assert!(
editables.is_empty(),
"No files should be editable at step 3"
);
assert!(test_project.session.editables_for_step(5).is_err());
Ok(())
}
#[test]
fn test_apply_last_step_with_editable() -> Result<()> {
let mut test_project = crate::testutils::test_project();
test_project.create_file_tree(&["test.txt", "new.txt"]);
test_project.write("test.txt", "content");
test_project.write("new.txt", "new content");
test_project.session.add_prompt(
"test_model".into(),
"test prompt".into(),
StepType::Code,
)?;
let step = test_project.session.steps.last_mut().unwrap();
let patch = Patch {
changes: vec![Change::Write(WriteFile {
path: PathBuf::from("test.txt"),
content: "modified content".into(),
})],
};
step.model_response = Some(ModelResponse {
patch: Some(patch),
operations: vec![Operation::Edit(PathBuf::from("new.txt"))],
usage: None,
comment: None,
response_text: None,
});
step.rollback_cache = [(PathBuf::from("test.txt"), "content".into())]
.into_iter()
.collect();
test_project.session.apply_last_step(&test_project.config)?;
assert_eq!(test_project.read("test.txt"), "modified content");
assert!(test_project
.session
.editable
.contains(&PathBuf::from("new.txt")));
assert_eq!(test_project.session.editable.len(), 1);
Ok(())
}
}