use std::path::Path;
use anyhow::{Context, Result, bail};
use inquire::Text;
use owo_colors::OwoColorize;
use crate::cli::NewArgs;
use crate::commands::ShokaContext;
use crate::commands::clone::clone_and_record;
use crate::config::ShokaConfig;
use crate::gh;
pub async fn run(ctx: &ShokaContext, args: NewArgs) -> Result<()> {
let cfg = ShokaConfig::load(&ctx.paths)?.resolve(ctx.profile_override.as_deref())?;
let (owner, name) = resolve_owner_name(args.spec.as_deref(), &cfg.ui.own_owners).await?;
let slug = format!("{owner}/{name}");
gh_repo_create(&slug, args.private, args.description.as_deref()).await?;
let repo = clone_and_record(ctx, &cfg, &slug).await?;
let dest = cfg.clone_path_for_one(&repo)?;
let preset = if args.no_kata {
None
} else {
args.preset.or_else(|| cfg.raw.global.new.preset.clone())
};
match preset {
Some(p) => {
if let Err(e) = kata_init(&p, &dest).await {
eprintln!(
"{} kata init failed ({e:#}) — repo is created and cloned; \
re-run `kata init {p} --at {}` by hand",
"new:".bold(),
dest.display()
);
}
}
None => {
println!(
"{} no kata preset (set [global.new].preset or pass --preset) — \
skipped scaffolding",
"new:".bold()
);
}
}
println!("{} created {} at {}", "new:".bold(), slug, dest.display());
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
enum Spec {
Full { owner: String, name: String },
NameOnly { name: String },
}
fn parse_spec(spec: &str) -> Result<Spec> {
match spec.split_once('/') {
Some((owner, name)) => {
let owner = owner.trim();
let name = name.trim();
validate_segment(owner, "owner")?;
validate_segment(name, "name")?;
Ok(Spec::Full {
owner: owner.to_string(),
name: name.to_string(),
})
}
None => {
let name = spec.trim();
validate_segment(name, "name")?;
Ok(Spec::NameOnly {
name: name.to_string(),
})
}
}
}
fn validate_segment(seg: &str, what: &str) -> Result<()> {
if seg.is_empty() {
bail!("{what} must not be empty");
}
if seg.contains('/') || seg.chars().any(char::is_whitespace) {
bail!("{what} `{seg}` has an invalid character (no slashes or spaces)");
}
Ok(())
}
async fn resolve_owner_name(spec: Option<&str>, own_owners: &[String]) -> Result<(String, String)> {
match spec {
Some(s) => match parse_spec(s)? {
Spec::Full { owner, name } => Ok((owner, name)),
Spec::NameOnly { name } => Ok((default_owner(own_owners).await?, name)),
},
None => {
let owner = default_owner(own_owners).await?;
let help = format!("created under {owner}/");
let name = Text::new("new repo name:")
.with_help_message(&help)
.prompt()
.context("repo name prompt cancelled")?;
let name = name.trim();
validate_segment(name, "name")?;
Ok((owner, name.to_string()))
}
}
}
async fn default_owner(own_owners: &[String]) -> Result<String> {
if let Some(first) = own_owners.first() {
return Ok(first.clone());
}
if let Some(token) = gh::resolve_token().await {
let client = gh::build_client(&token)
.context("building a GitHub client to resolve the default owner")?;
let login = gh::whoami(&client).await.context(
"looking up your GitHub login to default the owner — \
pass `owner/name` explicitly to skip this lookup",
)?;
return Ok(login);
}
bail!(
"can't determine the owner — pass `owner/name` explicitly or set \
[global.ui].own_owners in config"
)
}
async fn gh_repo_create(slug: &str, private: bool, description: Option<&str>) -> Result<()> {
let gh = which::which("gh").context("`gh` not found on PATH (required for `shoka new`)")?;
let mut cmd = tokio::process::Command::new(&gh);
cmd.arg("repo").arg("create").arg(slug);
cmd.arg(if private { "--private" } else { "--public" });
cmd.arg("--add-readme");
if let Some(d) = description {
cmd.arg("--description").arg(d);
}
#[cfg(windows)]
cmd.creation_flags(crate::silent_creation_flags());
let status = cmd
.status()
.await
.with_context(|| format!("spawning `{} repo create`", gh.display()))?;
if !status.success() {
bail!("`gh repo create {slug}` exited with status {status}");
}
Ok(())
}
async fn kata_init(preset: &str, dest: &Path) -> Result<()> {
let kata =
which::which("kata").context("`kata` not found on PATH (skip kata with --no-kata)")?;
println!(
"{} kata init {} (at {})",
"new:".bold(),
preset,
dest.display()
);
let status = tokio::process::Command::new(&kata)
.arg("init")
.arg(preset)
.arg("--at")
.arg(dest)
.status()
.await
.with_context(|| format!("spawning `{} init`", kata.display()))?;
if !status.success() {
bail!("`kata init {preset}` exited with status {status}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_spec_splits_owner_and_name() {
assert_eq!(
parse_spec("yukimemi/shoka").unwrap(),
Spec::Full {
owner: "yukimemi".into(),
name: "shoka".into()
}
);
}
#[test]
fn parse_spec_bare_name_defers_owner() {
assert_eq!(
parse_spec("shoka").unwrap(),
Spec::NameOnly {
name: "shoka".into()
}
);
}
#[test]
fn parse_spec_trims_surrounding_whitespace() {
assert_eq!(
parse_spec(" yukimemi / shoka ").unwrap(),
Spec::Full {
owner: "yukimemi".into(),
name: "shoka".into()
}
);
}
#[test]
fn parse_spec_rejects_empty_segments() {
assert!(parse_spec("owner/").is_err());
assert!(parse_spec("/name").is_err());
assert!(parse_spec(" ").is_err());
}
#[test]
fn parse_spec_rejects_host_owner_name_triple() {
let err = parse_spec("github.com/yukimemi/shoka").unwrap_err();
assert!(
err.to_string().contains("invalid character"),
"expected invalid-character error, got: {err}"
);
}
#[test]
fn validate_segment_flags_spaces() {
assert!(validate_segment("my repo", "name").is_err());
assert!(validate_segment("ok-name_1.2", "name").is_ok());
}
}