mitts 0.3.0

Rust-first build and package tooling with a shared artifact cache
Documentation
/*
 * Mitts Build — Rust-first build and package tooling with shared artifact cache
 * Copyright (c) 2026 Alexander Panasenko
 *
 * Contact: alex@prod.codes
 * Author: https://prod.codes/about/
 * Project: https://github.com/alex09x/mitts
 * SPDX-License-Identifier: MIT OR Apache-2.0
 */

use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;

/// Represents a package entry in Cargo.lock.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CargoLockPackage {
    pub name: String,
    pub version: String,
    #[serde(default)]
    pub source: Option<String>,
    #[serde(default)]
    pub checksum: Option<String>,
    #[serde(default)]
    pub dependencies: Vec<String>,
}

/// Represents the parsed Cargo.lock file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CargoLock {
    pub version: u32,
    pub packages: Vec<CargoLockPackage>,
}

impl CargoLock {
    /// Parses a standard Cargo.lock string representation.
    pub fn parse(content: &str) -> Result<Self> {
        let mut version = 3u32;
        let mut packages = Vec::new();
        let mut cur_pkg: Option<CargoLockPackage> = None;
        let mut in_dependencies = false;

        for line in content.lines() {
            let trimmed = line.trim();
            if trimmed.is_empty() || trimmed.starts_with('#') {
                continue;
            }

            if trimmed == "[[package]]" {
                if let Some(pkg) = cur_pkg.take() {
                    packages.push(pkg);
                }
                cur_pkg = Some(CargoLockPackage {
                    name: String::new(),
                    version: String::new(),
                    source: None,
                    checksum: None,
                    dependencies: Vec::new(),
                });
                in_dependencies = false;
                continue;
            }

            if in_dependencies {
                if trimmed == "]" {
                    in_dependencies = false;
                    continue;
                }
                let dep_token = trimmed.trim_matches(',').trim().trim_matches('"');
                if !dep_token.is_empty() {
                    if let Some(ref mut pkg) = cur_pkg {
                        pkg.dependencies.push(dep_token.to_string());
                    }
                }
                continue;
            }

            if let Some((key, val)) = trimmed.split_once('=') {
                let key = key.trim();
                let val = val.trim();

                if key == "version" && cur_pkg.is_none() {
                    version = val.parse().unwrap_or(3);
                } else if let Some(ref mut pkg) = cur_pkg {
                    match key {
                        "name" => pkg.name = val.trim_matches('"').to_string(),
                        "version" => pkg.version = val.trim_matches('"').to_string(),
                        "source" => pkg.source = Some(val.trim_matches('"').to_string()),
                        "checksum" => pkg.checksum = Some(val.trim_matches('"').to_string()),
                        "dependencies" => {
                            if val == "[" {
                                in_dependencies = true;
                            } else if val.starts_with('[') && val.ends_with(']') {
                                let inner = &val[1..val.len() - 1];
                                for item in inner.split(',') {
                                    let d = item.trim().trim_matches('"');
                                    if !d.is_empty() {
                                        pkg.dependencies.push(d.to_string());
                                    }
                                }
                            }
                        }
                        _ => {}
                    }
                }
            }
        }

        if let Some(pkg) = cur_pkg.take() {
            packages.push(pkg);
        }

        Ok(Self { version, packages })
    }

    /// Reads and parses a Cargo.lock file from disk.
    pub fn from_file(path: &Path) -> Result<Self> {
        let content = fs::read_to_string(path)
            .with_context(|| format!("Failed to read lockfile at {:?}", path))?;
        Self::parse(&content)
    }

    /// Formats the CargoLock into standard TOML format.
    pub fn to_string_formatted(&self) -> String {
        let mut out = String::new();
        out.push_str("# This file is automatically @generated by Mitts/Cargo.\n");
        out.push_str("# It is not intended for manual editing.\n");
        out.push_str(&format!("version = {}\n\n", self.version));

        let mut sorted_packages = self.packages.clone();
        sorted_packages.sort_by(|a, b| a.name.cmp(&b.name).then(a.version.cmp(&b.version)));

        for pkg in sorted_packages {
            out.push_str("[[package]]\n");
            out.push_str(&format!("name = \"{}\"\n", pkg.name));
            out.push_str(&format!("version = \"{}\"\n", pkg.version));
            if let Some(ref src) = pkg.source {
                out.push_str(&format!("source = \"{}\"\n", src));
            }
            if let Some(ref cs) = pkg.checksum {
                out.push_str(&format!("checksum = \"{}\"\n", cs));
            }
            if !pkg.dependencies.is_empty() {
                out.push_str("dependencies = [\n");
                for dep in &pkg.dependencies {
                    out.push_str(&format!(" \"{}\",\n", dep));
                }
                out.push_str("]\n");
            }
            out.push('\n');
        }

        out
    }

