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;
pub(crate) const MIN_SUPPORTED_PY_MINOR: u8 = 11;
const LATEST_KNOWN_STABLE_PY: u8 = 13;
#[derive(Args, Debug)]
pub struct InitArgs {
#[arg(long)]
pub force: bool,
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)]
}
pub fn expand_requires_python(spec: &str) -> Result<Vec<PythonVersion>> {
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)]
);
}
}