aven 0.1.13

Local-first task manager CLI and sync server
Documentation
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use aven_core::db::Database;

use crate::config;
use crate::config_edit::{self, ProjectPathMappingEdit};
use crate::projects::project_has_config_mapping;
use crate::types::Project;
use crate::workspaces::Workspace;

pub struct ProjectPathOutcome {
    pub project: Project,
    pub path: String,
    pub config_path: PathBuf,
}

pub struct ProjectDeleteOutcome {
    pub project: Project,
}

pub struct ProjectRenameOutcome {
    pub previous: Project,
    pub project: Project,
    pub changed: bool,
    pub config_mapping: bool,
}

pub async fn create_project_operation(
    database: &Database,
    workspace: &Workspace,
    name: &str,
    path: Option<&Path>,
) -> Result<aven_core::operations::ProjectOutcome> {
    let path = path.map(canonicalize_project_path).transpose()?;
    let outcome = database.create_project(workspace, name).await?;
    if let Some(path) = path {
        save_project_path_mapping(workspace, &outcome.project, path)?;
    }
    Ok(outcome)
}

pub async fn delete_project_operation(
    database: &Database,
    workspace: &Workspace,
    project: &str,
) -> Result<ProjectDeleteOutcome> {
    let project = database
        .resolve_existing_project(&workspace.id, project)
        .await?;
    let outcome = database.delete_project(workspace, &project.key).await?;
    Ok(ProjectDeleteOutcome {
        project: outcome.project,
    })
}

pub async fn rename_project_operation(
    database: &Database,
    workspace: &Workspace,
    project: &str,
    new_name: &str,
    prefix: Option<&str>,
) -> Result<ProjectRenameOutcome> {
    let (outcome, config_mapping) =
        database
            .rename_project_before_commit(workspace, project, new_name, prefix, |outcome| {
                if outcome.changed {
                    rename_config_project_mapping(
                        workspace,
                        &outcome.previous.key,
                        &outcome.project.key,
                    )
                } else {
                    Ok(project_has_config_mapping(
                        &workspace.id,
                        &workspace.key,
                        &outcome.previous.key,
                    )
                    .unwrap_or(false))
                }
            })
            .await?;
    Ok(ProjectRenameOutcome {
        previous: outcome.previous,
        project: outcome.project,
        changed: outcome.changed,
        config_mapping,
    })
}

pub fn rename_config_project_mapping(
    workspace: &Workspace,
    old_project: &str,
    new_project: &str,
) -> Result<bool> {
    let config_path = config::config_file_path()?;
    config_edit::rename_project_path(&config_path, &workspace.id, old_project, new_project)
}

fn canonicalize_project_path(path: &Path) -> Result<PathBuf> {
    fs::canonicalize(path).with_context(|| format!("could not resolve {}", path.display()))
}

fn project_path_remove_candidates(path: &Path) -> Vec<PathBuf> {
    let mut paths = Vec::new();
    if let Ok(path) = fs::canonicalize(path) {
        paths.push(path);
    }
    let supplied = if path.is_absolute() {
        path.to_path_buf()
    } else if let Ok(cwd) = env::current_dir() {
        cwd.join(path)
    } else {
        path.to_path_buf()
    };
    if !paths.iter().any(|path| path == &supplied) {
        paths.push(supplied);
    }
    paths
}

fn save_project_path_mapping(
    workspace: &Workspace,
    project: &Project,
    path: PathBuf,
) -> Result<ProjectPathOutcome> {
    let config_path = config::config_file_path()?;
    config_edit::add_project_path(
        &config_path,
        ProjectPathMappingEdit {
            workspace_id: &workspace.id,
            workspace: &workspace.key,
            project: &project.key,
            path: path.clone(),
        },
    )?;
    Ok(ProjectPathOutcome {
        project: project.clone(),
        path: path.display().to_string(),
        config_path,
    })
}

pub async fn add_project_path_operation(
    database: &Database,
    workspace: &Workspace,
    project: &str,
    path: &Path,
) -> Result<ProjectPathOutcome> {
    let project = database
        .resolve_existing_project(&workspace.id, project)
        .await?;
    let path = canonicalize_project_path(path)?;
    save_project_path_mapping(workspace, &project, path)
}

pub async fn remove_project_path_operation(
    database: &Database,
    workspace: &Workspace,
    project: &str,
    path: &Path,
) -> Result<ProjectPathOutcome> {
    let project = database
        .resolve_existing_project(&workspace.id, project)
        .await?;
    let config_path = config::config_file_path()?;
    let remove_paths = project_path_remove_candidates(path);
    config_edit::remove_project_path(&config_path, &workspace.id, &project.key, &remove_paths)?;
    for path in &remove_paths {
        database
            .remove_project_path(&project.workspace_id, &project.id, path)
            .await?;
    }
    let path = remove_paths
        .first()
        .unwrap_or(&path.to_path_buf())
        .display()
        .to_string();
    Ok(ProjectPathOutcome {
        project,
        path,
        config_path,
    })
}

pub async fn list_project_paths_operation(
    database: &Database,
    workspace: &Workspace,
    project: Option<&str>,
) -> Result<Vec<ProjectPathOutcome>> {
    let project = if let Some(project) = project {
        Some(
            database
                .resolve_existing_project(&workspace.id, project)
                .await?,
        )
    } else {
        None
    };
    let project_key = project.as_ref().map(|project| project.key.as_str());
    let config_path = config::config_file_path()?;
    let config = config::AppConfig::load_from_path(&config_path)?;
    let mut paths = Vec::new();
    for project_override in config.project.overrides {
        if !project_override.matches_workspace(Some(&workspace.id), Some(&workspace.key)) {
            continue;
        }
        if project_key.is_some_and(|key| project_override.project_key() != key) {
            continue;
        }
        let project = database
            .resolve_existing_project(&workspace.id, &project_override.project_key())
            .await?;
        paths.extend(
            project_override
                .paths
                .into_iter()
                .map(|path| ProjectPathOutcome {
                    project: project.clone(),
                    path: path.display().to_string(),
                    config_path: config_path.clone(),
                }),
        );
    }
    paths.sort_by(|left, right| {
        left.project
            .key
            .cmp(&right.project.key)
            .then_with(|| left.path.cmp(&right.path))
    });
    Ok(paths)
}