    /// Writes the formatted lockfile to disk.
    pub fn write_to_file(&self, path: &Path) -> Result<()> {
        let formatted = self.to_string_formatted();
        fs::write(path, formatted)
            .with_context(|| format!("Failed to write lockfile to {:?}", path))?;
        Ok(())
    }

    /// Returns the first package matching the given name.
    pub fn get_package(&self, name: &str) -> Option<&CargoLockPackage> {
        self.packages.iter().find(|p| p.name == name)
    }

    /// Fast-path validation: checks whether direct dependencies declared in Cargo.toml
    /// exist in this Cargo.lock. Returns true if compatible, false if sync is needed.
    pub fn validate_manifest_compatibility(&self, manifest_path: &Path) -> Result<bool> {
        if !manifest_path.is_file() {
            return Ok(false);
        }
        if let Ok(toml) = super::manifest::CargoToml::from_file(manifest_path) {
            for (dep_name, dep_val) in toml.dependencies.iter().chain(&toml.build_dependencies) {
                match dep_val {
                    super::manifest::TomlDependency::Simple(_) => {
                        if self.get_package(dep_name).is_none() {
                            return Ok(false);
                        }
                    }
                    super::manifest::TomlDependency::Detailed(d) => {
                        if d.optional.unwrap_or(false) || d.path.is_some() || d.git.is_some() {
                            continue;
                        }
                        let pkg_name = d.package_name.as_ref().unwrap_or(dep_name);
                        if self.get_package(pkg_name).is_none() {
                            return Ok(false);
                        }
                    }
                }
            }
            return Ok(true);
        }
        let manifest_content = fs::read_to_string(manifest_path)?;
        let direct_deps = extract_manifest_dependencies(&manifest_content);

        for dep_name in direct_deps {
            if self.get_package(&dep_name).is_none() {
                return Ok(false);
            }
        }

        Ok(true)
    }

    /// Two-phase dependency resolver:
    /// - Fast-path: reads existing Cargo.lock directly without re-resolution if compatible.
    /// - Sync-path: generates/updates standard Cargo.lock when missing or incompatible,
    ///   caching sparse-index entries under `cache_dir` (the default cache root when `None`).
    pub fn resolve_or_sync(
        manifest_path: &Path,
        offline: bool,
        cache_dir: Option<&Path>,
    ) -> Result<Self> {
        let manifest_dir = manifest_path
            .parent()
            .context("Manifest path has no parent directory")?;
        let lock_path = manifest_dir.join("Cargo.lock");

        if lock_path.is_file() {
            if let Ok(lock) = Self::from_file(&lock_path) {
                if lock
                    .validate_manifest_compatibility(manifest_path)
                    .unwrap_or(false)
                {
                    return Ok(lock);
                }
            }
        }

        if offline {
            bail!("Cargo.lock is missing or incompatible and --offline was specified");
        }

        // Sync-path: standalone native resolution via crates.io sparse index
        let resolver = super::resolver::NativeResolver::for_cache_dir(cache_dir)?;
        let lock = resolver.resolve_manifest(manifest_path, offline)?;
        let _ = lock.write_to_file(&lock_path);
        Ok(lock)
    }
}

/// Helper function to parse top-level dependency names from Cargo.toml content.
fn extract_manifest_dependencies(content: &str) -> Vec<String> {
    let mut deps = Vec::new();
    let mut in_dep_section = false;

    for line in content.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }

        if line.starts_with('[') && line.ends_with(']') {
            let section = line[1..line.len() - 1].trim();
            if let Some(sub) = section
                .strip_prefix("dependencies.")
                .or_else(|| section.strip_prefix("build-dependencies."))
                .or_else(|| section.strip_prefix("dev-dependencies."))
            {
                let dep_name = sub.trim().trim_matches('"');
                if !dep_name.is_empty() {
                    deps.push(dep_name.to_string());
                }
                in_dep_section = false;
                continue;
            }
            in_dep_section = matches!(
                section,
                "dependencies"
                    | "dev-dependencies"
                    | "build-dependencies"
                    | "workspace.dependencies"
            ) || (section.starts_with("target.")
                && (section.ends_with(".dependencies")
                    || section.ends_with(".build-dependencies")
                    || section.ends_with(".dev-dependencies")));
            continue;
        }

        if in_dep_section {
            if let Some((dep_name, _)) = line.split_once('=') {
                let dep_name = dep_name.trim().trim_matches('"');
                if !dep_name.is_empty() {
                    deps.push(dep_name.to_string());
                }
            }
        }
    }

    deps.sort();
    deps.dedup();
    deps
}