muntjac 0.1.0

Translate uv.lock into Buck2 build rules
Documentation
use anyhow::{Context, Result};
use clap::Args;
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};

use crate::cli::Globals;
use crate::config::PythonVersion;

/// The minimum Python minor version muntjac supports for new projects.
/// Requires-python constraints below 3.X for X < MIN_SUPPORTED_PY_MINOR are
/// clamped to this floor in `expand_requires_python`. Muntjac's MVP does not
/// validate against 3.10 or earlier; raise this constant only after CI runs
/// against the new minimum.
pub(crate) const MIN_SUPPORTED_PY_MINOR: u8 = 11;

const LATEST_KNOWN_STABLE_PY: u8 = 13;

#[derive(Args, Debug)]
pub struct InitArgs {
    /// Overwrite existing muntjac.toml.
    #[arg(long)]
    pub force: bool,
    /// Target directory (defaults to current dir).
    pub path: Option<PathBuf>,
}

pub struct Detection {
    pub path: PathBuf,
    pub python_versions: Vec<PythonVersion>,
}

pub fn run(args: InitArgs, globals: &Globals) -> Result<()> {
    let target = match args.path {
        Some(p) => p,
        None => globals.workdir().context("resolving working directory")?,
    };
    fs::create_dir_all(&target)
        .with_context(|| format!("creating target directory {}", target.display()))?;

    let cfg_path = target.join("muntjac.toml");
    if cfg_path.exists() && !args.force {
        anyhow::bail!(
            "muntjac.toml already exists at {}; pass --force to overwrite",
            cfg_path.display()
        );
    }

    let detection = find_pyproject(&target);
    let cfg_contents = render_starter_config(&target, detection.as_ref());
    fs::write(&cfg_path, cfg_contents)
        .with_context(|| format!("writing {}", cfg_path.display()))?;

    write_third_party_skeleton(&target)?;
    println!("muntjac.toml written to {}", cfg_path.display());
    Ok(())
}

fn render_starter_config(target: &Path, detection: Option<&Detection>) -> String {
    let (manifest_path, python_versions, banner) = match detection {
        Some(d) => {
            let rel = relative_to(target, &d.path);
            let versions: Vec<String> = d.python_versions.iter()
                .map(|v| format!("\"{}.{}\"", v.0, v.1))
                .collect();
            (
                format!("\"{}\"", rel.display()),
                format!("[{}]", versions.join(", ")),
                String::new(),
            )
        }
        None => (
            "\"TODO: path to your pyproject.toml\"".to_string(),
            "[\"3.12\"]".to_string(),
            "# TODO: muntjac init could not auto-detect a uv project.\n\
             # Edit `manifest_path` to point at your pyproject.toml, then run `muntjac config check`.\n\n".to_string(),
        ),
    };

    format!(
        "{banner}# Generated by `muntjac init`. Edit freely.\n\
         # See docs/superpowers/specs/2026-05-20-muntjac-design.md for full schema.\n\n\
         manifest_path   = {manifest_path}\n\
         third_party_dir = \"third-party/python\"\n\
         python_versions = {python_versions}\n\n\
         [platforms.linux-x86_64-gnu]\n\
         target    = \"x86_64-unknown-linux-gnu\"\n\
         manylinux = \"2_17\"\n\n\
         [platforms.linux-aarch64-gnu]\n\
         target    = \"aarch64-unknown-linux-gnu\"\n\
         manylinux = \"2_17\"\n\n\
         [platforms.linux-x86_64-musl]\n\
         target    = \"x86_64-unknown-linux-musl\"\n\
         musllinux = \"1_2\"\n\n\
         [platforms.macos-x86_64]\n\
         target    = \"x86_64-apple-darwin\"\n\
         macos_min = \"11.0\"\n\n\
         [platforms.macos-arm64]\n\
         target    = \"aarch64-apple-darwin\"\n\
         macos_min = \"11.0\"\n\n\
         # Uncomment to include PEP 735 dependency groups in the resolved graph.\n\
         # [lockfile]\n\
         # include_groups = [\"test\"]\n\n\
         [fixups]\n\
         registry = \"none\"\n\
         # Pin a community fixup registry (recommended for native deps). Example:\n\
         #   registry = \"github.com/rsJames-ttrpg/muntjac-fixups\"\n\
         # Then run `muntjac fixups update` to fetch and pin the latest SHA.\n\
         # For local checkout / offline use: registry = \"file:///abs/path\".\n\
         allow_local_overrides = true\n\n\
         [buck]\n\
         file_name = \"BUCK\"\n\
         vendor    = false\n"
    )
}

