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, bail};
use aven_core::db::Database;

use crate::config::{self, AppConfig, ProjectOverrideConfig};
use crate::fuzzy::is_near;
use crate::ids::WorkspaceId;
use crate::render::{print_near_error, quote};

pub use aven_core::projects::normalize_key;
#[cfg(test)]
pub use aven_core::test_support::create_project_in_workspace;

pub async fn resolve_project_key_for_add_with_database(
    database: &Database,
    workspace_id: &WorkspaceId,
    project: &str,
) -> Result<String> {
    if let Some(project) = database.find_project(workspace_id, project).await? {
        return Ok(project.key);
    }
    let needle = normalize_key(project);
    let choices = database
        .list_projects(workspace_id, None)
        .await?
        .into_iter()
        .filter(|project| is_near(&needle, &project.key))
        .map(|project| {
            format!(
                "{} prefix={} name={}",
                project.key,
                project.prefix,
                quote(&project.name)
            )
        })
        .collect::<Vec<_>>();
    if choices.is_empty() {
        return Ok(project.to_string());
    }
    print_near_error("project", project, &choices);
    bail!("near-match project")
}

pub async fn inferred_existing_project_key_with_database(
    database: &Database,
    workspace: &crate::workspaces::Workspace,
) -> Result<Option<String>> {
    let config = AppConfig::load()?;
    let config_candidate =
        matching_project_override(&config, Some(&workspace.id), Some(&workspace.key))?;
    let cwd = fs::canonicalize(env::current_dir()?)?;
    let root = git_root(&cwd)?;
    database
        .inferred_existing_project_key(
            &workspace.id,
            config_candidate.as_deref(),
            &cwd,
            root.as_deref(),
        )
        .await
}

pub async fn inferred_project_key_for_add_with_database(
    database: &Database,
    workspace: &crate::workspaces::Workspace,
) -> Result<Option<String>> {
    let config = AppConfig::load()?;
    if let Some(project) =
        matching_project_override(&config, Some(&workspace.id), Some(&workspace.key))?
    {
        return Ok(Some(normalize_key(&project)));
    }
    let cwd = fs::canonicalize(env::current_dir()?)?;
    let root = git_root(&cwd)?;
    if let Some(project) = database
        .inferred_existing_project_key(&workspace.id, None, &cwd, root.as_deref())
        .await?
    {
        return Ok(Some(project));
    }
    Ok(root
        .and_then(|path| {
            path.file_name()
                .map(|name| name.to_string_lossy().to_string())
        })
        .map(|name| normalize_key(&name)))
}

pub fn project_has_config_mapping(
    workspace_id: &WorkspaceId,
    workspace_key: &str,
    project_key: &str,
) -> Result<bool> {
    let config = AppConfig::load()?;
    Ok(config.has_project_override(Some(workspace_id), Some(workspace_key), project_key))
}

fn matching_project_override(
    config: &AppConfig,
    workspace_id: Option<&WorkspaceId>,
    workspace: Option<&str>,
) -> Result<Option<String>> {
    let cwd = fs::canonicalize(env::current_dir()?)?;
    let root = git_root(&cwd)?.unwrap_or_else(|| cwd.clone());
    let mut best: Option<(PathMatch, bool, &ProjectOverrideConfig)> = None;
    for project_override in &config.project.overrides {
        let scoped =
            project_override.workspace_id.is_some() || project_override.workspace.is_some();
        let matches_workspace = match project_override.workspace_id.as_ref() {
            Some(id) => Some(id) == workspace_id,
            None => project_override
                .workspace
                .as_deref()
                .is_none_or(|key| Some(key) == workspace),
        };
        if !matches_workspace {
            continue;
        }
        for path in &project_override.paths {
            let Ok(path) = config::expand_tilde(path).and_then(|path| {
                fs::canonicalize(&path)
                    .with_context(|| format!("could not resolve project path {}", path.display()))
            }) else {
                continue;
            };
            if let Some(path_match) = matching_path(&cwd, &root, &path)
                && best.as_ref().is_none_or(|(best_match, best_scoped, _)| {
                    path_match.is_better_than(*best_match)
                        || path_match == *best_match && scoped && !*best_scoped
                })
            {
                best = Some((path_match, scoped, project_override));
            }
        }
    }
    Ok(best.map(|(_, _, project_override)| project_override.project.clone()))
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PathMatch {
    kind: PathMatchKind,
    len: usize,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum PathMatchKind {
    Root,
    Cwd,
}

impl PathMatch {
    fn is_better_than(self, other: Self) -> bool {
        self.kind > other.kind || self.kind == other.kind && self.len > other.len
    }
}

fn matching_path(cwd: &Path, root: &Path, path: &Path) -> Option<PathMatch> {
    let len = path.components().count();
    if cwd.starts_with(path) {
        return Some(PathMatch {
            kind: PathMatchKind::Cwd,
            len,
        });
    }
    if root == path || root.starts_with(path) {
        return Some(PathMatch {
            kind: PathMatchKind::Root,
            len,
        });
    }
    None
}

fn git_root(path: &Path) -> Result<Option<PathBuf>> {
    let Some(root) = path
        .ancestors()
        .find(|ancestor| ancestor.join(".git").exists())
    else {
        return Ok(None);
    };
    let git_file = root.join(".git");
    if git_file.is_file() {
        let text = fs::read_to_string(&git_file)
            .with_context(|| format!("could not read {}", git_file.display()))?;
        if let Some(path) = text.trim().strip_prefix("gitdir:").map(str::trim) {
            let git_dir = root.join(path);
            if let Some(common_dir) = common_git_dir(&git_dir)? {
                let common_dir = fs::canonicalize(&common_dir)
                    .with_context(|| format!("could not resolve {}", common_dir.display()))?;
                if let Some(main_root) = common_dir.parent() {
                    return fs::canonicalize(main_root)
                        .map(Some)
                        .with_context(|| format!("could not resolve {}", main_root.display()));
                }
            }
        }
    }
    Ok(Some(root.to_path_buf()))
}

fn common_git_dir(git_dir: &Path) -> Result<Option<PathBuf>> {
    let common_dir_file = git_dir.join("commondir");
    if !common_dir_file.is_file() {
        return Ok(None);
    }
    let text = fs::read_to_string(&common_dir_file)
        .with_context(|| format!("could not read {}", common_dir_file.display()))?;
    let path = Path::new(text.trim());
    Ok(Some(if path.is_absolute() {
        path.to_path_buf()
    } else {
        git_dir.join(path)
    }))
}