repoctl-cli 0.5.0

Command-line frontend for repoctl.
use std::io::{self, IsTerminal};

use cliclack::{Confirm, Input, MultiSelect, Select, intro, outro};
use repoctl::{
    Diagnostic, IacProvider, OwnerHandle, ProjectKind, ProtoPackageName, RepoRelativePath,
    RepoctlError,
};

use super::{IacProviderArg, NewProjectArgs, OutputFormat};

const APP_ROOT: &str = "apps";
const FRAMEWORK_ROOT: &str = "frameworks";
const FOUNDATION_ROOT: &str = "foundations";
const KNOWN_PROJECT_ROOTS: [&str; 3] = [APP_ROOT, FRAMEWORK_ROOT, FOUNDATION_ROOT];

#[derive(Clone, Copy, Debug)]
pub(super) struct NewProjectPromptContext<'a> {
    pub(super) kind: &'a ProjectKind,
    pub(super) format: OutputFormat,
}

pub(super) trait InteractiveArgs: Sized {
    fn complete_interactively(
        self,
        context: NewProjectPromptContext<'_>,
    ) -> Result<Self, RepoctlError>;
}

impl InteractiveArgs for NewProjectArgs {
    fn complete_interactively(
        mut self,
        context: NewProjectPromptContext<'_>,
    ) -> Result<Self, RepoctlError> {
        if self.path.is_some() {
            return Ok(self);
        }
        if context.format != OutputFormat::Human {
            return Err(RepoctlError::diagnostic(missing_path_diagnostic(
                context.kind,
            )));
        }
        if !can_prompt() {
            return Err(RepoctlError::diagnostic(
                missing_path_diagnostic(context.kind).with_help(format!(
                    "pass a project name, for example `repoctl new {} {}`",
                    project_kind_command(context.kind),
                    example_slug(context.kind),
                )),
            ));
        }

        intro(format!("repoctl new {}", project_kind_label(context.kind))).map_err(prompt_error)?;
        self.path = Some(prompt_project_path(context.kind)?);
        match context.kind {
            ProjectKind::App => {
                if self.stack.is_empty() {
                    self.stack = prompt_app_stack()?;
                }
                if self.iac.is_none() {
                    self.iac = prompt_iac_provider()?;
                }
            }
            ProjectKind::Framework => {
                if self.languages.is_empty() {
                    self.languages = prompt_languages()?;
                }
                if !self.facade {
                    self.facade = prompt_framework_facade()?;
                }
            }
            ProjectKind::FoundationService => {
                if self.clients.is_empty() {
                    self.clients = prompt_clients()?;
                }
                if self.proto.is_none() {
                    self.proto = prompt_proto_package()?;
                }
                if self.iac.is_none() {
                    self.iac = prompt_iac_provider()?;
                }
            }
            ProjectKind::ProtoRoot
            | ProjectKind::CoreInfra
            | ProjectKind::CoreInfraComponent
            | ProjectKind::Tool => {}
        }
        if self.owner.is_none() {
            self.owner = prompt_owner()?;
        }
        outro("Project options collected.").map_err(prompt_error)?;
        Ok(self)
    }
}

pub(super) fn normalize_new_project_path(
    kind: &ProjectKind,
    path: Option<&str>,
) -> Result<RepoRelativePath, Diagnostic> {
    let raw = path
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| missing_path_diagnostic(kind))?;
    let trimmed = raw.trim_matches('/');
    if trimmed.is_empty() {
        return Err(missing_path_diagnostic(kind));
    }
    let expected_root = project_root(kind)?;
    if trimmed == expected_root {
        return Err(Diagnostic::error(
            "new.path.missing_slug",
            format!("project path must include a name below `{expected_root}/`"),
        )
        .with_help(format!(
            "use `{expected_root}/{}` or just `{}`",
            example_slug(kind),
            example_slug(kind),
        )));
    }
    let first_segment = trimmed.split('/').next().unwrap_or_default();
    let normalized = if KNOWN_PROJECT_ROOTS.contains(&first_segment) {
        if first_segment == expected_root {
            trimmed.to_string()
        } else {
            return Err(Diagnostic::error(
                "new.path.kind_mismatch",
                format!(
                    "`{}` creates {} projects, so the path must live under `{expected_root}/`",
                    project_kind_command(kind),
                    project_kind_label(kind),
                ),
            )
            .with_path(trimmed)
            .with_help(format!(
                "use `{expected_root}/{}` or run `repoctl new {first_segment_singular} {trimmed}`",
                trimmed
                    .rsplit('/')
                    .next()
                    .unwrap_or_else(|| example_slug(kind)),
                first_segment_singular = root_to_command(first_segment),
            )));
        }
    } else {
        format!("{expected_root}/{trimmed}")
    };
    RepoRelativePath::new(normalized)
}