fn relative_to(from: &Path, to: &Path) -> PathBuf {
    pathdiff::diff_paths(to, from).unwrap_or_else(|| to.to_path_buf())
}

fn write_third_party_skeleton(target: &Path) -> Result<()> {
    let tp = target.join("third-party/python");
    fs::create_dir_all(&tp).with_context(|| format!("creating {}", tp.display()))?;
    fs::create_dir_all(tp.join("fixups")).context("creating fixups dir")?;

    let buck = tp.join("BUCK");
    if !buck.exists() {
        fs::write(&buck, "# Generated by muntjac. Run: muntjac buckify\n")
            .with_context(|| format!("writing {}", buck.display()))?;
    }
    let gitignore = tp.join(".gitignore");
    if !gitignore.exists() {
        fs::write(
            &gitignore,
            "# muntjac-managed; vendor/ holds downloaded wheels.\nvendor/\n",
        )
        .with_context(|| format!("writing {}", gitignore.display()))?;
    }
    let gitkeep = tp.join("fixups/.gitkeep");
    if !gitkeep.exists() {
        fs::write(&gitkeep, "").with_context(|| format!("writing {}", gitkeep.display()))?;
    }
    Ok(())
}

pub fn find_pyproject(start: &Path) -> Option<Detection> {
    let mut cur = Some(start);
    while let Some(dir) = cur {
        let candidate = dir.join("pyproject.toml");
        if candidate.is_file() {
            if let Ok(bytes) = fs::read_to_string(&candidate) {
                if has_project_or_uv(&bytes) {
                    let python_versions = extract_python_versions(&bytes);
                    return Some(Detection {
                        path: candidate,
                        python_versions,
                    });
                }
            }
        }
        cur = dir.parent();
    }
    None
}

#[derive(Deserialize)]
struct PyprojectProbe {
    project: Option<ProjectTable>,
    tool: Option<ToolTable>,
}

#[derive(Deserialize)]
struct ProjectTable {
    #[serde(rename = "requires-python")]
    requires_python: Option<String>,
}

#[derive(Deserialize)]
struct ToolTable {
    uv: Option<toml::Value>,
}

fn has_project_or_uv(toml_src: &str) -> bool {
    let probe: Result<PyprojectProbe, _> = toml::from_str(toml_src);
    match probe {
        Ok(p) => p.project.is_some() || p.tool.and_then(|t| t.uv).is_some(),
        Err(_) => false,
    }
}

fn extract_python_versions(toml_src: &str) -> Vec<PythonVersion> {
    let probe: Result<PyprojectProbe, _> = toml::from_str(toml_src);
    if let Ok(p) = probe {
        if let Some(rp) = p.project.and_then(|p| p.requires_python) {
            if let Ok(versions) = expand_requires_python(&rp) {
                if !versions.is_empty() {
                    return versions;
                }
            }
        }
    }
    vec![PythonVersion(3, 12)]
}

