vespe 0.1.2

Text as a Canvas for LLM Collaboration and Automation
Documentation
use crate::ast2::{JsonPlusEntity, JsonPlusObject};
use crate::constants::{CTX_DIR_NAME, CTX_ROOT_FILE_NAME, METADATA_DIR_NAME};
use crate::execute2::{ContextAnalysis, ModelContent};
use crate::utils::file::{FileAccessor, ProjectFileAccessor};
use crate::utils::path::{PathResolver, ProjectPathResolver};

use std::sync::Arc;

use anyhow::Result;

use crate::error::Error;

use std::collections::BTreeMap;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};

use crate::config::{EditorInterface, ProjectConfig};
use crate::editor::{lockfile::FileBasedEditorCommunicator, EditorCommunicator};

pub struct Project {
    editor_interface: Option<Arc<dyn EditorCommunicator>>,
    file_access: Arc<ProjectFileAccessor>,
    path_res: Arc<ProjectPathResolver>,
    project_config: ProjectConfig,
}

pub struct ExecuteContextInput {
    pub context_name: String,
    pub input_file: Option<String>,
    pub args: Option<Vec<String>>,
    pub defines: Option<Vec<String>>,
    pub additional_aux_paths: Option<Vec<PathBuf>>,
    pub output_path: Option<PathBuf>,
}

impl Default for ExecuteContextInput {
    fn default() -> Self {
        Self {
            context_name: String::new(),
            input_file: None,
            args: None,
            defines: None,
            additional_aux_paths: None,
            output_path: None,
        }
    }
}

impl Project {
    pub fn init(root_path: &Path) -> Result<()> {
        let ctx_dir = root_path.join(CTX_DIR_NAME);
        if ctx_dir.is_dir() && ctx_dir.join(CTX_ROOT_FILE_NAME).is_file() {
            return Err(Error::ProjectAlreadyInitialized.into());
        }

        std::fs::create_dir_all(&ctx_dir).map_err(|source| Error::FailedToCreateDirectory {
            path: ctx_dir.clone(),
            source,
        })?;

        let ctx_root_file = ctx_dir.join(CTX_ROOT_FILE_NAME);
        std::fs::write(&ctx_root_file, "Feel The BuZZ!!").map_err(|source| Error::FileWrite {
            path: ctx_root_file.clone(),
            source,
        })?;

        let mut project_config = ProjectConfig::default();

        project_config.git_integration_enabled = crate::utils::git::is_in_git_repository(&ctx_dir)?;

        let file_access = Arc::new(ProjectFileAccessor::new(root_path, None));
        let path_res = Arc::new(ProjectPathResolver::new(
            root_path.to_path_buf(),
            project_config.aux_paths.clone(),
            None, // output_path
        ));

        let project = Project {
            editor_interface: None,
            file_access,
            path_res,
            project_config,
        };

        project.save_project_config()?;
        project.commit(Some("Initialized vespe project.".into()))?;

        Ok(())
    }

    pub fn find(path: &Path) -> Result<Project> {
        let mut current_path = path.to_path_buf();

        loop {
            let ctx_dir = current_path.join(CTX_DIR_NAME);
            if ctx_dir.is_dir() && ctx_dir.join(CTX_ROOT_FILE_NAME).is_file() {
                let root_path =
                    current_path
                        .canonicalize()
                        .map_err(|source| Error::CanonicalizePath {
                            path: current_path.clone(),
                            source,
                        })?;
                let project_config_path = root_path
                    .join(CTX_DIR_NAME)
                    .join(METADATA_DIR_NAME)
                    .join("project_config.json");
                let project_config = Self::load_project_config(&project_config_path)?;

                let editor_path = ctx_dir.join(METADATA_DIR_NAME).join(".editor");
                let editor_interface: Option<Arc<dyn EditorCommunicator>> =
                    match project_config.editor_interface {
                        EditorInterface::VSCode => Some(Arc::new(
                            FileBasedEditorCommunicator::new(&editor_path).map_err(|source| {
                                Error::EditorInterface {
                                    message: "Failed to create file-based editor communicator"
                                        .to_string(),
                                    source: source.into(),
                                }
                            })?,
                        )
                            as Arc<dyn EditorCommunicator>),
                        _ => None,
                    };

                let file_access = Arc::new(ProjectFileAccessor::new(
                    &root_path,
                    editor_interface.clone(),
                ));
                let path_res = Arc::new(ProjectPathResolver::new(
                    root_path.clone(),
                    project_config.aux_paths.clone(),
                    None, // output_path
                ));

                return Ok(Project {
                    editor_interface,
                    file_access,
                    path_res,
                    project_config,
                });
            }

            if !current_path.pop() {
                break;
            }
        }

        return Err(Error::ProjectNotFound.into());
    }