fn can_prompt() -> bool {
    io::stdin().is_terminal() && io::stderr().is_terminal()
}

fn prompt_project_path(kind: &ProjectKind) -> Result<String, RepoctlError> {
    let expected_root = project_root(kind).map_err(RepoctlError::diagnostic)?;
    let mut prompt = Input::new(format!("{} name", project_kind_label(kind)))
        .placeholder(example_slug(kind))
        .validate({
            let kind = kind.clone();
            move |value: &String| {
                normalize_new_project_path(&kind, Some(value))
                    .map(|_| ())
                    .map_err(|diagnostic| diagnostic.message.to_string())
            }
        });
    let value: String = prompt.interact().map_err(prompt_error)?;
    let normalized = normalize_new_project_path(kind, Some(&value))
        .map_err(RepoctlError::diagnostic)?
        .as_str()
        .to_string();
    let display = normalized
        .strip_prefix(&format!("{expected_root}/"))
        .unwrap_or(normalized.as_str());
    Ok(display.to_string())
}

fn prompt_app_stack() -> Result<Vec<String>, RepoctlError> {
    let mut prompt = MultiSelect::new("App stack")
        .item(
            "rust-api".to_string(),
            "Rust API",
            "service crate with Cargo tasks",
        )
        .item("bun-web".to_string(), "Bun web", "TypeScript web workspace")
        .item("uv-jobs".to_string(), "uv jobs", "Python jobs workspace")
        .initial_values(vec!["rust-api".to_string()]);
    prompt.interact().map_err(prompt_error)
}

fn prompt_languages() -> Result<Vec<String>, RepoctlError> {
    let mut prompt = MultiSelect::new("Framework languages")
        .item("rust".to_string(), "Rust", "facade and internal crates")
        .item(
            "typescript".to_string(),
            "TypeScript",
            "facade and internal packages",
        )
        .initial_values(vec!["rust".to_string()]);
    prompt.interact().map_err(prompt_error)
}

fn prompt_clients() -> Result<Vec<String>, RepoctlError> {
    let mut prompt = MultiSelect::new("Foundation clients")
        .item("rust".to_string(), "Rust", "Cargo client crate")
        .item("typescript".to_string(), "TypeScript", "Bun client package")
        .item("python".to_string(), "Python", "uv client package")
        .initial_values(vec!["rust".to_string()]);
    prompt.interact().map_err(prompt_error)
}

fn prompt_framework_facade() -> Result<bool, RepoctlError> {
    let mut prompt = Confirm::new("Include public facade and internal implementation areas?")
        .initial_value(true);
    prompt.interact().map_err(prompt_error)
}

fn prompt_iac_provider() -> Result<Option<IacProviderArg>, RepoctlError> {
    let mut prompt = Select::new("IaC provider")
        .item(None, "None", "skip infrastructure scaffold")
        .item(
            Some(IacProvider::Pulumi),
            "Pulumi",
            "generate Pulumi stacks",
        )
        .item(
            Some(IacProvider::Terraform),
            "Terraform",
            "generate Terraform stacks",
        )
        .item(
            Some(IacProvider::OpenTofu),
            "OpenTofu",
            "generate OpenTofu stacks",
        )
        .initial_value(None);
    let value = prompt.interact().map_err(prompt_error)?;
    Ok(value.map(|provider| match provider {
        IacProvider::Pulumi => IacProviderArg::Pulumi,
        IacProvider::Terraform => IacProviderArg::Terraform,
        IacProvider::OpenTofu => IacProviderArg::Opentofu,
    }))
}

