From fe8f27fe05d5258f3ef754a82467e4b7fdddd2c9 Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Sun, 9 Aug 2026 16:30:31 +0800 Subject: feat: add remote template source support via git refs Allow `proj-init` to accept a `@` template source that resolves and caches templates from a git repository, falling back to local directories for plain paths. --- mingling_cli/src/proj_mgr/cmd_proj_init.rs | 50 +++- mingling_cli/src/proj_mgr/template_source.rs | 365 +++++++++++++++++++++++++++ 2 files changed, 403 insertions(+), 12 deletions(-) create mode 100644 mingling_cli/src/proj_mgr/template_source.rs (limited to 'mingling_cli/src/proj_mgr') diff --git a/mingling_cli/src/proj_mgr/cmd_proj_init.rs b/mingling_cli/src/proj_mgr/cmd_proj_init.rs index 756965e..82f3bf1 100644 --- a/mingling_cli/src/proj_mgr/cmd_proj_init.rs +++ b/mingling_cli/src/proj_mgr/cmd_proj_init.rs @@ -6,18 +6,21 @@ use std::{ use just_template::Template; use mingling::{ - Grouped, RenderResult, Routable, + Grouped, LazyRes, RenderResult, Routable, macros::{arg, chain, command, metadata, pack, pack_err, r_println, renderer, routeify}, metadata::Description, - picker::{EntryPicker, value::DirPath}, + picker::EntryPicker, res::ResCurrentDir, }; -use crate::{Entry, Next, eprintln_cargo, hprintln_cargo, println_cargo}; +use crate::{Entry, Next, config::ResMlingConfig, eprintln_cargo, hprintln_cargo, println_cargo}; use super::rule_solver::{eval_rule, parse_checklist, parse_rules, resolve_answers}; +use super::template_source::{ + DEFAULT_TMPL_SOURCE, TemplateSource, cache_dir, normalize_source, resolve_git, +}; -/// The name of the checklist file the user edits and we re-read on generate. +/// The checklist filename that the user edits and is re-read during generation. const CHECKLIST_FILENAME: &str = "checklist.toml"; /// The name of the rules file declaring default params, display blocks and hides. const RULE_FILENAME: &str = "rule.toml"; @@ -46,6 +49,7 @@ pub struct ResultProjectGenerate { pack_err!(ErrorTemplateNotProvided = ()); pack_err!(ErrorTemplateCopyFailed = String); +pack_err!(ErrorTemplateFetchFailed = String); pack_err!(ErrorChecklistMissing = String); pack_err!(ErrorRuleParseFailed = String); pack_err!(ErrorTemplateExpandFailed = String); @@ -60,18 +64,33 @@ pub fn proj_init(args: Entry, cwd: &ResCurrentDir) -> Next { } } -/// Phase 1: copy the user-provided template directory into +/// Phase 1: resolve the user-provided template source into /// `./.mling/tmpl-cache/` and hand the checklist over for editing. #[chain(routeify)] pub fn handle_state_proj_checklist_ready( args: StateProjectChecklistReady, cwd: &ResCurrentDir, + config: &mut LazyRes, ) -> Next { - let tmpl_dir: Option = args.pick(&arg![Option]).to_result()?; - - // Validate - let Some(tmpl_dir) = tmpl_dir else { - return ErrorTemplateNotProvided::new(()).to_chain(); + let source: TemplateSource = args + .pick_or_route(&arg![TemplateSource], || { + ErrorTemplateNotProvided::new(()).to_chain() + }) + .to_result()?; + + // Resolve the template root directory. + let template_root: PathBuf = match source { + TemplateSource::FsDir(dir) => dir, + TemplateSource::Git { reference, variant } => { + let configured = config.get_ref().get("tmpl-source"); + let source_url = normalize_source(if configured.is_empty() { + DEFAULT_TMPL_SOURCE + } else { + configured + }); + resolve_git(&source_url, &reference, &variant, &cache_dir()) + .map_err(ErrorTemplateFetchFailed::new)? + } }; // Copy the template directory into the .mling directory under the current @@ -80,7 +99,7 @@ pub fn handle_state_proj_checklist_ready( fs::create_dir_all(&tmpl_cache).map_err(|e| { ErrorTemplateCopyFailed::new(format!("failed to create {}: {e}", tmpl_cache.display())) })?; - copy_dir_contents(&tmpl_dir, &tmpl_cache) + copy_dir_contents(&template_root, &tmpl_cache) .map_err(|e| ErrorTemplateCopyFailed::new(e.to_string()))?; // Move the internal checklist.toml to ./ for the user to fill in @@ -88,7 +107,7 @@ pub fn handle_state_proj_checklist_ready( if !checklist_src.is_file() { return ErrorChecklistMissing::new(format!( "no checklist.toml found inside {}", - tmpl_dir.display() + template_root.display() )) .to_chain(); } @@ -322,6 +341,13 @@ pub fn render_error_template_copy_failed(err: ErrorTemplateCopyFailed) -> Render r } +#[renderer] +pub fn render_error_template_fetch_failed(err: ErrorTemplateFetchFailed) -> RenderResult { + let mut r = RenderResult::new(); + eprintln_cargo!(r, "failed to fetch template: {}", err.info); + r +} + #[renderer] pub fn render_error_checklist_missing(err: ErrorChecklistMissing) -> RenderResult { let mut r = RenderResult::new(); diff --git a/mingling_cli/src/proj_mgr/template_source.rs b/mingling_cli/src/proj_mgr/template_source.rs new file mode 100644 index 0000000..6364512 --- /dev/null +++ b/mingling_cli/src/proj_mgr/template_source.rs @@ -0,0 +1,365 @@ +//! Template source resolution for `proj-init`. +//! +//! A template source is either: +//! - a git remote template addressed as `@` (e.g. `0.4@basic`), +//! where `ref` is a tag, branch or commit hash and `variant` is a +//! subdirectory of the repository; +//! - a local template directory given by a plain path. + +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; + +use mingling::picker::{PickerArgResult, SinglePickable}; + +/// Default template source when none is configured: the `mingling-rs/tmpl` +/// repository on GitHub. +pub const DEFAULT_TMPL_SOURCE: &str = "mingling-rs/tmpl"; + +/// The template cache root: `~/.local/share/mingling/cache`. +pub fn cache_dir() -> PathBuf { + dirs::data_local_dir() + .unwrap_or_default() + .join("mingling") + .join("cache") +} + +/// Normalize a template source spec into a full git URL. +/// +/// - `mingling-rs/tmpl` -> `https://github.com/mingling-rs/tmpl.git` +/// - `https://github.com/mingling-rs/tmpl` -> `https://github.com/mingling-rs/tmpl.git` +/// - `https://example.com/tmpl.git` -> unchanged +pub fn normalize_source(source: &str) -> String { + if source.ends_with(".git") { + return source.to_string(); + } + if source.contains("://") { + // Ensure GitHub URLs share the same cache key as the short form. + if let Some(rest) = source.strip_prefix("https://github.com/") { + return format!("https://github.com/{rest}.git"); + } + return source.to_string(); + } + if let Some((owner, repo)) = source.split_once('/') { + return format!("https://github.com/{owner}/{repo}.git"); + } + source.to_string() +} + +/// A template source provided by the user. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateSource { + /// Git remote template: `@`. + Git { reference: String, variant: String }, + /// Local template directory. + FsDir(PathBuf), +} + +impl TemplateSource { + /// Creates a git source from a `ref@variant` spec. + pub fn git(reference: impl Into, variant: impl Into) -> Self { + Self::Git { + reference: reference.into(), + variant: variant.into(), + } + } + + /// Creates a local-directory source. + pub fn fs_dir(path: impl Into) -> Self { + Self::FsDir(path.into()) + } +} + +impl SinglePickable for TemplateSource { + fn pick_single(str: Option<&str>) -> PickerArgResult { + let Some(raw) = str else { + return PickerArgResult::NotFound; + }; + + // `@` — both sides non-empty. + if let Some((reference, variant)) = raw.split_once('@') + && !reference.is_empty() + && !variant.is_empty() + { + return PickerArgResult::Parsed(Self::git(reference, variant)); + } + + // Plain path — reuse the PathBuf parsing (handles `~` expansion etc.). + match ::pick_single(str) { + PickerArgResult::Parsed(path) => PickerArgResult::Parsed(Self::FsDir(path)), + PickerArgResult::NotFound => PickerArgResult::NotFound, + PickerArgResult::Unparsed => PickerArgResult::Unparsed, + } + } +} + +/// Resolve a git template source to a local template directory. +/// +/// The repository is shallow-cloned into +/// `///` where `` is the first 16 +/// hex chars of the SHA-256 of the source URL and `` is the first 16 +/// hex chars of the resolved commit. Already-cached references are reused. The +/// returned path is `/`, validated to contain a `checklist.toml`. +pub fn resolve_git( + source_url: &str, + reference: &str, + variant: &str, + cache: &Path, +) -> Result { + let source_hash = sha256_prefix16(source_url); + let full_hash = resolve_commit(source_url, reference)?; + let ref_hash = &full_hash[..16]; + let repo_dir = cache.join(source_hash).join(ref_hash); + + if !repo_dir.join(".git").is_dir() { + shallow_clone(source_url, reference, &full_hash, &repo_dir)?; + } + + let template_dir = repo_dir.join(variant); + if !template_dir.join("checklist.toml").is_file() { + return Err(format!( + "variant `{variant}` has no checklist.toml in repository {source_url}" + )); + } + Ok(template_dir) +} + +/// First 16 hex chars of the SHA-256 digest of `input`. +fn sha256_prefix16(input: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(input.as_bytes()); + let digest = hasher.finalize(); + digest.iter().take(8).map(|b| format!("{b:02x}")).collect() +} + +/// Resolve `reference` (tag / branch / commit hash) to its full commit hash. +/// +/// A full 40-hex reference is used as-is; otherwise `git ls-remote` resolves +/// it — first as a ref name (tag / branch), then by prefix-matching against +/// every advertised ref to support abbreviated commit hashes. +fn resolve_commit(source_url: &str, reference: &str) -> Result { + if reference.len() == 40 && reference.chars().all(|c| c.is_ascii_hexdigit()) { + return Ok(reference.to_string()); + } + + // Ref name resolution (tag / branch). + let by_ref = git_ls_remote(source_url, Some(reference))?; + if let Some(hash) = by_ref + .lines() + .next() + .and_then(|line| line.split('\t').next()) + { + return Ok(hash.to_string()); + } + + // Abbreviated commit hash: prefix-match against all advertised refs. + let all_refs = git_ls_remote(source_url, None)?; + for line in all_refs.lines() { + let Some(hash) = line.split('\t').next() else { + continue; + }; + if hash.starts_with(reference) { + return Ok(hash.to_string()); + } + } + + Err(format!("reference `{reference}` not found in {source_url}")) +} + +/// Run `git ls-remote [pattern]` and return its stdout. +fn git_ls_remote(source_url: &str, pattern: Option<&str>) -> Result { + let mut cmd = Command::new("git"); + cmd.args(["ls-remote", source_url]); + if let Some(pattern) = pattern { + cmd.arg(pattern); + } + let output = cmd + .output() + .map_err(|e| format!("failed to run `git ls-remote`: {e}"))?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// Shallow-clone `reference` from `source_url` into `dst`. +/// +/// Uses `git init` + `git fetch --depth 1 origin ` so that tags and +/// branches are handled uniformly. When the reference is not a ref name (a +/// commit hash), it falls back to fetching the resolved full hash — supported +/// by GitHub, but not by plain local `file://` protocols. +fn shallow_clone( + source_url: &str, + reference: &str, + full_hash: &str, + dst: &Path, +) -> Result<(), String> { + if dst.exists() { + fs::remove_dir_all(dst).map_err(|e| e.to_string())?; + } + fs::create_dir_all(dst).map_err(|e| e.to_string())?; + + run_git(dst, ["init", "-q"])?; + run_git(dst, ["remote", "add", "origin", source_url])?; + + let fetched_by_ref = run_git(dst, ["fetch", "-q", "--depth", "1", "origin", reference]); + if fetched_by_ref.is_err() { + run_git(dst, ["fetch", "-q", "--depth", "1", "origin", full_hash])?; + } + + // `git fetch` only writes FETCH_HEAD; the fresh repo has no branch yet, so + // create one explicitly to populate the working tree. + run_git(dst, ["checkout", "-q", "-B", "cache", "FETCH_HEAD"])?; + Ok(()) +} + +/// Run a git command in `cwd`, returning an error message on failure. +fn run_git(cwd: &Path, args: [&str; N]) -> Result<(), String> { + let status = Command::new("git") + .args(args) + .current_dir(cwd) + .status() + .map_err(|e| format!("failed to run git: {e}"))?; + if !status.success() { + return Err(format!("git command failed with {status}")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Create a local git repository at `dir` with one commit tagged `v0.1` + /// and a `basic` variant subdirectory containing a checklist. + fn make_repo(dir: &Path) { + fs::create_dir_all(dir).unwrap(); + run_git(dir, ["init", "-q", "-b", "main"]).unwrap(); + fs::create_dir_all(dir.join("basic")).unwrap(); + fs::write( + dir.join("basic").join("checklist.toml"), + "program_name = \"x\"\n", + ) + .unwrap(); + fs::write(dir.join("basic").join("rule.toml"), "").unwrap(); + run_git(dir, ["add", "."]).unwrap(); + run_git( + dir, + [ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "commit", + "-q", + "-m", + "init", + ], + ) + .unwrap(); + run_git(dir, ["tag", "v0.1"]).unwrap(); + } + + #[test] + fn parses_ref_at_variant_as_git() { + let source = TemplateSource::pick_single(Some("0.4@basic")).unwrap(); + assert_eq!(source, TemplateSource::git("0.4", "basic")); + } + + #[test] + fn parses_plain_path_as_fs_dir() { + let source = TemplateSource::pick_single(Some("/some/dir")).unwrap(); + assert_eq!(source, TemplateSource::FsDir(PathBuf::from("/some/dir"))); + } + + #[test] + fn missing_input_is_not_found() { + assert!(matches!( + TemplateSource::pick_single(None), + PickerArgResult::NotFound + )); + } + + #[test] + fn empty_variant_falls_back_to_path() { + // `ref@` has an empty variant — treat as a path, not a git source. + let source = TemplateSource::pick_single(Some("0.4@")).unwrap(); + assert_eq!(source, TemplateSource::FsDir(PathBuf::from("0.4@"))); + } + + #[test] + fn sha256_prefix_is_stable_and_16_chars() { + let a = sha256_prefix16("https://example.com/repo.git"); + let b = sha256_prefix16("https://example.com/repo.git"); + let c = sha256_prefix16("https://example.com/other.git"); + assert_eq!(a, b); + assert_eq!(a.len(), 16); + assert_ne!(a, c); + } + + #[test] + fn normalize_source_default_and_configured() { + assert_eq!( + normalize_source(DEFAULT_TMPL_SOURCE), + "https://github.com/mingling-rs/tmpl.git" + ); + assert_eq!( + normalize_source("https://example.com/tmpl.git"), + "https://example.com/tmpl.git" + ); + assert_eq!( + normalize_source("some-one/other-tmpl"), + "https://github.com/some-one/other-tmpl.git" + ); + // GitHub URL without `.git` shares the cache key with the short form. + assert_eq!( + normalize_source("https://github.com/mingling-rs/tmpl"), + "https://github.com/mingling-rs/tmpl.git" + ); + } + + #[test] + fn resolve_git_clones_tag_and_validates_variant() { + let tmp = + std::env::temp_dir().join(format!("mling-tmpl-src-test-{}-clone", std::process::id())); + let repo = tmp.join("repo"); + let cache = tmp.join("cache"); + let _ = fs::remove_dir_all(&tmp); + make_repo(&repo); + + let repo_url = format!("file://{}", repo.display()); + let template = resolve_git(&repo_url, "v0.1", "basic", &cache).unwrap(); + assert!(template.join("checklist.toml").is_file()); + + // Cached under / with a working tree. + let source_hash = sha256_prefix16(&repo_url); + let full_hash = resolve_commit(&repo_url, "v0.1").unwrap(); + let cached = cache.join(source_hash).join(&full_hash[..16]); + assert!(cached.join(".git").is_dir()); + + // Second resolution reuses the cache. + let template_again = resolve_git(&repo_url, "v0.1", "basic", &cache).unwrap(); + assert_eq!(template, template_again); + + let _ = fs::remove_dir_all(&tmp); + } + + #[test] + fn resolve_git_rejects_unknown_variant() { + let tmp = + std::env::temp_dir().join(format!("mling-tmpl-src-test-{}-reject", std::process::id())); + let repo = tmp.join("repo"); + let cache = tmp.join("cache"); + let _ = fs::remove_dir_all(&tmp); + make_repo(&repo); + + let repo_url = format!("file://{}", repo.display()); + let err = resolve_git(&repo_url, "v0.1", "nope", &cache).unwrap_err(); + assert!(err.contains("nope"), "unexpected error: {err}"); + + let _ = fs::remove_dir_all(&tmp); + } +} -- cgit