aboutsummaryrefslogtreecommitdiff
path: root/mingling_cli/src/proj_mgr
diff options
context:
space:
mode:
Diffstat (limited to 'mingling_cli/src/proj_mgr')
-rw-r--r--mingling_cli/src/proj_mgr/cmd_proj_init.rs50
-rw-r--r--mingling_cli/src/proj_mgr/template_source.rs365
2 files changed, 403 insertions, 12 deletions
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<ResMlingConfig>,
) -> Next {
- let tmpl_dir: Option<DirPath> = args.pick(&arg![Option<DirPath>]).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();
}
@@ -323,6 +342,13 @@ pub fn render_error_template_copy_failed(err: ErrorTemplateCopyFailed) -> Render
}
#[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();
eprintln_cargo!(r, "{}", err.info);
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 `<ref>@<variant>` (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: `<ref>@<variant>`.
+ 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<String>, variant: impl Into<String>) -> Self {
+ Self::Git {
+ reference: reference.into(),
+ variant: variant.into(),
+ }
+ }
+
+ /// Creates a local-directory source.
+ pub fn fs_dir(path: impl Into<PathBuf>) -> Self {
+ Self::FsDir(path.into())
+ }
+}
+
+impl SinglePickable for TemplateSource {
+ fn pick_single(str: Option<&str>) -> PickerArgResult<Self> {
+ let Some(raw) = str else {
+ return PickerArgResult::NotFound;
+ };
+
+ // `<ref>@<variant>` — 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 <PathBuf as SinglePickable>::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
+/// `<cache>/<source-hash>/<ref-hash>/` where `<source-hash>` is the first 16
+/// hex chars of the SHA-256 of the source URL and `<ref-hash>` is the first 16
+/// hex chars of the resolved commit. Already-cached references are reused. The
+/// returned path is `<repo>/<variant>`, validated to contain a `checklist.toml`.
+pub fn resolve_git(
+ source_url: &str,
+ reference: &str,
+ variant: &str,
+ cache: &Path,
+) -> Result<PathBuf, String> {
+ 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<String, String> {
+ 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 <url> [pattern]` and return its stdout.
+fn git_ls_remote(source_url: &str, pattern: Option<&str>) -> Result<String, String> {
+ 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 <reference>` 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<const N: usize>(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 <source-hash>/<ref-hash> 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);
+ }
+}