shine-cli 2.1.0

Give personal automation a reviewable lifecycle
Documentation
use std::path::PathBuf;

use clap::{Args, Subcommand, ValueEnum};

#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum PresetTemplateKind {
    App,
    Shell,
    Sys,
}

#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum PresetReportFormat {
    Text,
    Json,
}

#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum PresetPlatform {
    Macos,
    Linux,
    Windows,
}

#[derive(Args, Debug)]
pub struct ExportCommand {
    /// Directory to export presets into. Defaults to the configured presets_dir.
    #[arg(value_name = "DIR")]
    pub dir: Option<PathBuf>,
    /// Overwrite existing files
    #[arg(long, short = 'f')]
    pub force: bool,
}

#[derive(Args, Debug)]
pub struct CopyCommand {
    /// Built-in preset to copy (app/name, shell/name, or sys/name)
    #[arg(value_name = "KIND/NAME", value_parser = parse_copy_target)]
    pub target: String,
    /// Overwrite existing files
    #[arg(long, short = 'f')]
    pub force: bool,
}

pub(crate) fn parse_copy_target(value: &str) -> Result<String, String> {
    if value.contains('\\') {
        return Err(format!(
            "invalid preset target '{value}': expected app/name, shell/name, or sys/name"
        ));
    }

    let mut parts = value.split('/');
    let kind = parts.next().unwrap_or_default();
    let name = parts.next().unwrap_or_default();
    if parts.next().is_some()
        || !matches!(kind, "app" | "shell" | "sys")
        || name.is_empty()
        || matches!(name, "." | "..")
    {
        return Err(format!(
            "invalid preset target '{value}': expected app/name, shell/name, or sys/name"
        ));
    }
    Ok(value.to_string())
}

#[derive(Args, Debug)]
pub struct LinkCommand {
    /// Directory to use as the external presets source.
    #[arg(value_name = "PATH")]
    pub path: PathBuf,
    /// Create the directory if it does not already exist.
    #[arg(long)]
    pub create: bool,
    /// Run external shell source changes on their next invocation.
    #[arg(long)]
    pub live: bool,
}

#[derive(Args, Debug)]
pub struct OverlayLinkCommand {
    /// Directory to use as the presets overlay. Mutually exclusive with --git.
    #[arg(value_name = "PATH", conflicts_with = "git")]
    pub path: Option<PathBuf>,
    /// Git URL for a shine-managed overlay. shine clones it (`--depth 1`) under
    /// `~/.shine/overlay` and keeps it mirrored to the remote tip on `shine preset pull`.
    #[arg(long, value_name = "URL")]
    pub git: Option<String>,
    /// Branch to track for --git. Defaults to the remote's default branch.
    #[arg(long, value_name = "BRANCH", requires = "git")]
    pub branch: Option<String>,
    /// Create the directory if it does not already exist (path mode only).
    #[arg(long)]
    pub create: bool,
}

#[derive(Subcommand, Debug)]
pub enum OverlayCommands {
    /// Set the presets overlay in the active config (local PATH or --git URL).
    Link(OverlayLinkCommand),
    /// Remove the presets overlay from the active config.
    Unlink,
    /// Show information about the active presets overlay.
    Info,
}

#[derive(Subcommand, Debug)]
pub enum PresetCommands {
    /// Create a shine.toml template for a new app, shell, or sys preset
    New {
        #[arg(value_enum)]
        kind: PresetTemplateKind,
        /// Overwrite shine.toml if it already exists
        #[arg(long, short = 'f')]
        force: bool,
    },
    /// Generate the versioned JSON Schema and command-help reference
    Schema {
        /// Output format
        #[arg(long, value_enum, default_value_t = PresetReportFormat::Text)]
        format: PresetReportFormat,
    },
    /// Statically validate preset metadata and referenced files
    Validate {
        /// Preset repository, category directory, or shine.toml (defaults to current directory)
        #[arg(value_name = "PATH", default_value = ".")]
        path: PathBuf,
        /// Output format
        #[arg(long, value_enum, default_value_t = PresetReportFormat::Text)]
        format: PresetReportFormat,
    },
    /// Check Preset author quality, portability, and permission minimization
    Lint {
        /// Preset repository, category directory, or shine.toml (defaults to current directory)
        #[arg(value_name = "PATH", default_value = ".")]
        path: PathBuf,
        /// Output format
        #[arg(long, value_enum, default_value_t = PresetReportFormat::Text)]
        format: PresetReportFormat,
        /// Exit with status 1 when lint warnings are present
        #[arg(long)]
        deny_warnings: bool,
    },
    /// Preview a first install against deterministic synthetic host state
    Plan {
        /// One app, shell, or sys category directory, or its shine.toml
        #[arg(value_name = "CATEGORY", default_value = ".")]
        path: PathBuf,
        /// Target platform for the hypothetical authoring report
        #[arg(long, value_enum)]
        platform: PresetPlatform,
        /// Output format
        #[arg(long, value_enum, default_value_t = PresetReportFormat::Text)]
        format: PresetReportFormat,
    },
    /// Run declarative shine.test.toml authoring fixtures
    Test {
        /// One app, shell, or sys category directory, or its shine.toml
        #[arg(value_name = "CATEGORY", default_value = ".")]
        path: PathBuf,
        /// Output format
        #[arg(long, value_enum, default_value_t = PresetReportFormat::Text)]
        format: PresetReportFormat,
    },
    /// Build a deterministic, policy-gated Preset bundle
    Pack {
        /// One app, shell, or sys category directory, or its shine.toml
        #[arg(value_name = "CATEGORY", default_value = ".")]
        path: PathBuf,
        /// Destination tar.gz bundle path (must be outside the category)
        #[arg(long, value_name = "FILE")]
        output: PathBuf,
        /// Replace an existing output file
        #[arg(long, short = 'f')]
        force: bool,
        /// Report output format
        #[arg(long, value_enum, default_value_t = PresetReportFormat::Text)]
        format: PresetReportFormat,
    },
    /// Review and migrate legacy Preset metadata for Shine 2
    Migrate {
        /// Preset repository, category directory, or shine.toml; defaults to active sources
        #[arg(value_name = "PATH")]
        path: Option<PathBuf>,
        /// Preview migration without creating backups or changing files
        #[arg(long, conflicts_with = "yes")]
        dry_run: bool,
        /// Apply the displayed migration without prompting
        #[arg(long)]
        yes: bool,
        /// Report output format; JSON apply requires --yes
        #[arg(long, value_enum, default_value_t = PresetReportFormat::Text)]
        format: PresetReportFormat,
    },
    /// Copy built-in presets to a directory for local customization
    Export(ExportCommand),
    /// Copy one built-in preset into the current directory
    Copy(CopyCommand),
    /// Set the external presets directory in the active config
    Link(LinkCommand),
    /// Remove the external presets directory from the active config
    Unlink,
    /// Manage the personal presets overlay directory
    Overlay {
        #[command(subcommand)]
        command: OverlayCommands,
    },
    /// Pull Git-managed preset and overlay repositories
    Pull,
}