draftline 0.2.2

Git-native versioning for creative content workflows.
Documentation
use std::env;
use std::path::PathBuf;

use draftline::{DiagnosticCode, DraftlineError, Workspace};
use serde::Serialize;

enum CliFailure {
    Usage(String),
    Draftline(DraftlineError),
}

impl std::fmt::Display for CliFailure {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CliFailure::Usage(message) => formatter.write_str(message),
            CliFailure::Draftline(error) => error.fmt(formatter),
        }
    }
}

impl From<DraftlineError> for CliFailure {
    fn from(error: DraftlineError) -> Self {
        CliFailure::Draftline(error)
    }
}

impl From<serde_json::Error> for CliFailure {
    fn from(error: serde_json::Error) -> Self {
        CliFailure::Draftline(error.into())
    }
}

#[derive(Serialize)]
struct CliError {
    code: &'static str,
    message: String,
}

fn main() {
    if let Err(error) = run() {
        let cli_error = CliError {
            code: match &error {
                CliFailure::Usage(_) => "invalid_arguments",
                CliFailure::Draftline(_) => "command_failed",
            },
            message: error.to_string(),
        };
        eprintln!(
            "{}",
            serde_json::to_string(&cli_error).unwrap_or_else(|_| "{}".to_string())
        );
        std::process::exit(1);
    }
}

fn run() -> Result<(), CliFailure> {
    let mut args = env::args().skip(1).collect::<Vec<_>>();
    if args.iter().any(|arg| arg == "--help" || arg == "-h") || args.is_empty() {
        print_help();
        return Ok(());
    }

    let command = args.remove(0);
    match command.as_str() {
        "inspect" => {
            require_json(&mut args)?;
            let workspace = Workspace::open(workspace_path(args)?)?;
            println!("{}", workspace.inspect_json()?);
        }
        "capabilities" => {
            require_json(&mut args)?;
            require_no_args(&args)?;
            println!("{}", Workspace::capabilities_json()?);
        }
        "verify" => {
            require_json(&mut args)?;
            let workspace = Workspace::open(workspace_path(args)?)?;
            println!("{}", serde_json::to_string(&workspace.verify_workspace()?)?);
        }
        "explain-error" => {
            require_json(&mut args)?;
            let [code] = args.as_slice() else {
                return Err(CliFailure::Usage(
                    "expected exactly one diagnostic code".to_string(),
                ));
            };
            println!(
                "{}",
                serde_json::to_string(&Workspace::explain_error(parse_diagnostic_code(code)?))?
            );
        }
        _ => {
            return Err(CliFailure::Usage(format!("unknown command: {command}")));
        }
    }

    Ok(())
}

fn require_no_args(args: &[String]) -> Result<(), CliFailure> {
    if args.is_empty() {
        Ok(())
    } else {
        Err(CliFailure::Usage("unexpected arguments".to_string()))
    }
}

fn require_json(args: &mut Vec<String>) -> Result<(), CliFailure> {
    if let Some(index) = args.iter().position(|arg| arg == "--json") {
        args.remove(index);
        Ok(())
    } else {
        Err(CliFailure::Usage("--json is required".to_string()))
    }
}

fn workspace_path(args: Vec<String>) -> Result<PathBuf, CliFailure> {
    match args.as_slice() {
        [] => env::current_dir().map_err(|error| CliFailure::Draftline(error.into())),
        [path] => Ok(PathBuf::from(path)),
        _ => Err(CliFailure::Usage(
            "expected at most one workspace path".to_string(),
        )),
    }
}

fn parse_diagnostic_code(code: &str) -> Result<DiagnosticCode, CliFailure> {
    match code {
        "recovery_required" => Ok(DiagnosticCode::RecoveryRequired),
        "workspace_locked" => Ok(DiagnosticCode::WorkspaceLocked),
        "dirty_workspace" => Ok(DiagnosticCode::DirtyWorkspace),
        "local_only_workspace" => Ok(DiagnosticCode::LocalOnlyWorkspace),
        "shared_capable_workspace" => Ok(DiagnosticCode::SharedCapableWorkspace),
        "no_current_variation" => Ok(DiagnosticCode::NoCurrentVariation),
        "workspace_read_failed" => Ok(DiagnosticCode::WorkspaceReadFailed),
        "policy_tracked_file_ignored" => Ok(DiagnosticCode::PolicyTrackedFileIgnored),
        _ => Err(CliFailure::Usage(format!(
            "unknown diagnostic code: {code}"
        ))),
    }
}

fn print_help() {
    println!(
        "draftline inspect --json [path]\n\
         draftline capabilities --json\n\
         draftline verify --json [path]\n\
         draftline explain-error --json <diagnostic_code>"
    );
}