openlatch-provider 0.1.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! `editor update` — PATCH /api/v1/editor/profile.

use crate::api::editor::{update_profile, EditorProfilePatch};
use crate::cli::commands::shared::make_client;
use crate::cli::{EditorAction, GlobalArgs};
use crate::config;
use crate::error::{OlError, OL_4210_SCHEMA_MISMATCH};
use crate::ui::output::OutputConfig;
use clap::Args;

#[derive(Args, Debug)]
pub struct UpdateArgs {
    #[arg(long)]
    pub display_name: Option<String>,
    #[arg(long)]
    pub description: Option<String>,
    #[arg(long)]
    pub homepage_url: Option<String>,
    #[arg(long)]
    pub docs_url: Option<String>,
}

pub async fn run(g: &GlobalArgs, action: EditorAction) -> Result<(), OlError> {
    match action {
        EditorAction::Update(args) => update(g, args).await,
    }
}

async fn update(g: &GlobalArgs, args: UpdateArgs) -> Result<(), OlError> {
    let out = OutputConfig::resolve(g);
    let mut patch = EditorProfilePatch {
        display_name: args.display_name,
        description: args.description,
        homepage_url: args.homepage_url,
        docs_url: args.docs_url,
    };

    let no_flags = patch.display_name.is_none()
        && patch.description.is_none()
        && patch.homepage_url.is_none()
        && patch.docs_url.is_none();

    let prompted = no_flags && out.interactive;
    if prompted {
        crate::ui::header::print(&out, &["editor", "update"]);
        print_context(&out, g);
        let current = load_current_editor(g.profile.as_deref());
        patch = prompt_editor_patch(&out, current)?;
    }

    if patch.display_name.is_none()
        && patch.description.is_none()
        && patch.homepage_url.is_none()
        && patch.docs_url.is_none()
    {
        if prompted {
            out.print_info("");
            out.print_info(
                "No changes — every field matched the current value, so there's nothing \
                 to send. Re-run `editor update` and edit a field to push a change.",
            );
        } else {
            out.print_info(
                "Nothing to update — pass at least one of --display-name / --description / --homepage-url / --docs-url",
            );
        }
        return Ok(());
    }
    if g.dry_run {
        out.print_step("--dry-run: would PATCH /api/v1/editor/profile");
        out.print_json(&patch);
        return Ok(());
    }
    let client = make_client().await?;
    let resp = update_profile(&client, &patch).await?;
    if out.is_machine() {
        out.print_json(&resp);
    } else {
        out.print_step("Editor profile updated");
    }
    Ok(())
}

/// Print which config profile + manifest is active so the user knows what
/// they're about to PATCH before they start typing answers.
fn print_context(out: &OutputConfig, g: &GlobalArgs) {
    let profile = g.profile.as_deref().unwrap_or("default");
    out.print_substep(&format!("Profile:  {profile}"));
    match config::active_manifest_path(g.profile.as_deref()) {
        Ok(path) => out.print_substep(&format!("Manifest: {}", path.display())),
        Err(_) => {
            out.print_substep("Manifest: (none — run `openlatch-provider init` to scaffold one)")
        }
    }
}

/// Snapshot of the current editor block in the local manifest, used to
/// pre-populate prompts so `editor update` feels like an edit rather than a
/// fresh entry. All fields are best-effort — if the manifest is missing or
/// unreadable, we fall back to empty inputs.
#[derive(Debug, Default)]
struct CurrentEditor {
    display_name: Option<String>,
    description: Option<String>,
    homepage_url: Option<String>,
    docs_url: Option<String>,
}

fn load_current_editor(profile: Option<&str>) -> Option<CurrentEditor> {
    let path = config::active_manifest_path(profile).ok()?;
    let m = crate::manifest::load(&path).ok()?;
    Some(CurrentEditor {
        display_name: Some(m.editor.display_name.to_string()),
        description: m.editor.description.as_ref().map(|d| d.to_string()),
        homepage_url: m.editor.homepage_url.clone(),
        docs_url: m.editor.docs_url.clone(),
    })
}

/// Interactive prompts for the editor patch. Each field is pre-populated with
/// the current manifest value (when available); an unchanged answer is omitted
/// from the PATCH body so we only send what actually changed.
fn prompt_editor_patch(
    out: &OutputConfig,
    current: Option<CurrentEditor>,
) -> Result<EditorProfilePatch, OlError> {
    out.print_info("");
    if current.is_some() {
        out.print_info(
            "Update your editor profile. Each field is pre-filled with its current \
             value — edit it to change, or press Enter to keep it.",
        );
    } else {
        out.print_info(
            "Update your editor profile. Leave a field empty to keep its current value.",
        );
    }

    let cur = current.unwrap_or_default();

    let display_name = edit_text("Display name:", cur.display_name.as_deref())?;
    let description = edit_text("Description:", cur.description.as_deref())?;
    let homepage_url = edit_text("Homepage URL:", cur.homepage_url.as_deref())?;
    let docs_url = edit_text("Docs URL:", cur.docs_url.as_deref())?;

    Ok(EditorProfilePatch {
        display_name: changed(cur.display_name.as_deref(), display_name.as_deref()),
        description: changed(cur.description.as_deref(), description.as_deref()),
        homepage_url: changed(cur.homepage_url.as_deref(), homepage_url.as_deref()),
        docs_url: changed(cur.docs_url.as_deref(), docs_url.as_deref()),
    })
}

/// Prompt with the current value pre-loaded into the input buffer, so users
/// can edit it in place. Empty / whitespace-only answers map to `None`.
fn edit_text(message: &str, current: Option<&str>) -> Result<Option<String>, OlError> {
    use inquire::Text;
    let mut prompt = Text::new(message);
    if let Some(v) = current {
        prompt = prompt.with_initial_value(v);
    }
    let answer = prompt
        .prompt()
        .map_err(|e| OlError::new(OL_4210_SCHEMA_MISMATCH, format!("prompt: {e}")))?;
    let trimmed = answer.trim();
    Ok(if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    })
}

/// Decide whether to include a field in the PATCH. Empty answers and answers
/// equal to the current value are treated as no-ops; only genuine edits are
/// sent. (To clear a field, pass an explicit `--<flag>=""` from the CLI.)
fn changed(current: Option<&str>, new: Option<&str>) -> Option<String> {
    match (current, new) {
        (_, None) => None,
        (Some(c), Some(n)) if c == n => None,
        (_, Some(n)) => Some(n.to_string()),
    }
}