/// Expand a `requires-python` constraint into a concrete list of Python
/// (major, minor) versions. Constraints below MIN_SUPPORTED_PY_MINOR (3.11)
/// are silently clamped — muntjac does not support Python 3.10 or earlier
/// for new projects.
pub fn expand_requires_python(spec: &str) -> Result<Vec<PythonVersion>> {
    // Very small subset: ">=X.Y", ">=X.Y,<X.Z", "==X.Y.*", "==X.Y".
    let mut min_minor: u8 = MIN_SUPPORTED_PY_MINOR;
    let mut max_minor: u8 = LATEST_KNOWN_STABLE_PY;
    for part in spec.split(',') {
        let part = part.trim();
        if let Some(rest) = part.strip_prefix(">=") {
            let (_, minor) = parse_two(rest)?;
            min_minor = min_minor.max(minor);
        } else if let Some(rest) = part.strip_prefix("<") {
            let (_, minor) = parse_two(rest)?;
            max_minor = max_minor.min(minor.saturating_sub(1));
        } else if let Some(rest) = part.strip_prefix("==") {
            let rest = rest.trim_end_matches(".*");
            let parts: Vec<&str> = rest.split('.').collect();
            if parts.len() >= 2 {
                let minor: u8 = parts[1].parse().context("bad minor in == bound")?;
                min_minor = minor;
                max_minor = minor;
            }
        }
    }
    let mut out = Vec::new();
    for m in min_minor..=max_minor {
        out.push(PythonVersion(3, m));
    }
    Ok(out)
}

fn parse_two(s: &str) -> Result<(u8, u8)> {
    let parts: Vec<&str> = s.split('.').collect();
    let major: u8 = parts
        .first()
        .context("missing major")?
        .parse()
        .context("major not u8")?;
    let minor: u8 = parts
        .get(1)
        .copied()
        .unwrap_or("0")
        .parse()
        .context("minor not u8")?;
    Ok((major, minor))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn detects_pyproject_in_cwd() {
        let dir = tempdir().unwrap();
        fs::write(
            dir.path().join("pyproject.toml"),
            "[project]\nname = \"x\"\nrequires-python = \">=3.11\"\n",
        )
        .unwrap();
        let found = find_pyproject(dir.path()).expect("detected");
        assert_eq!(found.path, dir.path().join("pyproject.toml"));
        assert_eq!(
            found.python_versions,
            vec![
                PythonVersion(3, 11),
                PythonVersion(3, 12),
                PythonVersion(3, 13)
            ]
        );
    }

    #[test]
    fn detects_pyproject_in_parent() {
        let dir = tempdir().unwrap();
        fs::write(
            dir.path().join("pyproject.toml"),
            "[project]\nname = \"x\"\n",
        )
        .unwrap();
        let subdir = dir.path().join("sub");
        fs::create_dir(&subdir).unwrap();
        let found = find_pyproject(&subdir).expect("detected");
        assert_eq!(found.path, dir.path().join("pyproject.toml"));
        assert_eq!(found.python_versions, vec![PythonVersion(3, 12)]);
    }

    #[test]
    fn no_detection_in_empty_tree() {
        let dir = tempdir().unwrap();
        assert!(find_pyproject(dir.path()).is_none());
    }

    #[test]
    fn skips_pyproject_without_project_or_tool_uv() {
        let dir = tempdir().unwrap();
        fs::write(
            dir.path().join("pyproject.toml"),
            "[build-system]\nrequires = []\n",
        )
        .unwrap();
        assert!(find_pyproject(dir.path()).is_none());
    }

    #[test]
    fn detects_pyproject_with_tool_uv() {
        let dir = tempdir().unwrap();
        fs::write(dir.path().join("pyproject.toml"), "[tool.uv]\n").unwrap();
        assert!(find_pyproject(dir.path()).is_some());
    }

    #[test]
    fn expands_requires_python_ranges() {
        assert_eq!(
            expand_requires_python(">=3.10").unwrap(),
            vec![
                PythonVersion(3, 11),
                PythonVersion(3, 12),
                PythonVersion(3, 13)
            ]
        );
        assert_eq!(
            expand_requires_python(">=3.11,<3.13").unwrap(),
            vec![PythonVersion(3, 11), PythonVersion(3, 12)]
        );
        assert_eq!(
            expand_requires_python("==3.12.*").unwrap(),
            vec![PythonVersion(3, 12)]
        );
    }
}