    pub fn execute_context(&self, input: ExecuteContextInput) -> Result<ModelContent> {
        let mut data = match input.args {
            Some(args) => {
                let mut data = args
                    .iter()
                    .enumerate()
                    .map(|(i, x)| (format!("${}", i + 1), JsonPlusEntity::NudeString(x.clone())))
                    .collect::<BTreeMap<String, JsonPlusEntity>>();
                data.insert(
                    "$args".to_string(),
                    JsonPlusEntity::DoubleQuotedString(args.join(" ")),
                );
                let data = JsonPlusObject::from_map(data);
                data
            }
            None => JsonPlusObject::new(),
        };
        if let Some(defines) = input.defines {
            for define in defines {
                if let Some((key, value)) = define.split_once('=') {
                    let key = format!("${}", key);
                    data.insert(key, JsonPlusEntity::NudeString(value.to_string()));
                }
            }
        }
        data.insert(
            "$stdin".to_string(),
            JsonPlusEntity::DoubleQuotedString(input.input_file.unwrap_or(String::new())),
        );
        let mut path_res_builder = self.path_res.clone();

        if let Some(aux_paths) = input.additional_aux_paths {
            path_res_builder = Arc::new(path_res_builder.with_additional_aux_paths(aux_paths));
        }

        if let Some(output_path) = input.output_path {
            path_res_builder = Arc::new(path_res_builder.with_alternative_output_path(output_path));
        }

        let path_res = path_res_builder;

        let content = crate::execute2::execute_context(
            self.file_access.clone(),
            path_res,
            &input.context_name,
            Some(&data),
        )?;
        self.commit(Some(format!("Executed context {}.", input.context_name)))?;
        Ok(content)
    }

    pub fn analyze_context(&self, context_name: &str) -> Result<ContextAnalysis> {
        let analysis = crate::execute2::analyze_context(
            self.file_access.clone(),
            self.path_res.clone(),
            context_name,
        )?;
        Ok(analysis)
    }

    pub fn project_home(&self) -> PathBuf {
        self.path_res.project_home()
    }

    pub fn contexts_root(&self) -> PathBuf {
        self.path_res.contexts_root()
    }

    pub fn project_config_path(&self) -> PathBuf {
        self.path_res.metadata_home().join("project_config.json")
    }

    pub fn create_context_file(
        &self,
        name: &str,
        initial_content: Option<String>,
    ) -> Result<PathBuf> {
        let file_path = self.path_res.resolve_output_file(&format!("{}", name))?;
        if file_path.exists() {
            return Err(Error::ContextFileAlreadyExists { path: file_path }.into());
        }
        let content = initial_content.unwrap_or_else(|| "".to_string());
        self.file_access.write_file(&file_path, &content, None)?;

        self.commit(Some(format!("Created new context {}", name)))?;

        Ok(file_path)
    }

    pub fn save_project_config(&self) -> Result<()> {
        let config_path = self.project_config_path();
        let parent_dir = config_path
            .parent()
            .ok_or_else(|| Error::ParentDirectoryNotFound {
                file_path: config_path.clone(),
            })?;
        std::fs::create_dir_all(parent_dir).map_err(|source| Error::FailedToCreateDirectory {
            path: parent_dir.to_path_buf(),
            source,
        })?;
        let serialized =
            serde_json::to_string_pretty(&self.project_config).map_err(Error::JsonError)?;
        self.file_access.write_file(
            &config_path,
            &serialized,
            Some("Saved project config file."),
        )?;
        Ok(())
    }

    pub fn load_project_config(project_config_path: &PathBuf) -> Result<ProjectConfig> {
        match std::fs::read_to_string(project_config_path) {
            Ok(content) => Ok(serde_json::from_str(&content).map_err(Error::JsonError)?),
            Err(e) if e.kind() == ErrorKind::NotFound => Ok(ProjectConfig::default()),
            Err(e) => Err(Error::FileRead {
                path: project_config_path.clone(),
                source: e,
            }
            .into()),
        }
    }

    pub fn add_aux_path(&mut self, path: PathBuf) -> Result<()> {
        self.project_config.aux_paths.push(path);
        self.save_project_config()?;
        self.commit(Some("Added auxiliary path to project config.".into()))?;
        Ok(())
    }

    pub fn remove_aux_path(&mut self, path: &Path) -> Result<()> {
        let initial_len = self.project_config.aux_paths.len();
        self.project_config.aux_paths.retain(|p| p != path);
        if self.project_config.aux_paths.len() < initial_len {
            self.save_project_config()?;
            self.commit(Some("Removed auxiliary path from project config.".into()))?;
        }
        Ok(())
    }

    pub fn get_aux_paths(&self) -> &Vec<PathBuf> {
        &self.project_config.aux_paths
    }

    pub fn commit(&self, title_message: Option<String>) -> Result<()> {
        if self.project_config.git_integration_enabled {
            Ok(self.file_access.commit(title_message)?)
        } else {
            Ok(())
        }
    }
}