fn prompt_proto_package() -> Result<Option<String>, RepoctlError> {
    let mut prompt = Input::new("Owned proto package")
        .placeholder("company.identity.v1")
        .required(false)
        .validate(|value: &String| {
            if value.trim().is_empty() {
                Ok(())
            } else {
                ProtoPackageName::new(value.trim().to_string())
                    .map(|_| ())
                    .map_err(|diagnostic| diagnostic.message.to_string())
            }
        });
    let value: String = prompt.interact().map_err(prompt_error)?;
    let trimmed = value.trim();
    if trimmed.is_empty() {
        Ok(None)
    } else {
        Ok(Some(trimmed.to_string()))
    }
}

fn prompt_owner() -> Result<Option<String>, RepoctlError> {
    let mut prompt = Input::new("Owner handle")
        .placeholder("@platform")
        .required(false)
        .validate(|value: &String| {
            if value.trim().is_empty() {
                Ok(())
            } else {
                OwnerHandle::new(value.trim().to_string())
                    .map(|_| ())
                    .map_err(|diagnostic| diagnostic.message.to_string())
            }
        });
    let value: String = prompt.interact().map_err(prompt_error)?;
    let trimmed = value.trim();
    if trimmed.is_empty() {
        Ok(None)
    } else {
        Ok(Some(trimmed.to_string()))
    }
}

fn project_root(kind: &ProjectKind) -> Result<&'static str, Diagnostic> {
    match kind {
        ProjectKind::App => Ok(APP_ROOT),
        ProjectKind::Framework => Ok(FRAMEWORK_ROOT),
        ProjectKind::FoundationService => Ok(FOUNDATION_ROOT),
        ProjectKind::Tool => Ok("tools"),
        ProjectKind::ProtoRoot | ProjectKind::CoreInfra | ProjectKind::CoreInfraComponent => {
            Err(Diagnostic::error(
                "new.kind.unsupported",
                "new project supports app, framework, foundation-service, and tool",
            ))
        }
    }
}

fn project_kind_command(kind: &ProjectKind) -> &'static str {
    match kind {
        ProjectKind::App => "app",
        ProjectKind::Framework => "framework",
        ProjectKind::FoundationService => "foundation",
        ProjectKind::ProtoRoot | ProjectKind::CoreInfra | ProjectKind::CoreInfraComponent => {
            "project"
        }
        ProjectKind::Tool => "tool",
    }
}

fn project_kind_label(kind: &ProjectKind) -> &'static str {
    match kind {
        ProjectKind::App => "app",
        ProjectKind::Framework => "framework",
        ProjectKind::FoundationService => "foundation service",
        ProjectKind::ProtoRoot => "proto root",
        ProjectKind::CoreInfra => "core infrastructure",
        ProjectKind::CoreInfraComponent => "core infrastructure component",
        ProjectKind::Tool => "tool",
    }
}

fn example_slug(kind: &ProjectKind) -> &'static str {
    match kind {
        ProjectKind::App => "catalog",
        ProjectKind::Framework => "core",
        ProjectKind::FoundationService => "identity",
        ProjectKind::ProtoRoot => "protos",
        ProjectKind::CoreInfra => "core-infra",
        ProjectKind::CoreInfraComponent => "nucleus",
        ProjectKind::Tool => "skills",
    }
}

fn root_to_command(root: &str) -> &'static str {
    match root {
        APP_ROOT => "app",
        FRAMEWORK_ROOT => "framework",
        FOUNDATION_ROOT => "foundation",
        _ => "project",
    }
}

fn missing_path_diagnostic(kind: &ProjectKind) -> Diagnostic {
    Diagnostic::error(
        "new.path.required",
        format!("missing {} name or path", project_kind_label(kind)),
    )
}

fn prompt_error(error: impl std::fmt::Display) -> RepoctlError {
    RepoctlError::diagnostic(Diagnostic::error(
        "cli.prompt.failed",
        format!("interactive prompt failed: {error}"),
    ))
}