simit 0.17.3

Semver-aware git commit helper for Rust projects
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use camino::Utf8PathBuf;
use serde::Deserialize;

#[derive(Debug, Clone)]
pub struct Project {
    pub name: String,
    pub version: String,
    pub requires_python: Option<String>,
    pub scripts: Vec<String>,
    pub optional_extras: Vec<String>,
    pub dependency_groups: Vec<String>,
    pub workspace_root: Utf8PathBuf,
}

#[derive(Debug, Deserialize)]
struct Pyproject {
    project: ProjectTable,
    #[serde(default, rename = "dependency-groups")]
    dependency_groups: BTreeMap<String, Vec<String>>,
}

#[derive(Debug, Deserialize)]
struct ProjectTable {
    name: String,
    version: String,
    #[serde(default, rename = "requires-python")]
    requires_python: Option<String>,
    #[serde(default)]
    scripts: BTreeMap<String, String>,
    #[serde(default, rename = "optional-dependencies")]
    optional_dependencies: BTreeMap<String, Vec<String>>,
}

pub fn project_for_current_dir() -> Result<Project> {
    let start = std::env::current_dir().context("reading current directory")?;
    let root = find_project_root(&start)?;
    load_project(&root)
}

pub fn find_project_root(start: &Path) -> Result<PathBuf> {
    for dir in start.ancestors() {
        if dir.join("pyproject.toml").exists() && dir.join("uv.lock").exists() {
            return Ok(dir.to_path_buf());
        }
    }

    bail!(
        "could not find Python uv project (pyproject.toml and uv.lock) in {} or its parents",
        start.display()
    );
}

pub fn load_project(root: &Path) -> Result<Project> {
    let pyproject_path = root.join("pyproject.toml");
    let content = fs::read_to_string(&pyproject_path)
        .with_context(|| format!("reading {}", pyproject_path.display()))?;
    let pyproject = toml_edit::de::from_str::<Pyproject>(&content)
        .with_context(|| format!("parsing {}", pyproject_path.display()))?;

    let mut scripts = pyproject
        .project
        .scripts
        .keys()
        .cloned()
        .collect::<Vec<_>>();
    scripts.sort();
    let mut optional_extras = pyproject
        .project
        .optional_dependencies
        .keys()
        .cloned()
        .collect::<Vec<_>>();
    optional_extras.sort();
    let mut dependency_groups = pyproject
        .dependency_groups
        .keys()
        .cloned()
        .collect::<Vec<_>>();
    dependency_groups.sort();

    Ok(Project {
        name: pyproject.project.name,
        version: pyproject.project.version,
        requires_python: pyproject.project.requires_python,
        scripts,
        optional_extras,
        dependency_groups,
        workspace_root: Utf8PathBuf::from_path_buf(root.to_path_buf())
            .map_err(|path| anyhow::anyhow!("workspace root is not UTF-8: {}", path.display()))?,
    })
}

pub fn is_python_uv_project(root: &Path) -> bool {
    root.join("pyproject.toml").exists() && root.join("uv.lock").exists()
}